Scaling Pub/Sub: From Prototype to Production
Pub/Sub (Publish-Subscribe) is one of the most powerful architectural patterns for building decoupled, scalable systems. It works beautifully in a prototype — a single broker, a handful of subscribers, and everything just hums along. But the moment you push it toward production traffic, the cracks appear: message ordering breaks down, consumers fall behind, retries cause duplicate processing, and a single slow subscriber can poison the entire pipeline. This tutorial walks through the journey of taking a Pub/Sub system from a working prototype to a resilient, production-grade platform.
What Is Pub/Sub?
Pub/Sub is a messaging pattern where publishers emit events to a topic without knowing who will consume them. Subscribers express interest in topics and receive events asynchronously. This decoupling — both in time and in identity — is what makes Pub/Sub so attractive for distributed systems.
Common implementations include cloud-managed services like Google Cloud Pub/Sub, Amazon SNS/SQS, and Azure Service Bus, as well as self-hosted options like Apache Kafka, NATS, Redis Pub/Sub, and RabbitMQ. While the specifics differ, the scaling challenges are remarkably similar across all of them.
Why Scaling Matters
In a prototype, you typically have one publisher, one topic, and one or two subscribers running on a single machine. Throughput is low, failures are rare, and the happy path is all you test. In production, you face:
- High throughput: thousands to millions of messages per second.
- Many consumers: dozens of independent services subscribing to the same events.
- Failure scenarios: network partitions, consumer crashes, poison messages.
- Ordering guarantees: some consumers need strict per-key ordering.
- Cost sensitivity: inefficient fan-out can explode your cloud bill.
Without deliberate design, each of these will eventually bite you. Let's build up a system step by step.
The Prototype: A Simple Pub/Sub Setup
Here's a typical prototype using Node.js with Google Cloud Pub/Sub. It publishes user activity events and has a single consumer that logs them.
// publisher.js
const { PubSub } = require('@google-cloud/pubsub');
const pubsub = new PubSub();
const topic = pubsub.topic('user-events');
async function publishEvent(event) {
const dataBuffer = Buffer.from(JSON.stringify(event));
await topic.publishMessage({ data: dataBuffer });
console.log('Published:', event.type);
}
publishEvent({ type: 'signup', userId: 'u123', timestamp: Date.now() });
// subscriber.js
const { PubSub } = require('@google-cloud/pubsub');
const pubsub = new PubSub();
const subscription = pubsub.subscription('user-events-sub');
subscription.on('message', async (message) => {
const event = JSON.parse(message.data.toString());
console.log('Received:', event.type, 'for user', event.userId);
message.ack();
});
console.log('Listening for messages...');
This works. It's clean, it's readable, and it will serve you well for a demo. But it has several latent problems: there's no retry logic beyond the broker defaults, no ordering key, no batching, no dead-letter handling, and no observability. Let's address each.
Step 1: Add Batching and Ordering
The first production concern is throughput. Publishing one message at a time introduces significant overhead. Most brokers support batching — grouping multiple messages into a single network call. Additionally, if your consumers need ordered processing per entity (e.g., all events for a given user processed in order), you need an ordering key.
// publisher.js — production version
const { PubSub } = require('@google-cloud/pubsub');
const pubsub = new PubSub();
const topic = pubsub.topic('user-events', {
batching: {
maxMessages: 1000,
maxMilliseconds: 100,
},
gaxOpts: {
timeout: 30000,
},
});
async function publishEvent(event) {
const dataBuffer = Buffer.from(JSON.stringify(event));
await topic.publishMessage({
data: dataBuffer,
orderingKey: event.userId, // ensures per-user ordering
attributes: {
eventType: event.type,
version: '1.0',
},
});
}
// Simulate a burst of events
const events = [];
for (let i = 0; i < 5000; i++) {
events.push({
type: 'page_view',
userId: `user_${i % 100}`,
timestamp: Date.now(),
page: `/product/${i}`,
});
}
await Promise.all(events.map(publishEvent));
console.log('All events published');
Key changes: the batching config groups up to 1000 messages or waits 100ms before flushing. The orderingKey ensures that all events for the same userId are delivered to the same consumer in order. Note that ordering requires the topic to be configured with message ordering enabled, and it slightly reduces throughput because the broker must route messages with the same key to the same partition.
Step 2: Build a Resilient Consumer
The prototype consumer ack()s every message immediately. In production, you need to process the message first, handle failures gracefully, and avoid getting stuck on poison messages. Here's a more robust consumer:
// subscriber.js — production version
const { PubSub } = require('@google-cloud/pubsub');
const pubsub = new PubSub();
const subscription = pubsub.subscription('user-events-sub', {
flowControl: {
maxMessages: 100, // process at most 100 concurrently
maxBytes: 10 * 1024 * 1024, // 10MB in flight
},
ackDeadline: 60, // seconds before broker redelivers
});
let processed = 0;
let errors = 0;
async function processEvent(event) {
// Simulate business logic
if (event.type === 'unknown') {
throw new Error('Unknown event type');
}
// ... do real work here, e.g., write to database
return;
}
subscription.on('message', async (message) => {
try {
const event = JSON.parse(message.data.toString());
await processEvent(event);
message.ack();
processed++;
} catch (err) {
errors++;
console.error('Processing failed:', err.message);
// If the message has been retried too many times, let it
// route to a dead-letter topic instead of looping forever.
const deliveryAttempt = message.deliveryAttempt || 0;
if (deliveryAttempt >= 5) {
console.error('Moving message to DLQ after', deliveryAttempt, 'attempts');
message.ack(); // ack to stop redelivery; DLQ handles it
} else {
message.nack(); // redeliver
}
}
});
subscription.on('error', (err) => {
console.error('Subscription error:', err);
});
// Health metrics endpoint (simplified)
setInterval(() => {
console.log(`Processed: ${processed}, Errors: ${errors}`);
}, 10000);
console.log('Consumer running with flow control...');
Several production patterns are at work here:
- Flow control limits concurrent processing to prevent memory blowups and backpressure cascades.
- Ack deadline gives the consumer time to process before the broker assumes the message was lost.
- Dead-letter handling prevents poison messages from blocking the queue indefinitely.
- Metrics logging provides basic observability into throughput and error rates.
Step 3: Dead-Letter Queues and Retry Strategy
A dead-letter queue (DLQ) is a separate topic or queue where messages that repeatedly fail processing are sent for inspection. Without a DLQ, a single malformed message can cause infinite redelivery loops that waste resources and block the pipeline. Most managed services support DLQs natively. Here's how to configure one on Google Cloud Pub/Sub using the gcloud CLI:
# Create the dead-letter topic
gcloud pubsub topics create user-events-dlq
# Create a subscription on the DLQ for inspection
gcloud pubsub subscriptions create user-events-dlq-sub \
--topic=user-events-dlq
# Attach a dead-letter policy to the main subscription
gcloud pubsub subscriptions update user-events-sub \
--dead-letter-topic=user-events-dlq \
--max-delivery-attempts=10 \
--dead-letter-topic-project=my-project-id
With this in place, the broker itself handles moving failed messages after 10 delivery attempts. Your consumer code can then focus on happy-path processing and let the infrastructure manage retries. You should also build a small tool or dashboard to inspect the DLQ so that operations teams can diagnose and replay failed messages.
Step 4: Horizontal Consumer Scaling
A single consumer process will eventually become a bottleneck. To scale horizontally, you run multiple instances of the same consumer, all pulling from the same subscription. The broker distributes messages across them. The key considerations are:
- Shared subscription: all instances pull from one subscription for load-balanced delivery.
- Idempotency: because messages can be redelivered (due to crashes or ack deadline expirations), your processing logic must be safe to run more than once.
- Stateless consumers: avoid storing processing state in memory; use a database or cache.
Here's an example of an idempotent consumer that uses a database to track processed events:
// idempotent-consumer.js
const { PubSub } = require('@google-cloud/pubsub');
const pubsub = new PubSub();
const subscription = pubsub.subscription('user-events-sub');
// Pseudo-database client
const db = require('./db');
async function processEvent(event, messageId) {
// Check if we already processed this exact message
const alreadyProcessed = await db.query(
'SELECT 1 FROM processed_events WHERE message_id = $1',
[messageId]
);
if (alreadyProcessed.rows.length > 0) {
console.log('Skipping duplicate:', messageId);
return;
}
// Do the real work
await db.query(
'INSERT INTO user_activity (user_id, event_type, created_at) VALUES ($1, $2, $3)',
[event.userId, event.type, new Date(event.timestamp)]
);
// Record that we processed this message
await db.query(
'INSERT INTO processed_events (message_id, processed_at) VALUES ($1, NOW())',
[messageId]
);
}
subscription.on('message', async (message) => {
try {
const event = JSON.parse(message.data.toString());
await processEvent(event, message.id);
message.ack();
} catch (err) {
console.error('Error:', err.message);
message.nack();
}
});
This pattern — check, process, record — ensures that even if a message is redelivered to a different consumer instance, it won't be processed twice. The processed_events table acts as a deduplication ledger. For higher throughput, you can use a TTL on these records so the table doesn't grow forever, since redelivery windows are typically minutes, not days.
Step 5: Observability and Monitoring
A production Pub/Sub system without observability is a black box. You need visibility into publish rates, delivery latencies, ack rates, and backlog depth. At minimum, track these metrics:
- Publish throughput: messages per second and bytes per second.
- Backlog size: number of unacked messages. A growing backlog means consumers can't keep up.
- Ack latency: time from publish to ack. High latency indicates slow consumers.
- Error rate: percentage of messages that fail processing.
- DLQ depth: messages accumulating in the dead-letter queue.
Here's a simple Prometheus-compatible metrics exporter for a consumer:
// metrics.js
const promClient = require('prom-client');
const express = require('express');
const register = new promClient.Registry();
promClient.collectDefaultMetrics({ register });
const messagesProcessed = new promClient.Counter({
name: 'pubsub_messages_processed_total',
help: 'Total messages processed',
labelNames: ['status'],
registers: [register],
});
const processingDuration = new promClient.Histogram({
name: 'pubsub_processing_duration_seconds',
help: 'Time spent processing a message',
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 10],
registers: [register],
});
const backlogGauge = new promClient.Gauge({
name: 'pubsub_backlog_messages',
help: 'Number of unacked messages',
registers: [register],
});
const app = express();
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
app.listen(9090, () => {
console.log('Metrics server on :9090');
});
module.exports = { messagesProcessed, processingDuration, backlogGauge };
Then instrument your consumer to record these metrics on every message:
const { messagesProcessed, processingDuration } = require('./metrics');
subscription.on('message', async (message) => {
const start = Date.now();
try {
const event = JSON.parse(message.data.toString());
await processEvent(event, message.id);
message.ack();
messagesProcessed.inc({ status: 'success' });
} catch (err) {
message.nack();
messagesProcessed.inc({ status: 'error' });
} finally {
processingDuration.observe((Date.now() - start) / 1000);
}
});
With these metrics flowing into Prometheus and visualized in Grafana, you can set alerts: page the on-call engineer if backlog exceeds 10,000 messages for more than 5 minutes, or if error rate exceeds 5%.
Step 6: Schema Management and Evolution
As your system grows, multiple teams will publish and consume events from the same topics. Without schema management, a producer changing a field can silently break dozens of consumers. Use a schema registry (like Confluent Schema Registry for Kafka, or Google Cloud's schema support for Pub/Sub) to enforce contracts.
Here's an example Avro schema for user events:
// user-events-schema.avsc
{
"type": "record",
"name": "UserEvent",
"namespace": "com.example.events",
"fields": [
{ "name": "userId", "type": "string" },
{ "name": "type", "type": "string" },
{ "name": "timestamp", "type": "long" },
{ "name": "properties", "type": { "type": "map", "values": "string" }, "default": {} },
{ "name": "version", "type": "int", "default": 1 }
]
}
Follow these evolution rules to maintain backward compatibility:
- Never remove a required field. Instead, deprecate it.
- New fields must have a default value so old consumers can ignore them.
- Never change a field's type. Add a new field instead.
- Bump the schema version and document the change in your team wiki.
Best Practices Summary
- Batch your publishes. Group messages to reduce per-message overhead and improve throughput by 10x or more.
- Use ordering keys judiciously. Only when you truly need per-entity ordering, since it limits parallelism.
- Make consumers idempotent. Assume every message will be delivered at least once, and design accordingly.
- Always configure a DLQ. Poison messages should be quarantined, not retried forever.
- Set flow control limits. Prevent any single consumer from being overwhelmed by a sudden spike.
- Monitor backlog and latency. These are your leading indicators of consumer health.
- Enforce schemas. Protect consumers from breaking producer changes with a schema registry.
- Keep consumers stateless. Store state in a database so any instance can process any message.
- Plan for redelivery. Design your downstream systems to tolerate duplicate events gracefully.
- Document your topics and schemas. A topic catalog helps new teams discover and use existing events.
Conclusion
Scaling a Pub/Sub system from prototype to production is less about the broker and more about the patterns you build around it. Batching and ordering keys solve throughput and correctness. Flow control and dead-letter queues solve resilience. Idempotency solves the reality of at-least-once delivery. Observability and schema management solve the human problems of operating a system that many teams depend on. By incrementally adding each of these layers — rather than trying to build them all on day one — you can ship your prototype quickly while having a clear roadmap for hardening it as real traffic arrives. The result is a messaging backbone that stays stable under load, fails gracefully when things go wrong, and scales horizontally as your business grows.