Publish and consume Pub/Sub messages from a TypeScript service, with retries, dead-letter topics and idempotent handlers.
Google Cloud Pub/Sub is GCP's managed message bus: one service publishes an event, any number of subscriptions receive it, and Google handles storage and redelivery. The client library is short to call. The work sits around it: creating topics and subscriptions per environment, granting publisher and subscriber roles, wiring dead-letter topics, and writing handlers that survive the same message arriving twice.
This guide builds an order service in TypeScript that publishes an order-placed event and sends a receipt from a subscriber. It starts with Encore (a tool that works alongside your framework), which provisions Pub/Sub from the code, then shows the same flow with the official Node.js client.
Each option is covered below, starting with Encore.
Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. A Topic declared in code becomes a Google Cloud Pub/Sub topic on GCP and an SNS topic on AWS. Each subscription gets a dead-letter topic. Cloud access sits with Encore's service identity, not the agent.

The topic, the publishing endpoint and the subscriber sit in one file:
// orders/orders.ts
import { api } from "encore.dev/api";
import { Topic, Subscription } from "encore.dev/pubsub";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import log from "encore.dev/log";
const db = new SQLDatabase("orders", { migrations: "./migrations" });
export interface OrderPlaced {
orderId: string;
email: string;
totalCents: number;
}
// A Pub/Sub topic on GCP, SNS on AWS.
export const orderPlaced = new Topic<OrderPlaced>("order-placed", {
deliveryGuarantee: "at-least-once",
});
export const place = api(
{ expose: true, method: "POST", path: "/orders" },
async (req: OrderPlaced): Promise<{ messageId: string }> => {
const messageId = await orderPlaced.publish(req);
return { messageId };
},
);
const _ = new Subscription(orderPlaced, "send-receipt", {
handler: async (event) => {
// Idempotent: a redelivered event finds the row and does nothing.
const inserted = await db.queryRow`
INSERT INTO receipts (order_id, email) VALUES (${event.orderId}, ${event.email})
ON CONFLICT (order_id) DO NOTHING
RETURNING order_id`;
if (!inserted) return;
log.info("receipt sent", { orderId: event.orderId, email: event.email });
},
retryPolicy: { maxRetries: 5, minBackoff: "10s" },
});
The migration in orders/migrations creates the receipts table the queries use:
-- orders/migrations/1_create_receipts.up.sql
CREATE TABLE receipts (
order_id TEXT PRIMARY KEY,
email TEXT NOT NULL
);
The receipts table has order_id as its primary key, which is what makes the handler safe to run twice. Under encore run, publishing calls the subscriber in-process, so the whole flow is testable without a GCP project or the emulator:
When the handler throws, Encore retries with backoff up to maxRetries and then moves the message to the subscription's dead-letter topic, where it can be released again once the bug is fixed. The Pub/Sub docs cover exactly-once delivery, ordering by attribute and maxConcurrency.
Typed events help coding agents as much as people: when an agent adds a field to OrderPlaced, the compiler flags every publisher and subscriber that needs to change, and the agent can run the flow under encore run before it opens a pull request.
The infrastructure provisioning docs describe what is created in each environment.
Run in your terminal to get started locally.
The official Node.js client talks to topics and subscriptions that must already exist. Create them first, along with a dead-letter topic:
gcloud pubsub topics create order-placed gcloud pubsub topics create order-placed-dlq gcloud pubsub subscriptions create send-receipt \ --topic=order-placed \ --ack-deadline=30 \ --dead-letter-topic=order-placed-dlq \ --max-delivery-attempts=5 \ --min-retry-delay=10s gcloud pubsub subscriptions create order-placed-dlq-sub \ --topic=order-placed-dlq
Dead-lettering also needs the Pub/Sub service agent to have publisher rights on the dead-letter topic and subscriber rights on the source subscription, and the order-placed-dlq-sub subscription keeps failed messages so you can inspect them and publish them back to order-placed, as Google's failure handling guide describes. Your service account then needs roles/pubsub.publisher on the topic and roles/pubsub.subscriber on the subscription.
Publishing and consuming look like this:
import { PubSub, Message } from "@google-cloud/pubsub";
const pubsub = new PubSub();
export async function publishOrder(order: { orderId: string; email: string }) {
return pubsub.topic("order-placed").publishMessage({ json: order });
}
pubsub.subscription("send-receipt").on("message", async (message: Message) => {
const order = JSON.parse(message.data.toString());
try {
await sendReceiptOnce(order); // your idempotent insert
message.ack();
} catch {
message.nack();
}
});
This is a streaming pull subscriber, so the process has to stay running. On Cloud Run, which scales to zero, teams usually switch to a push subscription that posts each message to an HTTPS endpoint with an OIDC token, and then verify that token in the handler. For local work, the Pub/Sub emulator runs from the gcloud CLI and the client finds it through PUBSUB_EMULATOR_HOST.
Pub/Sub delivers at least once by default. A slow handler that passes its ack deadline gets the message again, and so does a crash between the side effect and ack(). A unique key on the side effect, like the order_id primary key above, is the simplest guard.
Pick Encore when you want typed events, topics, subscriptions and dead-letter topics created in your own GCP project from the code, and the full publish and subscribe flow running locally under encore run. The same code deploys to AWS on SNS and SQS.
Pick the @google-cloud/pubsub client directly when the topics are shared with other systems and managed in Terraform, or when you need client features such as flow control settings and schema validation on the topic.
With the @google-cloud/pubsub client you call topic.publishMessage({ json }) and listen for message events on a subscription, calling message.ack() when done. With Encore, you declare new Topic<OrderPlaced>("order-placed", { deliveryGuarantee: "at-least-once" }), call orderPlaced.publish(event), and handle events with new Subscription(orderPlaced, "send-receipt", { handler }). Encore creates the Pub/Sub topic, subscription and dead-letter topic in your GCP project on deploy.
Yes. The default delivery is at-least-once, so a message can arrive twice, for example after an ack deadline expires. Write handlers so a second delivery has no extra effect, such as inserting with ON CONFLICT DO NOTHING keyed on the order ID.
Google ships a Pub/Sub emulator in the gcloud CLI that you start and point the client at with PUBSUB_EMULATOR_HOST.
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.