Pub/Sub
Firenook’s Pub/Sub emulator answers every RPC of the official Pub/Sub emulator, over gRPC and HTTP/JSON on one port. Read this page to set it up, publish and receive messages, and see the few places where it behaves differently.
Set it up
Section titled “Set it up”Pub/Sub has no top-level section in firebase.json, so it starts when
emulators.pubsub is there:
{ "emulators": { "pubsub": { "port": 8085 } }}Google’s Pub/Sub client libraries connect when PUBSUB_EMULATOR_HOST is set,
as they do to the official emulator:
PUBSUB_EMULATOR_HOST=127.0.0.1:8085 node publisher.mjsYour Functions and firenook emulators:exec get the variable set for them.
What it emulates
Section titled “What it emulates”Every RPC of the official emulator’s google.pubsub.v1 Publisher, Subscriber
and SchemaService, and the google.iam.v1 policy service, checked against a
recorded run of it:
- Topics, publishing and subscriptions with acknowledgement deadlines and redelivery.
- Pull, streaming pull and push delivery with retries.
- Ordering keys, filters, retention, seek and snapshots.
- Dead-letter topics and retry policies.
- Avro schemas with revisions, bound to topics.
- Delivery to your
onMessagePublishedfunctions, through a subscription namedemulator-sub-<topic>, and the schedules of youronSchedulefunctions, in the time zone each declares.
It does not do what the official emulator answers with 501 either: Protocol
Buffer schemas, IAM, detachSubscription and updateSnapshot. It has no
BigQuery, Cloud Storage or Bigtable export subscriptions, and, like the
official emulator, it keeps no messages across a restart.
Publish and receive
Section titled “Publish and receive”The client library works unchanged:
import { PubSub } from '@google-cloud/pubsub';
const pubsub = new PubSub({ projectId: 'demo-shop' });const topic = pubsub.topic('orders');const [subscription] = await topic.createSubscription('orders-email');subscription.on('message', (m) => { console.log('received', m.data.toString(), m.attributes); m.ack();});const id = await topic.publishMessage({ json: { orderId: 'o43' }, attributes: { kind: 'paid' } });console.log('published', id);$ PUBSUB_EMULATOR_HOST=127.0.0.1:8085 node pubsub.mjsreceived {"orderId":"o43"} { kind: 'paid' }published 2The same port speaks HTTP/JSON, so curl works too. Here it pulls from
orders-audit, another subscription of the topic (output trimmed to one
message):
$ curl -X POST -H 'Content-Type: application/json' --data '{"maxMessages":10}' \ http://127.0.0.1:8085/v1/projects/demo-shop/subscriptions/orders-audit:pull{ "receivedMessages": [ { "ackId": "projects/demo-shop/subscriptions/orders-audit:2", "message": { "data": "eyJvcmRlcklkIjoibzQyIn0=", "attributes": { "kind": "paid" }, "messageId": "1", "publishTime": "2026-10-21T15:20:26.220070+00:00" } } ]}Filters and dead letters
Section titled “Filters and dead letters”A subscription with a filter and a dead-letter policy behaves as in
production. Here it receives only paid messages, the handler refuses each
one, and after five attempts the message moves to the dead-letter topic with
the attributes Pub/Sub adds:
const [events] = await pubsub.createTopic('events');const [dead] = await pubsub.createTopic('events-dead');const [paid] = await events.createSubscription('paid', { filter: 'attributes.kind = "paid"', deadLetterPolicy: { deadLetterTopic: dead.name, maxDeliveryAttempts: 5 },});await events.publishMessage({ data: Buffer.from('a'), attributes: { kind: 'paid' } });await events.publishMessage({ data: Buffer.from('b'), attributes: { kind: 'refunded' } });paid.on('message', (m) => { console.log('paid sub got', m.data.toString(), 'attempt', m.deliveryAttempt); m.nack();});$ node dead-letter.mjspaid sub got a attempt 1paid sub got a attempt 2paid sub got a attempt 3paid sub got a attempt 4paid sub got a attempt 5dead-letter topic got a { CloudPubSubDeadLetterSourceDeliveryCount: '5', CloudPubSubDeadLetterSourceSubscription: 'paid', CloudPubSubDeadLetterSourceSubscriptionProject: 'demo-shop', CloudPubSubDeadLetterSourceTopicPublishTime: '2026-10-21T15:37:04.216Z', kind: 'paid'}Where it differs
Section titled “Where it differs”Three behaviours differ on purpose. Each follows production, or the official emulator’s design, where the recorded run of the official emulator says nothing:
- Acknowledged messages are dropped once nothing can bring them back: the
subscription does not retain acknowledged messages, no snapshot holds them,
and no other subscription of the topic still needs them. Turning
retainAckedMessageson later does not bring earlier acknowledgements back to a seek. - A topic’s history is bounded: the newest 5,000 messages and 16 MiB, or its retention window up to 100,000 messages and 256 MiB, and 512 MiB across all topics. Topic retention seeds a new subscription from that history.
- Dead-lettered messages reach functions listening on the dead-letter topic, as any publish does. No recorded run of the official emulator confirms this yet.
The console’s Pub/Sub page can also peek at a backlog, force an acknowledgement and run a schedule now. Those are console controls; no client protocol reaches them.
Use it on its own
Section titled “Use it on its own”For tests that need only Pub/Sub, run it without the rest of the suite:
$ npx firenook native pubsub --project-id demo-shop --port 8085Pub/Sub emulator ready at 127.0.0.1:8085 (project demo-shop)Run one service on its own has the details.
- Cloud Functions:
onMessagePublishedtriggers. - Scheduled functions: schedules run on Pub/Sub.
- Cloud Tasks: queues for one-off work.