Publishing and consuming events in a Node.js and TypeScript backend with Encore, SNS and SQS, BullMQ or NATS, including typed messages, retries, local brokers, tests and production.

When an API needs to tell other parts of the system that something happened, such as sending a confirmation after an order is placed, the work moves off the request path and onto a message broker.
In a TypeScript backend that means choosing a broker and a client library, keeping message types in step between publisher and consumer, deciding where consumers run, handling retries and having a broker available locally. This guide covers Encore, SNS with SQS, BullMQ and NATS.
43.6% of respondents to the 2025 Stack Overflow Developer Survey had done extensive development work in TypeScript over the past year.
What you need to decide:
Encore combines the topic, its subscribers and the broker into typed declarations in your TypeScript code, and provisions the broker locally and in your cloud. Encore is a TypeScript backend framework with a backend framework, and an existing Express app can move over endpoint by endpoint.
A topic is declared at package level with its message type:
// orders/topics.ts
import { Topic } from "encore.dev/pubsub";
export interface OrderPlaced {
orderId: number;
email: string;
}
export const orderPlaced = new Topic<OrderPlaced>("order-placed", {
deliveryGuarantee: "at-least-once",
});
The endpoint publishes after the order has been stored:
// orders/orders.ts
import { api } from "encore.dev/api";
import { orderPlaced } from "./topics";
interface PlaceRequest {
email: string;
}
export const place = api(
{ expose: true, method: "POST", path: "/orders" },
async (req: PlaceRequest): Promise<{ orderId: number }> => {
const orderId = 42; // stored in your database in a real app
await orderPlaced.publish({ orderId, email: req.email });
return { orderId };
},
);
A subscription in another service handles each message, and a handler that throws is retried with backoff:
// email/confirmations.ts
import { Subscription } from "encore.dev/pubsub";
import log from "encore.dev/log";
import { orderPlaced } from "../orders/topics";
const _ = new Subscription(orderPlaced, "send-confirmation", {
handler: async (event) => {
log.info("sending confirmation", { orderId: event.orderId, email: event.email });
},
retryPolicy: { minBackoff: "10s", maxRetries: 5 },
});
encore run runs the topic and subscription locally, so publishing from the endpoint triggers the handler on your machine and the trace shows both. On deploy the topic becomes SNS and SQS in your AWS account or Pub/Sub on GCP. When an agent adds a new subscriber, it changes the handler and the declaration in one place and triggers it under encore run, and the compiler rejects a handler that reads a field the message type does not have.
Without Encore you create the topic, the queue, the subscription between them and the queue policy that lets SNS write to it, for each environment, and you run a consumer loop and keep the message type in sync on both sides by convention. The SNS and SQS section below shows the code.
Delivery is at least once, so handlers should be safe to run twice for the same message.
On AWS the standard fan-out is an SNS topic with one SQS queue per consumer, using SNS to SQS subscriptions. Publishing uses the AWS SDK for JavaScript:
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from "@aws-sdk/client-sqs";
const sns = new SNSClient({});
const sqs = new SQSClient({});
export async function publishOrderPlaced(orderId: number, email: string) {
await sns.send(new PublishCommand({
TopicArn: process.env.ORDER_PLACED_TOPIC_ARN,
Message: JSON.stringify({ orderId, email }),
}));
}
export async function consume(queueUrl: string) {
for (;;) {
const { Messages = [] } = await sqs.send(new ReceiveMessageCommand({
QueueUrl: queueUrl, WaitTimeSeconds: 20, MaxNumberOfMessages: 10,
}));
for (const m of Messages) {
try {
const envelope = JSON.parse(m.Body!);
const event = JSON.parse(envelope.Message) as { orderId: number; email: string };
// handle event, then delete so it is not delivered again
await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: m.ReceiptHandle! }));
} catch (err) {
// leave the message on the queue so it is retried and can reach the DLQ
console.error("failed to process message", m.MessageId, err);
}
}
}
}
Standard queues deliver at least once and may reorder messages, and a dead-letter queue catches messages that keep failing. Unless raw message delivery is enabled, the SQS body is an SNS envelope, which is why the consumer parses twice.
BullMQ is a Redis-backed job queue, which fits background work with one consumer per job rather than fan-out:
import { Queue, Worker } from "bullmq";
const connection = { host: "localhost", port: 6379 };
const confirmations = new Queue("confirmations", { connection });
await confirmations.add("send", { orderId: 42, email: "ada@example.com" }, {
attempts: 5,
backoff: { type: "exponential", delay: 10_000 },
});
new Worker("confirmations", async (job) => {
// send the email for job.data.orderId
}, { connection });
Each job goes to one worker, so a second kind of consumer needs its own queue and its own add call in the publisher.
NATS is a lightweight broker with subjects and, through JetStream, persistence and acknowledgements. The nats.js client publishes and subscribes with a few lines, and plain NATS subjects deliver at most once, so durable consumers need JetStream.
Choose a broker and its client: SNS and SQS through the AWS SDK on AWS, BullMQ on Redis for job queues, or NATS for lightweight messaging. Publish from the request handler after the main write succeeds, and run consumers in the same process or a separate worker. With Encore you declare a typed Topic and a Subscription in your code, encore run runs them locally, and a deploy provisions SNS and SQS or GCP Pub/Sub in your account.
A queue delivers each message to one consumer, which suits background jobs. Pub/Sub delivers each message to every subscription, so several services can react to the same event independently.
SQS makes a message visible again after its visibility timeout and moves it to a dead-letter queue after a set number of receives. BullMQ retries jobs with the attempts and backoff options. Encore retries a subscription handler that throws, using the retry policy set on the subscription.
Most setups run Redis, NATS or an SQS emulator in a container. With Encore, encore run runs the declared topics and subscriptions locally.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.