Updated Nov 5, 20266 min read

How to Use Google Cloud Pub/Sub with Node.js

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.

Pub/Sub on Node.js at a glance

Encore@google-cloud/pubsub
Topic and subscription creationFrom the code, on deploygcloud, Terraform or console
IAMA service account per deployment, set up by EncorePublisher and Subscriber roles you grant
Dead-letter topicConfigured on every subscriptionSeparate topic plus subscription flags
RetriesretryPolicy in codeSubscription retry policy
Local developmentIn-process under encore runPub/Sub emulator
Type safetyTyped Topic<T> and handlermessage.data is a Buffer

Each option is covered below, starting with Encore.

Typed topics that become Pub/Sub on GCP 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.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

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:

~/orders-app
$ encore run$ curl -s -X POST localhost:4000/orders \ -d '{"orderId":"ord_42","email":"ada@example.com","totalCents":4200}'{"messageId":"..."}$ echo "SELECT order_id, email FROM receipts;" | encore db shell ordersorder_id | email----------+-----------------ord_42 | ada@example.com(1 row)

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.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

The same flow with @google-cloud/pubsub

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.

Good to know

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.

How to choose

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.

Frequently asked questions

How do I publish and subscribe to Google Cloud Pub/Sub in Node.js?

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.

Does Google Cloud Pub/Sub deliver messages more than once?

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.

How do I test Pub/Sub locally without a GCP project?

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.

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