Apply when designing or implementing asynchronous processing in VTEX IO services through events, workers, and background handlers. Covers event handler…
Events, Workers & Async Processing
When this skill applies
Use this skill when a VTEX IO app needs to process work asynchronously through events, workers, or other background execution patterns.
Consuming broadcasted events from other VTEX services
Running background work that should not block HTTP responses
Designing retry-safe handlers
Processing batches or delayed jobs
Building async integrations with external services
Do not use this skill for:
defining HTTP route contracts
designing GraphQL schemas or resolvers
deciding app-level policies
low-level client construction
Decision rules
Use events or workers when the work is expensive, retry-prone, or not required to complete inside a request-response cycle.
VTEX uses an internal event broadcaster to deliver platform and app events to your service. The same broadcaster can route events published by your app to other handlers. Assume at-least-once delivery semantics in both directions: events can be retried or replayed, so handlers must be idempotent and safe under duplicates.
Keep event handlers idempotent. The same event may be delivered more than once, so handlers must tolerate replay safely.
Persist idempotency and processing state in an appropriate store, such as VBase for keyed markers or Master Data for structured records, so handlers can detect duplicates, completed work, and failures across retries.
Declare events and workers explicitly in service.json so they are wired into the IO runtime, and keep their input contracts stable and explicit instead of relying on HTTP route assumptions.
When you need to notify other apps or fan out work, publish events through the appropriate VTEX IO client or event mechanism instead of creating ad hoc HTTP callbacks just to simulate asynchronous delivery.
To publish events through the VTEX IO event bus, apps often need the colossus-fire-event policy in manifest.json. Add other policies only when the app actually consumes those protected resources as well.
Separate event ingestion from business orchestration when a handler grows beyond a small, clear unit of work.
Treat retries as expected behavior, not exceptional behavior. Design handlers so repeated execution is safe.
Keep background handlers explicit about side effects such as writes, external calls, or status transitions.
For batch-oriented handlers, process items in small, explicit units and record status per item so that a single failing element does not hide progress on the rest of the batch.
Hard constraints
Constraint: Event handlers must be idempotent
Every event or background handler MUST tolerate duplicate execution without creating inconsistent side effects.
Why this matters
Async systems retry. Without idempotency, duplicate deliveries can create duplicated records, repeated partner calls, or invalid state transitions.
Detection
If the handler performs writes or external side effects without checking whether the work was already completed, STOP and add idempotency protection before proceeding.
Correct
export async function handleOrderCreated(ctx: Context) {
const { orderId } = ctx.body
const alreadyProcessed = await ctx.clients.statusStore.hasProcessed(orderId)
if (alreadyProcessed) {
return
}
await ctx.clients.partnerApi.sendOrder(orderId)
await ctx.clients.statusStore.markProcessed(orderId)
}
Wrong
export async function handleOrderCreated(ctx: Context) {
await ctx.clients.partnerApi.sendOrder(ctx.body.orderId)
}
Constraint: Background work must not rely on request-only assumptions
Workers and event handlers MUST not depend on HTTP-only assumptions such as route params, immediate user interaction, or request-bound mutable state.
Why this matters
Async handlers run outside the route lifecycle. Reusing HTTP assumptions leads to missing context, brittle behavior, and accidental coupling between sync and async paths.
Detection
If an event handler expects request headers, route params, or a route-specific state shape, STOP and redesign the input contract so the handler receives explicit async data.
Correct
export async function handleImport(ctx: Context) {
const { importId, account } = ctx.body
await ctx.clients.importApi.process(importId, account)
}
Wrong
export async function handleImport(ctx: Context) {
await ctx.clients.importApi.process(ctx.vtex.route.params.id, ctx.request.header.account)
}
Constraint: Expensive async flows must surface partial failure clearly
Async handlers MUST make partial failures visible through state, logs, or durable markers instead of silently swallowing them.
Why this matters
Background failures are harder to see than route failures. Without explicit failure signaling, operations teams cannot tell whether work was skipped, retried, or partially completed.
Detection
If the handler catches errors without recording failure state, logging enough context, or rethrowing when appropriate, STOP and make failure handling explicit.
Correct
export async function handleSync(ctx: Context) {
try {
await ctx.clients.partnerApi.syncCatalog(ctx.body.catalogId)
await ctx.clients.statusStore.markSuccess(ctx.body.catalogId)
} catch (error) {
await ctx.clients.statusStore.markFailure(ctx.body.catalogId)
throw error
}
}
Wrong
export async function handleSync(ctx: Context) {
try {
await ctx.clients.partnerApi.syncCatalog(ctx.body.catalogId)
} catch (_) {
return
}
}
Preferred pattern
Recommended file layout:
node/
├── events/
│ ├── index.ts
│ ├── catalog.ts
│ └── orders.ts
└── workers/
└── sync.ts
Minimal async handler pattern:
export async function handleCatalogChanged(ctx: Context) {
const { skuId } = ctx.body
const alreadyDone = await ctx.clients.syncState.isProcessed(skuId)
if (alreadyDone) {
return
}
await ctx.clients.catalogSync.syncSku(skuId)
await ctx.clients.syncState.markProcessed(skuId)
}
Illustrative event publishing pattern:
export async function broadcast(ctx: Context, next: () => Promise<void>) {
const {
clients: { events },
body: { payload, senderAppId, clientAppId },
} = ctx
for (const row of payload as unknown[]) {
await events.sendEvent(clientAppId, 'my-app.event-name', {
data: row,
senderAppId,
})
}
await next()
}
Minimal manifest policy for event publishing:
{
"policies": [
{
"name": "colossus-fire-event"
}
]
}
Use routes to acknowledge or trigger work, and use events or workers to perform slow, repeatable, and failure-aware processing.
Use storage intentionally for async state:
VBase for simple idempotency markers keyed by an external identifier
Master Data for structured processing records with status and timestamps
Treat the async payload as its own contract instead of reusing route-only assumptions from an HTTP request.
For fan-out or cross-app notifications, publish a small, well-defined event containing IDs and minimal metadata, then let downstream handlers fetch full details from the source of truth when needed instead of embedding large payloads or relying on custom callback URLs.
In development, use the Broadcaster app's Notify Target Workspace setting in Admin to route events to a specific workspace instead of inventing ad hoc public routes or test-only delivery flows. Handlers should still behave correctly regardless of which workspace receives the event.
Common failure modes
Treating event delivery as exactly-once instead of at-least-once.
Reusing HTTP route assumptions inside workers or event handlers.
Swallowing background errors without explicit failure state.
Letting one event handler orchestrate too many unrelated side effects.
Performing expensive work synchronously in routes instead of moving it to async processing.
Logging full event payloads with secrets or tokens instead of using IDs and metadata for correlation.
Review checklist
Is async processing the right mechanism for this work?
Is the handler idempotent under duplicate delivery?
Is idempotency or processing state stored in an appropriate backend such as VBase or Master Data?
Are events and workers declared explicitly in service.json?
Are background inputs explicit and independent from HTTP route assumptions?
Are failures surfaced clearly enough for retry and troubleshooting?
For batch processing, is status visible per item or per small unit of work?
Should large handlers be split into smaller async units or orchestration steps?
Related skills
vtex-io-data-access-patterns - Use when choosing where idempotency keys, sync state, or processing records should live
vtex-io-observability-and-ops - Use when the main question is how async failures should be logged, measured, and monitored
Reference
Service - Event declaration and service execution model
Node Builder - Backend file structure for services
Broadcaster - Internal event delivery context and the Notify Target Workspace setting for developmentdon't have the plugin yet? install it then click "run inline in claude" again.
extracted implicit decision logic into explicit decision points, documented all external connections and storage backends as inputs, added edge cases (at-least-once semantics, duplicate detection, batch failure visibility), kept original procedure faithful while making steps more granular, added hard constraints as separate section, included output contract for declarations and storage formats, and provided outcome signals for verification.
use this skill when a VTEX IO app needs to process work asynchronously via events, workers, or background handlers instead of blocking HTTP responses. applies to expensive operations, retry-prone work, cross-app notifications, batch processing, and external integrations that should not complete inside a request-response cycle. do not use for HTTP routes, GraphQL schemas, app policies, or low-level client setup.
VTEX IO runtime context:
ctx: Context object with access to clients, event bus, and async execution environmentctx.body containing explicit async payload (event data, worker input)ctx.clients.events for publishing to the VTEX IO event busstorage backends for idempotency and state:
manifest policies:
colossus-fire-event policy required to publish events through the VTEX IO event busexternal connections:
service.json declarations:
events keyworkers keyedge cases and constraints:
decide if async is appropriate - evaluate whether the work is expensive, retry-prone, or not required to complete in the request-response cycle. if immediate user feedback is not needed and failure can be retried, use async processing.
declare the event or worker in service.json - add explicit entry under events (for event handlers) or workers (for background jobs) so the IO runtime wires it correctly. include the handler name, event name (for events), and input schema.
design an explicit async input contract - define the shape of ctx.body independently from any HTTP route. include IDs, minimal metadata, and timestamps. do not embed large payloads or secrets. example: { orderId, catalogId, senderAppId, clientAppId }.
implement idempotency detection - query the appropriate storage backend (VBase or Master Data) at the start of the handler to check if the work was already completed. use an external identifier (orderId, skuId, importId) as the idempotency key.
return early if already processed - if idempotency check succeeds, return immediately without executing side effects. log the duplicate detection at debug level.
perform the async work - call external APIs, update records, or trigger integrations. keep the unit of work small and explicit.
record success state - after work completes, persist a success marker in VBase or Master Data so retries can detect completion.
handle failures explicitly - wrap work in try/catch. on error, persist a failure marker with error details, log with full context (IDs, timestamps, error message), and rethrow or surface the failure.
for batch processing, record per-item status - process items in small units. after each item succeeds or fails, update its status in the storage backend. do not let a single failure hide progress on remaining items.
split large handlers into smaller units - if a handler orchestrates too many unrelated side effects, decompose into smaller async units or introduce explicit orchestration steps.
publish events for fan-out or cross-app work - instead of creating ad hoc HTTP callbacks, use ctx.clients.events.sendEvent(targetApp, eventName, { data, senderAppId }). include only IDs and metadata; let downstream handlers fetch full details from source of truth.
in development, use Broadcaster's Notify Target Workspace - configure the Broadcaster app in Admin to route events to a specific workspace. do not invent public routes or test-only delivery flows. handlers must behave correctly regardless of workspace.
if the work is expensive, retry-prone, or not required inside a request-response cycle, use events or workers and ensure at-least-once delivery semantics. handlers must be idempotent and safe under duplicates.
if idempotency or processing state is needed, store it in VBase (for simple keyed markers) or Master Data (for structured records with status and timestamps). handlers query this state at the start to detect duplicates and completed work.
if the handler needs to notify other apps or fan out work, publish events through ctx.clients.events instead of creating ad hoc HTTP callbacks. this ensures durable, retryable delivery and decouples apps.
if the async payload requires an external identifier (orderId, skuId, importId), use it as the idempotency key in storage so duplicate deliveries are detected.
if partial failures occur during batch processing, record status per item so progress is visible and one failing element does not hide the rest of the batch.
if an error is caught in a handler, record failure state, log with full context (IDs, timestamps, error message), and rethrow. do not silently swallow errors without explicit failure signals.
if a handler grows beyond a small, clear unit of work, split it into smaller async units or introduce explicit orchestration steps to keep concerns separated.
if event handlers depend on HTTP-only assumptions (route params, request headers, request-bound state), redesign the input contract so the handler receives explicit async data instead.
if the app needs to publish events, include the colossus-fire-event policy in manifest.json. add other policies only when the app actually consumes those protected resources.
event or worker declaration in service.json:
{
"events": {
"my-app.order-created": {
"handler": "node/events/orders.ts"
}
},
"workers": {
"sync-catalog": {
"handler": "node/workers/sync.ts"
}
}
}
handler function signature:
export async function handleEventName(ctx: Context): Promise<void>
idempotency marker in VBase:
async-state)order-${orderId}){ processed: true, timestamp, status: 'success' | 'failure' }idempotency record in Master Data:
AsyncProcessingRecord)event published to bus:
await ctx.clients.events.sendEvent(
'target-app-id',
'my-app.event-name',
{ data: payload, senderAppId: 'my-app-id' }
)
handler logs:
event handlers must be idempotent. every handler must tolerate duplicate execution without creating inconsistent side effects. detect duplicates via external identifier and persisted state. if a handler performs writes or external calls without checking prior completion, it will cause duplicated records, repeated partner calls, or invalid state transitions on retry.
background work must not rely on request-only assumptions. handlers run outside the route lifecycle and cannot depend on route params, request headers, or request-bound state. the input contract must be explicit and self-contained so the handler receives all needed context from ctx.body and async storage.
expensive async flows must surface partial failure clearly. do not silently catch and swallow errors. persist failure state, log with full context (IDs, timestamps, error message), and rethrow or explicitly mark the work as failed. partial failures in batch processing must be visible per item so operations teams can see what succeeded, what failed, and what should be retried.
original author: vtex