Event workers
The listeners of @dotpress/events run in workers: processes that read a queue and run the listener of each message. This page covers where to run them, and how to set up each queue.
Choose a queue
The code that emits does not change from one to another: only the transport of the bus, and the entry point of the worker.
A message larger than the limit fails at emit(), with an EventEmitError; the other messages of the emission are sent.
Rules for every queue
- Configure a dead-letter queue. After its last attempt, a failing message goes there, where you inspect it and replay it. Dead-letter queues are part of your infrastructure (CDK, Terraform, Wrangler…): each section below gives the setting.
- Align the attempts. Set
maxAttemptsof the worker to the retry limit of the queue:onFailurethen reports the last attempt asfinal, for your alerts. - Give listeners enough time. The visibility timeout, lease or lock of a message must outlast your slowest listener, or the message is delivered again while it runs.
- Make listeners idempotent. Every queue delivers at least once: use the message
idto ignore duplicates. See Retries and idempotency. - Deploy workers before routing events to them. A message for a listener its worker does not know fails as
unknown-listener. When you rename a listener, deploy workers that know both names first.
Messages that can never be handled (not an envelope, unknown listener, invalid payload) are dead: retrying would not help. When the transport can send them to the dead-letter queue directly, they go there at once; otherwise, they fail until the retry limit, like other failures.
Long-running workers
On a server or in a container, a worker is started by dotpress-events work, with a module that exports the worker and the source of its messages:
- The module exports
{ worker, source }and optionally alogger, or a function returning them;--exportchooses another export thandefault. TypeScript modules need Node.js 22.18 or later (erasable syntax, relative imports ending in.ts); otherwise, run the compiled module, or the command withNODE_OPTIONS="--import tsx". - Stop:
SIGTERM(or Ctrl+C) stops reading, and waits for the messages in progress; a second signal, a second later or more, exits at once. Repeats within one second are ignored, becausenpm run,pnpm runand--watchrelay the signal they receive. Give the platform a stop delay above your slowest listener. - Logs: JSON lines on the standard error.
- Scale: raise the number of messages a worker handles at once (
concurrencyfor BullMQ,batchSizefor SQS, pg-boss and Cloudflare,maxMessagesfor Pub/Sub,maxConcurrentCallsfor Service Bus), then the number of worker instances. - In a container: the image of the API, with another command. See Containers.
In code, startWorker(worker, source, { logger, signal }) does the same, and returns { stop() }.
Redis (BullMQ)
Each message becomes a BullMQ job named after its listener, with the message id as job id: the same message sent again adds no duplicate while the job exists.
- Redis 6.2 or later, with
maxmemory-policy=noeviction: Redis must never evict the keys of the queue. A managed Redis often defaults to another policy: check it. - Retries (
jobOptions, merged with the defaults): 3attempts, anexponentialbackoff from 1 second, completed jobs removed (removeOnComplete: true), failed jobs kept (removeOnFail: false). SetmaxAttemptsto the same number. - Dead letters: failed jobs stay in the failed set of the queue, with their error, where a dashboard such as Bull Board shows and retries them. Messages that can never be handled fail at once, without retries (
UnrecoverableError). - The source handles
concurrencyjobs at a time (1 by default). On stop, it waits for the jobs in progress, up tocloseTimeout(30 seconds by default); their jobs then become stalled, and another worker retries them. While Redis is unreachable, BullMQ cannot close: the stop gives up aftercloseTimeout. - Connection: use the
prefixoption for the key prefix, not thekeyPrefixof ioredis. An ioredis instance passed asconnectionneedsmaxRetriesPerRequest: null. Passqueueinstead ofnameandconnectionto reuse a BullMQQueue. - BullMQ runs on Node.js and Bun.
For development, the Events example starts Redis with Docker Compose:
Postgres (pg-boss)
pg-boss keeps the queue in your Postgres database (13 or later), and needs Node.js 22.12 or later, or Bun. With a connection string or options, the transport and the source start their own pg-boss instance (close() stops the one of the transport); pass a started instance to share it.
- Queues are created on first use, with
queueOptionsmerged with the defaults: 3 attempts (retryLimit: 2), an exponential backoff from 1 second (retryDelay: 1,retryBackoff: true). Existing queues are not changed. SetmaxAttemptstoretryLimit + 1. - Dead letters: with
deadLetter, the dead-letter queue is created too. Failed jobs go there after their last attempt, and messages that can never be handled at once. - Duplicates: the job id is the message id: the same message sent again is ignored.
- The source handles
batchSizejobs at a time (1 by default), in parallel, each with its own result: one failure does not retry the others.pollingIntervalSecondssets the wait on an empty queue (0.5 at least). On stop, it waits for the jobs in progress, up tocloseTimeout(30 seconds by default).
Amazon SQS
Messages are sent in batches of 10 (SendMessageBatch), with event and listener message attributes. The client comes from the environment (region, credentials), or from clientConfig; pass client to reuse yours.
FIFO queues (URL ending with .fifo) keep the order within a message group: the listener by default, or messageGroupId: (envelope) => … to group by entity. They deduplicate messages by id for 5 minutes, and do not accept delays.
Lambda workers
The messages of a batch run in parallel; on a FIFO queue, in order, stopping at the first failure. Configure the queue and its trigger:
ReportBatchItemFailureson the event source mapping: the handler returns the failed messages, and only they are retried. Without it, one failure retries the whole batch.- A dead-letter queue, with a redrive policy:
maxReceiveCountis the number of attempts. SetmaxAttemptsto the same value. - A visibility timeout of at least 6 times the timeout of the function (AWS recommendation). A failed message becomes visible again after it;
retryDelay(attempt)sets another delay (ChangeMessageVisibility, up to 12 hours). - With
deadLetterQueueUrl, messages that can never be handled are copied to the dead-letter queue at once, withreasonanderrorattributes, instead of failing untilmaxReceiveCount.
IAM permissions: sqs:SendMessage for the code that emits. For workers, sqs:ReceiveMessage, sqs:DeleteMessage and sqs:GetQueueAttributes (granted by the event source mapping on Lambda); sqs:ChangeMessageVisibility with retryDelay or visibilityTimeout; sqs:SendMessage on the dead-letter queue with deadLetterQueueUrl.
Long-running workers
The source reads the queue by long polling (waitTimeSeconds, 20 by default; batchSize, 10 by default), runs the messages of a batch in parallel, and deletes the handled ones. With visibilityTimeout, it extends the visibility of a message while its listener runs, every half timeout. Receive errors are logged and retried, after 1 second, then up to 30 seconds. At startup, it warns when the queue has no dead-letter queue.
During development, dotpress-events work reads your development queue as the Lambda would, without deploying.
Cloudflare Queues
No dependency: the transport uses the binding of the queue inside Workers, and fetch elsewhere.
queue also accepts the binding itself. Messages are sent as JSON, in batches of 100 messages and 256 KB at most; a message is limited to 128 KB, including about 100 bytes of metadata.
Worker consumers
Each message is acknowledged or retried on its own: a failure does not retry the whole batch. After max_retries, Cloudflare moves the message to the dead_letter_queue of the consumer; without one, the message is deleted. Set maxAttempts to max_retries + 1. With deadLetterQueue, a producer binding to the dead-letter queue, messages that can never be handled are sent there at once.
Pull consumers
To run the workers outside Cloudflare (a container, your machine with dotpress-events work), enable an HTTP pull consumer on the queue: wrangler queues consumer http add events. A queue has either a Worker consumer or a pull consumer.
The source pulls batchSize messages (10 by default, up to 100), runs them in parallel, then acknowledges or retries them in one request. The API cannot extend a lease: set visibilityTimeoutMs (30 seconds by default, up to 12 hours) above your slowest listener. An empty queue is pulled again after pollInterval (1 second by default). Errors are logged and retried, after 1 second, then up to 30 seconds.
Google Cloud Pub/Sub
Each one is optional: install what you use.
The transport publishes over the REST API, with fetch: it runs on Node.js, Bun, Deno and Cloudflare Workers. Each message carries the attributes event and listener: a subscription can filter on them (--message-filter='attributes.listener = "send-welcome-mail"') to run some listeners on other workers.
- Authentication: an OAuth token from Application Default Credentials, through
google-auth-library(the service account of Cloud Run, GKE or Cloud Functions,GOOGLE_APPLICATION_CREDENTIALS, orgcloud auth application-default login). The account needs the Pub/Sub Publisher role (roles/pubsub.publisher) on the topic. Elsewhere, passgetAccessToken, which returns a token with thehttps://www.googleapis.com/auth/pubsubscope. For the emulator:baseUrl: 'http://localhost:8085', getAccessToken: false. - Limits: 10 MB and 1,000 messages per request (larger emissions are split); 10 MB per message with its base64-encoded envelope, about 7.5 MB of JSON. Pub/Sub does not support delays: an emission with a
delayfails.
Subscriptions and dead letters
With a dead-letter policy, Pub/Sub counts the delivery attempts, and moves a message to the dead-letter topic after --max-delivery-attempts (5 to 100); its service account needs the Publisher role on the dead-letter topic and the Subscriber role on the subscription. Set maxAttempts to --max-delivery-attempts. Without a dead-letter policy, every delivery counts as the first attempt, and a failing message is delivered again until the retention of the subscription expires (7 days by default).
With the deadLetter option of the push handler or deadLetterTopic of the pull source, messages that can never be handled are published at once to that topic, with the attributes reason and error.
Push endpoints
A push subscription POSTs each message to your endpoint, with an OIDC token signed by Google:
The handler verifies the token with jose: the signature with the keys of Google, the issuer, the audience, a verified email, equal to serviceAccountEmail when set. It answers 204 when the message is handled (or moved to deadLetter), 503 to have it delivered again, 401 and 403 for invalid tokens, 400 for bodies that are not push messages. Set verifyToken: false only behind a proxy that checks the token. Pub/Sub waits up to the ack deadline of the subscription (10 seconds by default, up to 600): set it above your slowest listener.
Pull workers
The source uses the streaming pull of @google-cloud/pubsub (Node.js), which extends the ack deadline of the messages in progress. Credentials come from Application Default Credentials, clientConfig (options of new PubSub()), or the client you pass; the emulator is used when PUBSUB_EMULATOR_HOST is set. On stop, the source waits for the messages in progress, up to closeTimeout (30 seconds by default).
Azure Service Bus
The SDK speaks AMQP: Node.js only. With a connection string, the transport and the source create their own client (close() closes the one of the transport); pass a client to share it, or to use Microsoft Entra ID (@azure/identity, with the Azure Service Bus Data Sender or Data Receiver role).
- Messages carry the envelope as JSON,
subject(the event) and the application propertieseventandlistener. ThemessageIdis the message id: with duplicate detection on the queue (requiresDuplicateDetection, set when the queue is created; Standard and Premium tiers), the same message sent again within the detection window is ignored.delayschedules the message (scheduledEnqueueTimeUtc). - Limits: 256 KB per message in the Standard tier, properties included; in the Premium tier, 1 MB unless raised on the queue (up to 100 MB). Batches are filled up to the size the queue accepts, 1 MB at most.
- The source handles
maxConcurrentCallsmessages at a time (1 by default), in peek-lock mode. The lock of a message in progress is renewed formaxAutoLockRenewalDurationInMs(5 minutes by default; the lock itself lasts 1 minute by default, 5 at most). A failed message is abandoned: Service Bus delivers it again at once, until themaxDeliveryCountof the queue (10 by default), then moves it to the dead-letter queue. Messages that can never be handled go there at once, with adeadLetterReason(invalid-envelope,unknown-listener,invalid-payload). On stop, the source waits for the messages in progress, up tocloseTimeout(30 seconds by default).
Service Bus does not wait between attempts: a listener that calls a service which is down fails its attempts within seconds. Keep maxDeliveryCount high enough, and retry short outages inside the listener.
Azure Functions
The handler takes one message per invocation (cardinality: 'one', the default). A failure throws: the Functions host abandons the message, delivered again until maxDeliveryCount, then moved to the dead-letter queue. attempt is the deliveryCount of the trigger metadata. A function cannot move a message to the dead-letter queue itself: with deadLetter, messages that can never be handled are sent to that queue, with the properties reason and error, and the invocation succeeds.
Monitoring
onFailureon the worker receives each failure, withfinalon the last attempt: raise an alert on final failures.- Logs:
Event listener failed, the message is retried(warn),Event listener failed, the message is not retried(error). Payloads are never logged. See the logs. - Dead-letter queues: watch their size. A growing dead-letter queue is a bug, or a service down.