Updated Nov 5, 20266 min read

How to Add Pub/Sub to a TypeScript API

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.

Illustration for How to Add Pub/Sub to a TypeScript API

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:

  • Whether you need fan-out to several consumers or a queue for one worker.
  • How the message type is shared between publisher and subscriber.
  • How failures are retried and where messages go when retries run out.
  • Where the broker runs locally and in production.

Pub/Sub options for TypeScript at a glance

CapabilityEncoreSNS and SQS (AWS SDK)BullMQNATS (nats.js)
ModelTopics with any number of subscriptionsTopics fanning out to queuesJob queues on RedisSubjects, with JetStream for persistence
Message typesTopic<T> checked by the compilerJSON you serialize and parseJob data you type yourselfBytes or JSON you parse
RetriesRetry policy per subscriptionVisibility timeout and dead-letter queueattempts and backoff per jobJetStream redelivery
Local brokerencore run runs itAn emulator or a dev accountYou run RedisYou run NATS
ProductionSNS and SQS in your AWS account or Pub/Sub in your GCP accountYou create topics, queues and policiesYou operate RedisYou operate NATS

Encore: typed topics and subscriptions

Encore

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

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.

Good to know

Delivery is at least once, so handlers should be safe to run twice for the same message.

Try Encore →

SNS and SQS with the AWS SDK

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); } } } }

Good to know

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

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 });

Good to know

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

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.

How to choose

  • Encore for typed topics and subscriptions that run locally and are created in your AWS or GCP account from the code.
  • SNS and SQS if you already manage AWS resources and want the native services directly.
  • BullMQ for background jobs on Redis with retries and scheduling.
  • NATS for low-latency messaging you are prepared to operate.

Frequently asked questions

How do I add Pub/Sub to a Node.js TypeScript API?

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.

What is the difference between a queue and Pub/Sub in TypeScript backends?

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.

How do I retry failed messages in Node.js?

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.

How do I run a message broker locally for TypeScript development?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders