GCP Serverless

GCP Pub/Sub and Event-Driven Architecture: Decouple and Scale

Quick take: Cloud Pub/Sub is the nervous system of event-driven GCP. It decouples producers from consumers, buffers bursts, and lets many services react to the same event without knowing about each other — but only if you get the subscription type, the ack deadline, the ordering model and the dead-letter policy right.

At 11:40 on a sale morning, a retail platform’s checkout service was calling four downstream services synchronously — inventory, payments, email, analytics — one HTTP call after another, inside the request that the customer was waiting on. The analytics endpoint hit a slow query and started taking eight seconds. Because checkout awaited it, checkout requests piled up, the thread pool drained, and the whole storefront returned 500s. Analytics — the least important consumer of an order — took down the most important producer. That is the failure mode synchronous chains are built to have: the slowest, least-critical dependency sets the latency and the availability of the entire flow.

The fix was Cloud Pub/Sub — Google’s fully managed, global, asynchronous messaging service. Checkout now publishes one order-placed message to a topic and returns in single-digit milliseconds. Inventory, payments, email and analytics each own a subscription and consume at their own pace. When analytics lags, its subscription backlog grows — a number on a dashboard — while checkout, payments and email are completely unaffected. Pub/Sub absorbed a flash-sale burst of 30,000 messages/second without a capacity ticket, because throughput provisioning is Google’s problem, not yours. This is the core promise: decouple producers from consumers in time, in load, and in failure.

But “just put a queue in front of it” is where most teams stop and most production incidents begin. Pub/Sub is not one thing — it is a topic plus a family of subscription types (pull, streaming pull, push, BigQuery, Cloud Storage), each with different delivery semantics, different failure modes and a different bill. Getting it right means understanding the at-least-once default and what duplicates do to your handlers, when exactly-once delivery is worth its constraints, how ordering keys trade throughput for order, why your ack deadline is the single most misconfigured setting in the system, and how dead-letter topics and retry policy keep one poison message from wedging a subscription forever. This article is the production playbook for all of it, with real gcloud, real client code, and a table for every decision.

What problem this solves

Synchronous request-response chains are brittle in three independent ways, and Pub/Sub addresses all three. Temporal coupling: the caller and callee must both be up at the same instant; if the callee is restarting, deploying, or briefly overloaded, the caller fails too. Load coupling: a burst that the producer can absorb (it just accepts HTTP and returns) is forced immediately onto every downstream, so the slowest consumer’s capacity becomes the system’s capacity. Failure coupling: an error or timeout in any link propagates back up the chain, so a non-critical leaf (analytics, a recommendation refresh, a webhook to a partner) can fail a critical root (checkout, payment capture).

Pub/Sub breaks all three by inserting a durable, elastic buffer between producer and consumer. The producer publishes and is done — it does not know or care who is listening, how many subscribers there are, or whether they are healthy. Each consumer reads from its own subscription, which holds an independent backlog and retries independently. A slow consumer grows its backlog (visible, alertable, recoverable) instead of pushing back on the producer. A down consumer simply resumes from where it left off when it recovers, within the message retention window (default 7 days, up to 31). Add a fifth consumer next quarter? Create a subscription; nobody who already publishes or consumes changes a line of code. That is fan-out, and it is the feature that turns a tangle of point-to-point calls into a clean event mesh.

Who hits the pain this solves: anyone whose checkout/ingest/upload path fans out to multiple downstreams; anyone whose traffic is bursty (sales, batch loads, IoT device storms, log spikes); anyone integrating systems that deploy and fail on independent schedules (microservices, partner webhooks, cross-team pipelines); and anyone streaming events into analytics, where you want the operational path fast and the analytical path eventually-consistent. The catch — and this article exists for it — is that Pub/Sub trades synchronous simplicity for eventual consistency, at-least-once duplicates, and a set of delivery knobs you must understand. Misuse any of them and you get duplicate charges, out-of-order state, silently stuck subscriptions, or a telemetry bill that dwarfs your compute. The point of what follows is to use the trade deliberately.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with a GCP project and the gcloud CLI (auth, setting a project, reading JSON/table output in Cloud Shell), and with basic IAM — that access is granted by binding a role to a principal on a resource. The Google Cloud IAM Explained Simply: Members, Roles, and Bindings (Without the Jargon) piece is the right warm-up if any of that is shaky, and GCP IAM and Service Accounts: Roles, Bindings and Least Privilege covers the service-account identities that push subscriptions and the Pub/Sub service agent rely on. Knowing HTTP status codes and the idea of an idempotent operation helps, because at-least-once delivery makes idempotency non-negotiable.

This sits at the centre of the serverless and event-driven track. Upstream of it are the compute choices: most modern consumers are Cloud Run Explained: Serverless Containers That Scale to Zero (and Back) Without Kubernetes services receiving push, and the GCP Cloud Run vs GKE vs Compute Engine: Choose the Right Compute decision determines what runs your handlers. Downstream are the sinks: BigQuery for Data Analytics: Warehousing, Querying and Visualization (the destination for a BigQuery subscription) and Cloud Storage Classes Decoded: Standard, Nearline, Coldline, Archive — and Lifecycle Rules (where a Cloud Storage subscription lands batched files). For day-two operations, GCP Cloud Monitoring and Operations: Observability Built In is where you watch subscription backlog and set the alerts this article recommends.

A quick map of who owns which layer of a Pub/Sub system, so an incident reaches the right person fast:

Layer What lives here Who usually owns it What it can break
Publisher app Produces and publishes messages Producing service team Publish failures, bad payloads, missing ordering key
Topic + schema The contract and fan-out point Platform / data-contract owner Schema drift breaks every subscriber
Subscription One consumer’s independent view + backlog Consuming service team Wrong ack deadline, no DLQ, runaway backlog
Consumer app Pulls/receives and processes Consuming service team Non-idempotent handler, slow ack, poison messages
Sinks (BQ/GCS/Dataflow) Where native subscriptions land data Data / analytics team Schema mismatch, write failures → DLQ growth
IAM + service agents Who may publish/subscribe; push identity Security / platform team PERMISSION_DENIED, silent push 403s

Core concepts

Six mental models make every later decision obvious.

A topic is a named, schema-aware fan-out point; a subscription is one consumer’s durable view of it. A publisher sends a message (a payload of up to 10 MB plus key/value attributes) to a topic. The topic does not store messages for direct reading — instead, every subscription attached to the topic gets its own independent copy of every message published after that subscription was created, with its own backlog, its own retention clock, and its own acknowledgement state. Two subscriptions on one topic are two completely separate streams. This is the whole architecture: publish once, deliver to every subscription, each consumed independently.

Delivery is at-least-once by default, which means duplicates are normal, not exceptional. Pub/Sub guarantees a message is delivered at least once, and will redeliver any message it does not see acknowledged (acked) before the ack deadline expires. Network blips, consumer restarts, a handler that’s slow to ack, even Pub/Sub’s own internal mechanics can cause the same message to arrive two or more times. Therefore every handler must be idempotent — processing the same message twice must produce the same result as processing it once. This single fact drives more correct (and incorrect) Pub/Sub designs than anything else.

The ack deadline is a lease, and you can extend it. When a consumer receives a message, it gets an exclusive lease for the ack deadline (default 10 seconds, configurable 10–600 s). If it acks within the lease, the message is removed from the subscription. If the lease expires first, Pub/Sub assumes the consumer failed and redelivers the message (to that consumer or another). For work that legitimately takes longer than the deadline, the client library issues modifyAckDeadline calls to extend the lease while processing continues — the official client libraries do this automatically up to a maxExtensionPeriod. A deadline shorter than your real processing time is the number-one cause of “why is everything being processed twice?”

Ordering and exactly-once are opt-in and have costs. By default Pub/Sub does not guarantee order and gives at-least-once (so, duplicates). Turn on message ordering and messages sharing an ordering key are delivered in publish order to a given subscriber, at the cost of throughput per key and head-of-line blocking. Turn on exactly-once delivery and Pub/Sub guarantees no acked message is redelivered and gives stronger ack semantics, at the cost of region-pinned subscribers and modestly higher latency. You choose these per subscription, deliberately, when correctness needs them — not by default.

The failure path is first-class: retry policy + dead-letter topic. A message that a consumer keeps nacking (negative-acknowledging) or never acking would otherwise redeliver forever, blocking progress. A retry policy controls the backoff between redeliveries (immediate, or exponential 10 s → 600 s). A dead-letter topic (DLQ) moves a message that has exceeded its maximum delivery attempts (5–100) onto a separate topic, so the poison message is quarantined for inspection and the subscription keeps flowing. Together they turn “one bad message wedges the pipeline” into “bad messages land in a DLQ you alert on.”

Pub/Sub is global; Pub/Sub Lite is zonal and capacity-provisioned. Standard Pub/Sub is a global service: publish in one region, subscribe in another, no capacity planning, pay per data volume. Pub/Sub Lite is a separate, lower-cost product where you provision throughput and storage capacity in a single zone/region — far cheaper at sustained high volume, but with manual capacity management and fewer features. They are different products with the same conceptual model; choosing between them is a real architectural decision covered below.

The vocabulary in one table

Pin down every moving part before the deep sections. The glossary at the end repeats these for lookup; this table is the mental model side by side:

Term One-line definition Where it lives Why it matters
Topic Named fan-out point publishers send to Project, global The contract; every subscription branches from it
Subscription One consumer’s durable, independent view of a topic Project, attached to a topic Holds backlog, retention, ack state per consumer
Message Payload (≤10 MB) + attributes + optional ordering key In flight / in backlog The unit of delivery; duplicates possible
Attributes Up to 100 key/value string pairs on a message On the message Routing, filtering, metadata without parsing the body
Publisher Code/service that sends messages to a topic Producing app Decoupled from all consumers
Subscriber Code/service that receives from a subscription Consuming app Must be idempotent (at-least-once)
Ack / Nack Confirm processed / signal failed-redeliver-now Subscriber → Pub/Sub Drives removal vs redelivery
Ack deadline Lease window to ack before redelivery (10–600 s) Subscription setting Too short → duplicate processing
Ordering key Tag that forces per-key in-order delivery On the message Order at the cost of throughput
Exactly-once No redelivery of acked messages (per subscription) Subscription flag Correctness without dedup code
Dead-letter topic (DLQ) Where messages go after max delivery attempts Subscription setting + a topic Quarantines poison messages
Retry policy Backoff between redeliveries Subscription setting Controls retry storms vs latency
Schema Avro/Protobuf contract enforced at publish Project, bound to a topic Stops bad payloads at the source
Snapshot / Seek Saved ack state / rewind a subscription in time Subscription operation Replay and reprocessing
Flow control Client cap on in-flight messages/bytes Subscriber client config Prevents a consumer overwhelming itself

Topics, subscriptions and the publish path

Everything starts with a topic. A topic is cheap, has no fixed throughput to provision, and is global. The first design question is granularity: one topic per event type, or one fat topic with a type attribute? The practitioner default is one topic per logical event (order-placed, order-shipped, payment-captured) because subscriptions, IAM, schemas and DLQs all attach at the topic level — a coarse topic forces every subscriber to filter and shares one access boundary across unrelated events.

Create a topic and publish, both CLI and client code:

# Create a topic with 7-day retention (so subscriptions created later can replay)
gcloud pubsub topics create order-placed \
  --message-retention-duration=7d

# Publish a message with a JSON body and routing attributes
gcloud pubsub topics publish order-placed \
  --message='{"orderId":"A-10231","total":2499,"currency":"INR"}' \
  --attribute=region=in-south,priority=high
# Python: a single, reused PublisherClient (do NOT create one per message)
from google.cloud import pubsub_v1
import json

publisher = pubsub_v1.PublisherClient()
topic_path = publisher.topic_path("my-project", "order-placed")

def publish_order(order: dict) -> str:
    data = json.dumps(order).encode("utf-8")           # body must be bytes
    future = publisher.publish(
        topic_path, data,
        region="in-south", priority="high",            # attributes are kwargs
    )
    return future.result(timeout=30)                   # blocks; returns messageId

Then attach subscriptions. The cardinal rule that surprises everyone: a subscription only receives messages published after it was created. A message published to a topic with zero subscriptions is delivered to nobody and is gone. Create the subscription first, then publish. (For replay of past messages you use retention + seek, covered later — and the subscription must already have existed when those messages were published.)

# A pull subscription with a 30-second ack deadline and 7-day retention
gcloud pubsub subscriptions create inventory-sub \
  --topic=order-placed \
  --ack-deadline=30 \
  --message-retention-duration=7d \
  --expiration-period=never

The topic- and subscription-level knobs you set at creation, with their real defaults and limits:

Setting Scope Default Range / values When to change
message-retention-duration Topic & subscription 7 days 10 min – 31 days Longer for replay/audit; shorter to cut storage cost
ack-deadline Subscription 10 s 10 – 600 s Raise to just above your real p99 processing time
expiration-period Subscription 31 days idle 1 day – never never for long-lived prod subs; default reaps abandoned ones
retain-acked-messages Subscription off on/off On to allow seek-back to before messages were acked
enable-message-ordering Subscription off on/off On only when per-key order is required
enable-exactly-once-delivery Subscription off on/off On when duplicates are unacceptable and dedup is costly
dead-letter-topic Subscription none a topic Always, in production, for poison-message safety
max-delivery-attempts Subscription (with DLQ) 5 5 – 100 Tune to how many retries a transient fault deserves
min/max-retry-delay Subscription n/a (immediate) 0 s – 600 s Set exponential backoff to tame retry storms
filter Subscription none attribute expression Receive only a slice of the topic (set at create, immutable)
push-endpoint Subscription none (pull) HTTPS URL Makes it a push subscription

Hard limits worth memorising, because hitting them is a production incident, not a tuning exercise:

Limit Value What happens at the edge
Max message size 10 MB Larger publish is rejected; use Cloud Storage + a pointer message
Max attributes per message 100 Extra attributes rejected
Max attribute key / value size 256 bytes / 1024 bytes Oversized attribute rejected
Ack deadline range 10 – 600 s Outside range rejected; library extends lease up to maxExtensionPeriod
Subscriptions per topic 10,000 Plan fan-out within this; rarely hit
Message retention 10 min – 31 days Beyond 31 days, archive to a sink (GCS/BigQuery)
Default publish throughput Very high, auto-scaled Per-region quota; request increases if needed
Ordering-key throughput ~1 MB/s per ordering key A hot key serialises and caps throughput
Max outstanding (exactly-once) Region-pinned subscribers Subscriber must connect to the message-storage region

Push vs pull vs streaming pull: the delivery model

How a consumer gets messages is the most consequential subscription choice. There are three subscriber-driven models plus two native sinks; this section covers the three subscriber models and the next covers the native ones.

Pull (unary): the consumer calls Pull to fetch a batch, processes, then calls Acknowledge. Simple, explicit, but a request-per-batch is inefficient at scale. Almost nobody uses raw unary pull directly — the client libraries wrap streaming pull instead.

Streaming pull: the consumer opens a long-lived gRPC stream; Pub/Sub pushes messages down the stream as they arrive, the client library manages leases (auto-extending the ack deadline), flow control, and concurrency. This is what subscriber.subscribe(...) gives you in every official client library. It’s the right default for any consumer you run yourself (GKE pods, Compute Engine, a long-running Cloud Run service with min-instances, a worker process).

Push: Pub/Sub makes an HTTPS POST to your endpoint for each message; you return 2xx to ack, any other status (or a timeout) to nack. No client library, no polling — ideal for serverless handlers (Cloud Run, Cloud Functions) that scale on request volume and to zero. The endpoint must be HTTPS; for security you attach a service account so Pub/Sub sends an OIDC token your endpoint verifies.

The decision, side by side:

Dimension Pull (unary) Streaming pull (library) Push
Who initiates Consumer requests batches Consumer holds a stream; server pushes down it Pub/Sub POSTs to your URL
Typical runtime Rare; debugging/scripts GKE, GCE, always-on workers Cloud Run/Functions, serverless
Ack mechanism Explicit Acknowledge call Library acks on your callback return HTTP 2xx response
Scaling model You manage concurrency Library flow control + threads Scales with endpoint (to zero)
Latency Higher (poll gaps) Low (stream) Low (immediate POST)
Throughput ceiling Lowest Highest (parallel streams) Bounded by endpoint capacity
Backpressure Manual Built-in flow control Endpoint 429/5xx → retry w/ backoff
Endpoint requirement None None Public HTTPS, OIDC-verifiable
Best for Almost never Self-run consumers needing max throughput Event-driven serverless fan-out

Streaming pull in practice

# Streaming pull with the Python client library (handles leases + flow control)
from google.cloud import pubsub_v1
from concurrent.futures import TimeoutError

subscriber = pubsub_v1.SubscriberClient()
sub_path = subscriber.subscription_path("my-project", "inventory-sub")

def callback(message: pubsub_v1.subscriber.message.Message) -> None:
    try:
        order = message.data.decode("utf-8")
        decrement_stock(order)          # your idempotent work
        message.ack()                   # success → remove from backlog
    except TransientError:
        message.nack()                  # fail → redeliver per retry policy
    # an unhandled exception = no ack = redeliver after the ack deadline

# Flow control: never hold more than 100 messages or 50 MB in flight
flow = pubsub_v1.types.FlowControl(max_messages=100, max_bytes=50 * 1024 * 1024)
streaming_future = subscriber.subscribe(sub_path, callback=callback, flow_control=flow)

try:
    streaming_future.result()           # block forever, processing messages
except (KeyboardInterrupt, TimeoutError):
    streaming_future.cancel()
    streaming_future.result()

Push to Cloud Run / Cloud Functions

# A push subscription that POSTs to a Cloud Run service, authenticated with OIDC
gcloud pubsub subscriptions create order-push-sub \
  --topic=order-placed \
  --push-endpoint=https://order-handler-xyz.a.run.app/events \
  --push-auth-service-account=pubsub-push@my-project.iam.gserviceaccount.com \
  --ack-deadline=60
# The Cloud Run handler. 2xx = ack; anything else = nack (redelivered).
import base64, json
from flask import Flask, request

app = Flask(__name__)

@app.post("/events")
def handle():
    envelope = request.get_json(silent=True)
    if not envelope or "message" not in envelope:
        return ("bad request", 400)              # malformed → nack
    msg = envelope["message"]
    data = base64.b64decode(msg["data"]).decode() if msg.get("data") else ""
    order = json.loads(data)
    try:
        process_order(order)                     # idempotent
        return ("", 204)                         # ACK
    except TransientError:
        return ("retry later", 503)              # NACK → Pub/Sub retries

The push payload always wraps your message in an envelope: {"message": {"data": "<base64>", "attributes": {...}, "messageId": "...", "publishTime": "..."}, "subscription": "..."}. Decode data from base64; read routing from attributes. The OIDC token arrives in the Authorization: Bearer header — Cloud Run verifies it automatically when the service requires authentication and the push service account has roles/run.invoker.

Push-specific behaviour and gotchas you must plan for:

Aspect Behaviour Implication
Ack signal HTTP 2xx within ack deadline A slow handler that 200s late may already have been redelivered
Nack signal Non-2xx or timeout 4xx and 5xx both retry; there is no “drop” status — use a DLQ
Backoff on failure Push backs off automatically on errors/slow endpoints Sustained failures throttle delivery (good) but grow backlog
Auth OIDC token from push-auth-service-account Endpoint must require auth + grant SA invoker, or it’s open
Throughput Bounded by your endpoint’s concurrency A slow endpoint caps delivery; scale the service or use pull
No batching to endpoint One message per POST (by default) High volume = many requests; pull batches more efficiently
Endpoint must be public HTTPS Internal-only services need ingress config Use Cloud Run “internal + Pub/Sub” ingress or a pull worker

BigQuery and Cloud Storage subscriptions: no-code sinks

Two subscription types deliver directly to a Google sink with no consumer code at all — Pub/Sub itself writes the data. They replace the once-universal “Dataflow streaming insert” or “tiny Cloud Function that writes to BigQuery” patterns for the common case, removing an entire moving part.

BigQuery subscription: messages are written straight into a BigQuery table. You either map message attributes/JSON to columns, or land the whole message in a wide schema. No Dataflow job, no compute to run — Pub/Sub streams into BigQuery and you pay (reduced) Pub/Sub data throughput plus BigQuery storage. It’s the fastest path from “events on a topic” to “queryable in BigQuery.”

# Stream a topic straight into a BigQuery table, using the table's schema
gcloud pubsub subscriptions create orders-to-bq \
  --topic=order-placed \
  --bigquery-table=my-project:analytics.orders \
  --use-topic-schema \
  --write-metadata

Cloud Storage subscription: Pub/Sub batches messages and writes them as files (Avro or text) to a GCS bucket, rolling files by size or time. Ideal for cheap archival, a data-lake landing zone, or batch downstreams. You pick the batch trigger (e.g. every 5 minutes or every 100 MB).

# Batch messages into 5-minute / 100 MB files in a GCS bucket as Avro
gcloud pubsub subscriptions create orders-to-gcs \
  --topic=order-placed \
  --cloud-storage-bucket=my-project-events-lake \
  --cloud-storage-file-prefix=orders/ \
  --cloud-storage-max-duration=300s \
  --cloud-storage-max-bytes=100000000 \
  --cloud-storage-output-format=avro

When to reach for each delivery target — the full menu:

Target What it does Consumer code? Best for Watch-out
Pull / streaming pull You fetch and process Yes (a worker) Max-throughput, complex logic, self-run You operate the consumer
Push Pub/Sub POSTs to your URL Yes (an endpoint) Serverless, scale-to-zero handlers Endpoint capacity caps throughput
BigQuery subscription Writes rows to a BQ table No Streaming analytics, no transform Schema mismatch → DLQ; no enrichment
Cloud Storage subscription Batches to GCS files No Archival, data lake, batch Latency = batch window; file management
Dataflow (read) A pipeline reads the subscription Yes (a pipeline) Windowing, joins, transforms at scale Operate a Dataflow job; cost
Eventarc Routes events (incl. Pub/Sub) to targets Indirect Standardised event routing to Cloud Run Adds an abstraction layer

For the BigQuery and Cloud Storage subscriptions, Pub/Sub’s own service agent does the writing, so it needs IAM on the destination — a step people forget, producing a healthy-looking subscription whose backlog silently climbs because every write is denied:

Native subscription Service agent needs On the resource
BigQuery subscription roles/bigquery.dataEditor (+ metadataViewer) The dataset/table
Cloud Storage subscription roles/storage.objectCreator (+ legacy bucket reader) The bucket
Either, if topic has a schema roles/pubsub.viewer on the topic The topic

The Pub/Sub service agent is service-<PROJECT_NUMBER>@gcp-sa-pubsub.iam.gserviceaccount.com — grant it, not your own account, the destination role.

Message ordering with ordering keys

By default, Pub/Sub does not guarantee order. Under fan-out, retries and parallelism, message 2 can arrive before message 1. For many event systems that is fine — an order-placed and an order-cancelled for different orders have no relative order that matters. But within a single entity (one order, one device, one account) you often need events applied in publish order, or state goes wrong (apply cancelled before placed and you cancel nothing, then create a ghost).

Ordering keys solve this. Tag related messages with the same ordering key (e.g. the order ID), enable ordering on the subscription, and Pub/Sub delivers messages with the same key in the order they were published, to a single subscriber, one at a time — the next is not delivered until the current one is acked. Messages with different keys are still parallel and unordered relative to each other, so throughput scales with the number of distinct keys.

# Ordering must be enabled on the SUBSCRIPTION
gcloud pubsub subscriptions create order-events-ordered \
  --topic=order-events \
  --enable-message-ordering \
  --ack-deadline=30
# Publisher MUST set enable_message_ordering AND publish to the same region endpoint
from google.cloud import pubsub_v1

opts = pubsub_v1.types.PublisherOptions(enable_message_ordering=True)
publisher = pubsub_v1.PublisherClient(
    publisher_options=opts,
    client_options={"api_endpoint": "asia-south1-pubsub.googleapis.com:443"},
)
topic = publisher.topic_path("my-project", "order-events")

def publish_event(order_id: str, body: bytes):
    # All events for one order share the ordering key → delivered in order
    return publisher.publish(topic, body, ordering_key=order_id)

The mechanics and the costs you accept by enabling ordering:

Property With ordering keys Why it matters
Order guarantee Per-key, in publish order, to a single subscriber Correct state for an entity’s event sequence
Throughput ~1 MB/s per key A hot key serialises; pick high-cardinality keys
Parallelism Across keys only Many keys = high aggregate throughput; one key = a bottleneck
Head-of-line blocking A stuck/nacked message blocks its key One poison message stalls that key until resolved/DLQ’d
Publisher requirement Must enable ordering AND pin to a region endpoint Forget either and ordering silently won’t hold
Resume after error If a publish fails, later same-key publishes are rejected until resume_publish You must call resume_publish(ordering_key) to recover
Interaction with DLQ A repeatedly-failing message dead-letters, unblocking the key DLQ is what prevents a permanent per-key stall

Choosing the ordering key is the whole game. The key should be the identity of the thing whose events must be ordered, at the highest cardinality that still preserves the needed order:

Scenario Good ordering key Bad ordering key Why
Per-order lifecycle orderId region Region is one hot key serialising everything
Per-device telemetry deviceId deviceType Type buckets thousands of devices onto one key
Per-account ledger accountId currency Currency is low-cardinality → throughput cliff
Per-user session sessionId userId (if multi-session) Session is the true ordering unit
Global strict order (rare, slow) one constant key Only if you truly need total order; ~1 MB/s cap

Exactly-once delivery: when at-least-once isn’t enough

At-least-once + idempotent handlers is the default and is correct for most systems. But some operations are genuinely hard to make idempotent (a non-idempotent legacy API, a “send exactly one email,” a financial side-effect with no natural dedup key), and adding a dedup store (Redis/Firestore keyed on messageId) is real work. For those, exactly-once delivery moves the guarantee into Pub/Sub.

Enabling it changes three things: (1) an acknowledged message is never redelivered; (2) ack/nack become reliable operations with statusack() can succeed or fail, and you only consider work complete on a successful ack; (3) the subscriber is pinned to the message-storage region. Within a subscription, you still might receive a message more than once before it’s acked (a redelivery of an unacked message), but once acked it’s gone for good — so you de-duplicate only across in-flight redeliveries (which the library and messageId handle), not across the whole stream.

gcloud pubsub subscriptions create payments-eo-sub \
  --topic=payment-capture \
  --enable-exactly-once-delivery \
  --ack-deadline=60
# With exactly-once, ack() returns a future you should check
from google.cloud import pubsub_v1

def callback(message):
    capture_payment(message.data)          # non-idempotent side-effect
    ack_future = message.ack_with_response()
    try:
        ack_future.result()                # confirm the ack actually committed
    except pubsub_v1.exceptions.AcknowledgeError as e:
        # ack failed (e.g. lease expired) — the message may be redelivered;
        # do NOT treat the work as durably complete
        log.warning("ack failed: %s", e.error_code)

At-least-once vs exactly-once, decided honestly:

Dimension At-least-once (default) Exactly-once
Duplicates after ack Possible (rare, but happens) None — acked = gone forever
Handler requirement Must be idempotent Idempotency optional (but still wise)
Ack semantics Best-effort Reliable, with success/failure status
Region Any subscriber region Pinned to message-storage region
Latency Lowest Slightly higher
Throughput Highest Slightly lower
Ordering Optional, independent Combine with ordering for ordered + exactly-once
Cost Standard Standard (no surcharge; the cost is constraints)
Use when You can dedup or are naturally idempotent Dedup is expensive/impossible; side-effect is unforgiving

The honest rule: prefer idempotent handlers and at-least-once. Reach for exactly-once when the side-effect is truly non-idempotent and a dedup store is more complex or expensive than accepting the region-pinning and latency cost. It is a powerful feature, not a default to sprinkle everywhere.

Dead-letter topics and retry policy: the failure path

A subscription with no failure plan has exactly one: retry forever. A message your consumer can never process (malformed JSON, a referenced record that no longer exists, a bug that throws on one shape of input) will be nacked or time out, redelivered, fail again, forever — consuming quota, polluting logs, and (with ordering on) blocking its key permanently. Production subscriptions need two settings to make failure survivable.

Retry policy controls the backoff between redeliveries. The default is effectively immediate redelivery, which turns a transient downstream outage into a redelivery storm. Set exponential backoff so retries space out (10 s, then doubling up to 600 s), giving a flapping dependency room to recover:

gcloud pubsub subscriptions update inventory-sub \
  --min-retry-delay=10s \
  --max-retry-delay=600s

Dead-letter topic moves a message that has been delivered max-delivery-attempts times (5–100) onto a separate topic, so it’s quarantined and the subscription keeps flowing. You then attach a subscription to the DLQ to inspect, alert, and reprocess:

# 1) A topic to hold poison messages
gcloud pubsub topics create order-placed-dlq

# 2) Point the main subscription at it, after 5 attempts
gcloud pubsub subscriptions update inventory-sub \
  --dead-letter-topic=order-placed-dlq \
  --max-delivery-attempts=5

# 3) The Pub/Sub service agent must be allowed to publish to the DLQ
#    AND to ack on the source subscription
PROJECT_NUMBER=$(gcloud projects describe my-project --format='value(projectNumber)')
SA="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com"

gcloud pubsub topics add-iam-policy-binding order-placed-dlq \
  --member="$SA" --role=roles/pubsub.publisher
gcloud pubsub subscriptions add-iam-policy-binding inventory-sub \
  --member="$SA" --role=roles/pubsub.subscriber

The DLQ machinery, setting by setting:

Setting Default Range Effect Gotcha
dead-letter-topic none a topic Where failed messages land Service agent needs publisher on it
max-delivery-attempts 5 (when DLQ set) 5 – 100 Attempts before dead-lettering Too low = transient faults dead-letter; too high = slow quarantine
min-retry-delay ~immediate 0 – 600 s Floor of backoff between retries 0 with a flapping dep = retry storm
max-retry-delay ~immediate 0 – 600 s Ceiling of backoff Cap so retries don’t stall a day
Delivery-attempt count tracked per message Surfaced as deliveryAttempt field Read it to log “attempt N of 5”
DLQ message attributes adds source info Original subscription/reason attached Use it to route reprocessing

Crucially, a message in the DLQ carries attributes identifying where it came from, so a reprocessing job (or a human after a fix) can replay it onto the original topic. Treat the DLQ subscription’s backlog as an alert: a non-zero, growing DLQ means messages are being permanently dropped from the main flow and need eyes.

A reprocessing sketch — drain the DLQ back onto the source topic after fixing the bug:

# Pull from the DLQ, re-publish to the original topic, ack the DLQ message
def reprocess_dlq(dlq_sub_path, source_topic_path, subscriber, publisher):
    resp = subscriber.pull(subscription=dlq_sub_path, max_messages=100)
    for rm in resp.received_messages:
        publisher.publish(source_topic_path, rm.message.data,
                          **dict(rm.message.attributes)).result()
        subscriber.acknowledge(subscription=dlq_sub_path,
                               ack_ids=[rm.ack_id])

Schemas: enforcing the message contract

A topic without a schema accepts any bytes, so a producer bug (renamed field, wrong type, truncated JSON) sails through publish and explodes in every consumer — far from the source, multiplied by fan-out. Schemas push the contract to the topic: attach an Avro or Protocol Buffer schema, and Pub/Sub validates every publish against it, rejecting non-conforming messages at the door.

# 1) Define an Avro schema
cat > order.avsc <<'EOF'
{ "type": "record", "name": "Order", "fields": [
  { "name": "orderId",  "type": "string" },
  { "name": "total",    "type": "double" },
  { "name": "currency", "type": "string", "default": "INR" }
]}
EOF
gcloud pubsub schemas create order-schema --type=avro --definition-file=order.avsc

# 2) Bind it to a topic and require JSON-or-binary encoding
gcloud pubsub topics create order-placed-v2 \
  --schema=order-schema \
  --message-encoding=json

Now a publish missing orderId or with a string total is rejected with an error, at the producer, before any consumer is touched. The contract is enforced once, centrally.

Schema choices and how to evolve them without a flag day:

Aspect Avro Protocol Buffers
Encoding on the wire JSON or binary JSON or binary
Strength Rich schema evolution rules; analytics-friendly Compact; strong typing; gRPC-native
Best fit Data/analytics pipelines, BigQuery subscriptions Service-to-service, polyglot APIs
Evolution model Add fields with defaults; reader/writer resolution Add fields with new tags; never reuse a tag number
BigQuery subscription Maps cleanly to columns Supported, mapped via schema

The compatibility rules you must respect so old and new subscribers coexist:

Change Safe? Why
Add an optional field (with default) Yes Old readers ignore it; new readers get the default
Remove an optional field Usually Only if no consumer requires it
Add a required field with no default No Old producers’ messages become invalid
Rename a field No It’s a remove + add; breaks readers
Change a field’s type No Reader/writer resolution fails
Create a new schema revision Yes Pub/Sub supports revisions; pin/validate per topic

Practical rule: only ever add optional fields with defaults, version with schema revisions when you must break, and never reuse a Protobuf tag or rename an Avro field in place. Schema-on-topic is the single highest-leverage reliability control in a multi-team event system, because it stops bad data at the one place it’s cheap to stop.

Pub/Sub vs Pub/Sub Lite, and Dataflow integration

Pub/Sub Lite is a distinct product: a cheaper, capacity-provisioned messaging service for high, predictable throughput where you accept operational ownership. With Lite you create a reservation/topic and provision publish and subscribe throughput (MiB/s) and storage (GiB) yourself, in a single zone or region. At sustained high volume it can be dramatically cheaper than Pub/Sub — but you plan capacity, you live in one zone/region, and you give up push subscriptions, the BigQuery/GCS native subscriptions, and several other features.

Dimension Pub/Sub Pub/Sub Lite
Scope Global Zonal or regional (single)
Capacity Auto-scaled, no provisioning You provision throughput + storage
Cost model Per data volume (higher unit cost) Per provisioned capacity (much lower at scale)
Push subscriptions Yes No (pull only)
BigQuery/GCS subscriptions Yes No
Ordering Ordering keys Per-partition ordering
Exactly-once Yes Via the Kafka/Dataflow client semantics
Ops burden None Manage partitions/capacity
Best for Most workloads; bursty; multi-region; rich features Steady, very high volume where cost dominates
Wrong for Extreme sustained volume on a tight budget Bursty/spiky load; needing push or native sinks

The decision rule: start on Pub/Sub. Move a stream to Lite only when it has steady, high, predictable throughput and the per-GB Pub/Sub bill is material — and you’re willing to own capacity. Most teams never need Lite; a few high-volume telemetry/log streams save a lot with it.

Dataflow is the heavy-transform consumer. When you need windowed aggregations, streaming joins, deduplication at scale, late-data handling, or exactly-once stream processing into BigQuery, a Dataflow (Apache Beam) pipeline reads the Pub/Sub subscription as an unbounded source. Beam’s PubsubIO understands message IDs (for dedup), attributes, and event-time timestamps, and Dataflow gives exactly-once processing semantics within the pipeline.

# Apache Beam (Dataflow): read a subscription, window, write to BigQuery
import apache_beam as beam
from apache_beam.options.pipeline_options import PipelineOptions, StandardOptions

opts = PipelineOptions(streaming=True)
opts.view_as(StandardOptions).runner = "DataflowRunner"

with beam.Pipeline(options=opts) as p:
    (p
     | "Read" >> beam.io.ReadFromPubSub(
            subscription="projects/my-project/subscriptions/orders-dataflow")
     | "Parse" >> beam.Map(parse_order)
     | "Window" >> beam.WindowInto(beam.window.FixedWindows(60))   # 1-min windows
     | "Sum" >> beam.CombinePerKey(sum)
     | "ToBQ" >> beam.io.WriteToBigQuery(
            "my-project:analytics.order_totals_1m",
            write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND))

When to choose which consumer for analytics ingestion:

Need Use Why not the others
Raw events into BigQuery, no transform BigQuery subscription Dataflow is overkill; no code to run
Cheap archival of all events Cloud Storage subscription Native batching; BQ storage is pricier
Windowed aggregates, joins, dedup, late data Dataflow Native subs can’t transform or window
Simple per-event side-effect Push to Cloud Run Dataflow/native subs don’t run your logic
Max-throughput custom processing Streaming pull worker Push caps at endpoint; you want parallel streams

Flow control and subscriber tuning

A subscriber that pulls faster than it can process will OOM, thrash, or exceed ack deadlines and trigger mass redelivery. Flow control is the client-side cap on how much work is outstanding (received but not yet acked) at once — by message count and/or bytes. It is the backpressure valve every self-run consumer needs.

The library holds at most max_messages / max_bytes in flight, stops requesting more until you ack some, and meanwhile auto-extends the ack deadline of held messages (up to max_extension_period). Set these to what one worker can actually process concurrently, not to a guess:

Knob What it caps Default (library) How to set it
max_messages Outstanding messages held ~1000 ≈ your real concurrency × a small factor
max_bytes Outstanding bytes held ~100 MB Below the memory you can hold safely
max_extension_period How long the library keeps extending a lease ~60 min Just above your worst-case processing time
Concurrency / threads Parallel callback execution per library Match CPU/IO profile of the handler
Per-stream throughput Streams the library opens per library Add streams to scale aggregate throughput

The failure these settings prevent — and the symptom if you get them wrong:

Misconfiguration Symptom Fix
Flow control too high, slow handler OOM / leases expire / mass redelivery (duplicates) Lower max_messages/max_bytes to true concurrency
Ack deadline << processing time, no extension Everything processed 2–3× Raise ack deadline; let the library extend leases
Flow control too low Underutilised worker, backlog grows Raise limits or add workers/instances
Long sync work in the callback thread Thread starvation, throughput collapse Offload to a pool; keep the callback fast
No backpressure to upstream calls Downstream API rate-limited → nacks → retries Bound concurrency to the downstream’s limit

Event-driven patterns on Pub/Sub

The primitives compose into a handful of patterns you’ll reuse across systems. Knowing them by name shortcuts design discussions.

Pattern Shape How to build it When
Fan-out 1 topic → N subscriptions, each a consumer One topic, a subscription per consumer One event, many independent reactions
Fan-in / aggregation N producers → 1 topic → 1 consumer Many publishers, one subscription + Dataflow Consolidate streams; aggregate
Work queue / competing consumers 1 subscription, M worker replicas One subscription, scale the consumer horizontally Parallelise load across workers
Routing by attribute 1 topic → filtered subscriptions Subscription --filter on attributes Slice a stream by region/type/priority
Pipeline / choreography Service A → topic → Service B → topic → C Each stage publishes the next event Decoupled multi-step workflows
Event replay Re-deliver a past window Snapshot + seek, or retained backlog Reprocess after a bug fix or new consumer
Dead-letter + reprocess Failures → DLQ → fix → replay DLQ topic + reprocessing job Survive poison messages
Claim-check Large payload in GCS, pointer in message Publish a GCS URI; consumer fetches Payloads near/over the 10 MB limit

Two of these deserve code-level notes. Routing by attribute uses subscription filters, set at creation and immutable, so each consumer only pays for and processes its slice:

# Only deliver high-priority, India-south messages to this subscription
gcloud pubsub subscriptions create urgent-in-sub \
  --topic=order-placed \
  --message-filter='attributes.priority = "high" AND attributes.region = "in-south"'

Event replay uses snapshots and seek. A snapshot captures a subscription’s ack state at a point in time; seeking the subscription to that snapshot (or to a timestamp within retention) re-delivers everything since:

# Snapshot now, then later rewind the subscription to reprocess from this point
gcloud pubsub snapshots create before-deploy --subscription=inventory-sub
# ... deploy a fix ...
gcloud pubsub subscriptions seek inventory-sub --snapshot=before-deploy
# Or rewind to a wall-clock time (within retention):
gcloud pubsub subscriptions seek inventory-sub --time=2026-06-23T09:00:00Z

For replay to reach back before messages were acked, the subscription needs --retain-acked-messages and sufficient --message-retention-duration. Seek is the feature that lets you reprocess history after fixing a consumer bug or onboarding a brand-new consumer that needs to backfill.

Architecture at a glance

The first diagram shows the fan-out that is the heart of event-driven Pub/Sub. A single publisher (the checkout service) sends one order-placed message to one topic. The topic does no work of its own except branch: every subscription attached to it receives an independent copy. Follow the three branches — an inventory subscription feeding a worker that decrements stock, an email subscription feeding a notification handler, and an analytics subscription feeding the data path. Each subscription holds its own backlog and ack state, so the slow branch (analytics) backs up in its own queue while the fast branches (inventory, email) are untouched. That isolation — one publish, N independent consumers, no shared fate — is the entire value proposition in one picture.

Cloud Pub/Sub fan-out: a checkout publisher sends one order-placed message to a single topic, which delivers an independent copy to three separate subscriptions — inventory, email and analytics — each with its own backlog and consumer, so a slow analytics consumer backs up in its own queue without affecting inventory or email

The second diagram traces a concrete end-to-end event flow: a file lands in Cloud Storage, which emits an event that flows through Pub/Sub to a consumer that loads the data into BigQuery. Read it left to right — the GCS upload is the producer event, Pub/Sub is the durable decoupling buffer that absorbs bursts and retries on failure, and BigQuery is the analytical sink. This is the canonical ingestion shape: an operational event (an upload, a transaction, a device reading) becomes a durable message, and a consumer (or a native BigQuery subscription) turns it into queryable data — the operational side stays fast while the analytical side is eventually consistent and independently scalable.

End-to-end event flow: a file uploaded to Cloud Storage emits an event that travels through Cloud Pub/Sub as a durable buffer to a consumer that loads the record into BigQuery, decoupling the fast upload path from the eventually-consistent analytics path

The shared lesson of both diagrams: Pub/Sub sits between a producer and one-or-more consumers as a durable, elastic buffer, and the unit of independence is the subscription. Design topic boundaries around events, subscription boundaries around consumers, and the rest of the system inherits decoupling for free.

Real-world scenario

Kanban Kart, a mid-market Indian e-commerce company, ran the synchronous checkout described in the intro: a Cloud Run checkout service that, inside the customer’s request, called inventory, payments, an email provider, and an analytics ingest endpoint in sequence. Peak traffic was ~600 orders/minute, spiking to ~4,000/minute during festival sales. The platform team was five engineers; monthly spend on the order path was about ₹40,000.

The incident hit on a Diwali sale. At 10:42, the analytics ingest endpoint — a small service writing to BigQuery via streaming inserts — hit a quota limit and began returning 503s with 7-second timeouts. Because checkout awaited analytics last in the chain, every order request now blocked for 7 seconds before failing. Cloud Run scaled checkout to its max instances, but each instance was stuck waiting on analytics, so concurrency saturated and healthy orders started returning 500. Payments and inventory were fine; the least important consumer had taken down the storefront at peak revenue. The on-call’s first reflex — bump Cloud Run max-instances — made it worse (more instances, all stuck on analytics). Twenty-two minutes of partial checkout outage during a sale is real money.

The breakthrough was recognising the coupling, not the capacity. The fix, shipped over the following sprint, was Pub/Sub fan-out. Checkout was changed to do exactly one thing after persisting the order: publish an order-placed message to a topic, then return 201 to the customer. That call is single-digit milliseconds and never waits on a downstream. Four subscriptions were created: inventory (push to a Cloud Run service, ack deadline 30 s), payments (push, exactly-once delivery enabled because a double-capture is unforgiving, ordering key orderId so per-order events stay ordered), email (push to a notification service), and analytics (a BigQuery subscription — no code at all, Pub/Sub streams straight into the warehouse). Every subscription got a dead-letter topic with max-delivery-attempts=10 and exponential retry (10 s → 600 s).

The payoff showed at the next sale. A burst of 4,000 orders/minute published in milliseconds; checkout p99 fell from ~900 ms (synchronous) to ~40 ms. When the analytics path again hit a quota wobble, the BigQuery subscription’s backlog grew to ~9,000 messages and drained within minutes once quota recovered — checkout never noticed. A bug in the email service that threw on one malformed address sent ~30 messages to its DLQ; the team fixed the parser, ran the reprocessing job to replay the DLQ, and lost zero notifications. The order path bill rose modestly (Pub/Sub data volume + the BigQuery subscription throughput) to about ₹44,000, but the architecture absorbed a 6× burst with no capacity ticket and no outage.

The incident and fix as a timeline, because the sequence is the lesson:

Time / phase State Action Effect What it taught
10:42 Analytics 503s, 7 s timeouts (alert fires) Checkout blocks on analytics A leaf can fail the root
10:48 Checkout 500s at peak Bump Cloud Run max-instances Worse — instances stuck waiting Capacity doesn’t fix coupling
11:04 Outage understood Stop scaling; accept the loss for now Bleeding slows Synchronous fan-out was the bug
+1 sprint Re-architected Checkout publishes one message, returns p99 900 ms → 40 ms Decouple in time and failure
Next sale 4,000/min burst Publish in ms; 4 subs consume independently No outage; analytics backlog drained alone Fan-out isolates fate
Next sale Email bug on 30 msgs DLQ caught them; fix + replay Zero lost notifications DLQ + replay = no data loss

Advantages and disadvantages

Pub/Sub trades synchronous simplicity for decoupling and elasticity. Weigh it honestly:

Advantages Disadvantages
Decouples producers from consumers in time, load and failure — a slow/down consumer can’t break the producer Eventual consistency — consumers lag; “is it done?” has no synchronous answer
Elastic buffering absorbs bursts (festival sales, IoT storms) with no capacity planning At-least-once duplicates force idempotent handlers (or exactly-once + its constraints)
Fan-out to N consumers by adding subscriptions — zero change to existing code Operational complexity — distributed debugging across topics, subscriptions, DLQs
Fully managed and global — no brokers to run, publish/subscribe across regions Ordering costs throughput (~1 MB/s per key) and adds head-of-line blocking
Native sinks (BigQuery, GCS) and Dataflow remove whole pipelines of glue code Cost can surprise at high volume or long retention; telemetry can dwarf compute
First-class failure path — DLQ + retry policy + seek/replay make failures recoverable A misconfigured ack deadline or missing DLQ silently causes duplicates or stuck backlogs
Schemas enforce the contract at the source, protecting every subscriber More moving parts than a direct call — overkill for synchronous request/response

The model is right when consumers must react independently, when load is bursty, when you need fan-out or replay, or when you’re streaming to analytics. It is the wrong tool for synchronous request/response where the caller needs an immediate, definitive answer (use a direct API call or RPC), for strict global total ordering at high throughput (the per-key cap bites), and for tiny, low-volume systems where the operational overhead exceeds the benefit. The disadvantages are all manageable — idempotency, a sane ack deadline, a DLQ, an alert on backlog — but only if you know they exist, which is the point of this article.

Hands-on lab

Build a complete fan-out with a dead-letter path, observe ordering and redelivery, and tear it all down. Free-tier-friendly: a few small messages cost effectively nothing; delete at the end. Run in Cloud Shell.

Step 1 — Set your project and enable the API.

gcloud config set project $(gcloud config get-value project)
gcloud services enable pubsub.googleapis.com
PROJECT_ID=$(gcloud config get-value project)
PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
echo "Project: $PROJECT_ID ($PROJECT_NUMBER)"

Step 2 — Create the topic and a dead-letter topic.

gcloud pubsub topics create lab-orders --message-retention-duration=1d
gcloud pubsub topics create lab-orders-dlq

Expected: two Created topic lines.

Step 3 — Create two subscriptions on the topic (fan-out), one with a DLQ.

# Consumer A: inventory, 20 s ack deadline, dead-letters after 5 attempts
gcloud pubsub subscriptions create lab-inventory \
  --topic=lab-orders --ack-deadline=20 \
  --dead-letter-topic=lab-orders-dlq --max-delivery-attempts=5 \
  --min-retry-delay=10s --max-retry-delay=600s

# Consumer B: analytics, plain pull
gcloud pubsub subscriptions create lab-analytics \
  --topic=lab-orders --ack-deadline=20

Step 4 — Grant the Pub/Sub service agent the DLQ permissions (forgetting this is the #1 lab failure).

SA="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com"
gcloud pubsub topics add-iam-policy-binding lab-orders-dlq \
  --member="$SA" --role=roles/pubsub.publisher
gcloud pubsub subscriptions add-iam-policy-binding lab-inventory \
  --member="$SA" --role=roles/pubsub.subscriber

Step 5 — Publish three messages and watch fan-out.

for id in A-1 A-2 A-3; do
  gcloud pubsub topics publish lab-orders \
    --message="{\"orderId\":\"$id\",\"total\":999}" \
    --attribute=priority=high
done

Expected: three messageIds. The same three messages are now waiting in both subscriptions independently.

Step 6 — Pull and ack from one subscription; the other still has all three.

# Pull from inventory and ACK (auto-ack), then confirm analytics is untouched
gcloud pubsub subscriptions pull lab-inventory --limit=10 --auto-ack
gcloud pubsub subscriptions pull lab-analytics --limit=10   # NO --auto-ack: leaves them

Expected: lab-inventory returns the three messages and removes them; lab-analytics also returns three (proving fan-out gives each subscription its own copy). Because you didn’t --auto-ack the analytics pull, those remain for redelivery.

Step 7 — Observe redelivery and the delivery-attempt counter. Pull lab-analytics again without acking, a few times; note the messages keep coming back (ack deadline expires → redelivery). On lab-inventory, if you repeatedly pull-without-ack, after 5 attempts the message moves to lab-orders-dlq:

# Pull WITHOUT auto-ack repeatedly to force redelivery on inventory
for i in 1 2 3 4 5 6; do
  gcloud pubsub subscriptions pull lab-inventory --limit=10 \
    --format='value(message.attributes.googclient_deliveryattempt, message.data)'
  sleep 22   # let the 20 s ack deadline expire so it redelivers
done
# After ~5 attempts the message should appear in the DLQ:
gcloud pubsub subscriptions create lab-dlq-reader --topic=lab-orders-dlq
gcloud pubsub subscriptions pull lab-dlq-reader --limit=10 --auto-ack

Expected: the delivery-attempt value climbs; once it exceeds max-delivery-attempts=5, the message surfaces in lab-orders-dlq — exactly how poison messages are quarantined in production.

Step 8 — Validation checklist.

What you did What it proves
Published once, pulled from two subs Fan-out: each subscription is an independent copy
Acked one sub, the other kept its copies Subscriptions don’t share ack state
Pulled without acking, messages returned At-least-once: unacked → redelivered after the ack deadline
Watched the delivery-attempt counter climb The redelivery/backoff machinery is real and observable
Saw a message land in the DLQ after 5 tries Dead-lettering quarantines poison messages

Step 9 — Teardown (avoid lingering charges, though they’d be tiny).

gcloud pubsub subscriptions delete lab-inventory lab-analytics lab-dlq-reader --quiet
gcloud pubsub topics delete lab-orders lab-orders-dlq --quiet

Cost note. A handful of small messages and a day of retention cost a fraction of a rupee; deleting the topics and subscriptions stops everything. Pub/Sub has a generous free tier (the first 10 GiB of throughput per month is free), so this lab is effectively free.

Common mistakes & troubleshooting

The failures that actually page you, as a scannable table first, then the worst offenders expanded.

# Symptom Root cause Confirm (exact cmd / signal) Fix
1 Everything processed 2–3 times Ack deadline shorter than real processing time Compare handler p99 to --ack-deadline; rising expired_ack_deadlines Raise ack deadline; rely on library lease extension; make handlers idempotent
2 A new subscriber receives nothing for old messages Subscription created after those messages were published gcloud pubsub subscriptions describe; check creation time vs publish time Recreate before publishing; or seek to a retained timestamp (needs prior retention)
3 Backlog grows forever, consumer “fine” Consumer not acking (bug/exception swallowed), or push endpoint 4xx/5xx num_undelivered_messages climbing; oldest_unacked_message_age rising Fix handler to ack on success; check push endpoint logs/status
4 BigQuery/GCS subscription backlog climbs, no errors in your code Pub/Sub service agent lacks IAM on the destination gcloud pubsub subscriptions describe; check sink IAM Grant the service agent bigquery.dataEditor / storage.objectCreator
5 Ordered messages arrive out of order Ordering not enabled on sub, or publisher didn’t set ordering + region describe shows enableMessageOrdering: false; publisher options Enable on sub AND publisher; pin publisher to a region endpoint
6 After one publish failure, that key stops delivering Ordering: a failed publish blocks the key until resumed Publisher logs show the failed future for that key Call resume_publish(ordering_key) after handling the error
7 Push subscription silently delivers nothing Endpoint not public/HTTPS, or OIDC SA lacks run.invoker gcloud run services get-iam-policy; push SA bindings Make endpoint reachable; grant the push SA roles/run.invoker
8 PERMISSION_DENIED on publish/subscribe Principal missing pubsub.publisher/subscriber The error names the missing permission Bind the right role on the topic/subscription
9 Poison message wedges the subscription No DLQ; message nacks forever (worse with ordering) Same message redelivered indefinitely Add a dead-letter topic + max-delivery-attempts
10 DLQ itself fills, source still stuck Service agent can’t publish to DLQ / ack source DLQ empty despite failures; or source not draining Grant service agent publisher on DLQ and subscriber on source sub
11 Consumer OOMs / leases expire under load Flow control too high for the handler’s real concurrency Memory spikes; mass redelivery (duplicates) Lower max_messages/max_bytes to true concurrency
12 Publish rejected with a schema error Message doesn’t match the topic’s bound schema The publish error names the field/type Fix the producer; only add optional fields with defaults
13 Telemetry/Pub/Sub bill spikes Long retention, huge fan-out, or chatty per-event volume Billing breakdown by SKU; retention settings Shorten retention; consolidate topics; consider Pub/Sub Lite
14 Messages vanish before consumer reads Retention window elapsed (or subscription expired idle) oldest_unacked_message_age near retention; sub expirationPolicy Increase retention; set --expiration-period=never for prod subs

The ones that cause the most wasted hours, expanded:

1. Everything is processed two or three times. The classic. Your handler takes 25 seconds, the ack deadline is the default 10. The lease expires mid-processing, Pub/Sub redelivers, and you do the work again — possibly while the first attempt is still running. Confirm: the subscription’s expired_ack_deadlines_count is non-zero and your handler’s p99 exceeds the ack deadline. Fix: set the ack deadline just above your real p99 (--ack-deadline=60 etc.), let the client library extend the lease for legitimately long work (max_extension_period), and — because at-least-once never fully eliminates duplicates — make the handler idempotent (dedup on messageId, or use a natural idempotency key). Raising the deadline reduces duplicates; idempotency makes the residual ones harmless.

3. The backlog grows forever while the consumer “looks fine.” A consumer that swallows an exception and never acks, or a push endpoint quietly returning 500, keeps every message unacked, so num_undelivered_messages climbs without bound. Confirm: oldest_unacked_message_age and num_undelivered_messages rising on the subscription (Cloud Monitoring); for push, the endpoint’s request logs show non-2xx. Fix: ensure the handler acks only on success and nacks/throws on failure; for push, fix whatever makes the endpoint return non-2xx. A growing oldest_unacked_message_age is the single most important Pub/Sub alarm — wire it.

4. A BigQuery or Cloud Storage subscription backs up and there are no errors in your code — because there is no code. Native subscriptions are written by the Pub/Sub service agent, so a missing IAM grant on the destination silently fails every write while the subscription looks healthy. Confirm: the subscription’s backlog climbs; the sink shows no new rows/files; the service agent lacks the role. Fix: grant service-<PROJECT_NUMBER>@gcp-sa-pubsub.iam.gserviceaccount.com the destination role (roles/bigquery.dataEditor on the dataset, roles/storage.objectCreator on the bucket). This is the most surprising failure precisely because there’s no application log to look at.

9. A single poison message wedges the whole subscription. Without a DLQ, a message your consumer can never process is nacked, redelivered, fails, forever — and with ordering on, it blocks its entire key. Confirm: the same messageId reappears indefinitely; with ordering, a key stops progressing. Fix: attach a dead-letter topic with a sane max-delivery-attempts (5–20), grant the service agent the DLQ permissions, and alert on DLQ backlog. The poison message lands in the DLQ after N tries and the main flow resumes.

Best practices

The alerts worth wiring before the next incident — leading indicators, not “the service is down”:

Alert on Metric Threshold (starting point) Why it’s leading
Backlog age subscription/oldest_unacked_message_age > your SLO (e.g. 5 min) The truest “consumer falling behind” signal
Backlog size subscription/num_undelivered_messages sustained growth Confirms the consumer can’t keep up
Expired ack deadlines subscription/expired_ack_deadlines_count > 0 sustained Predicts duplicate processing
DLQ backlog num_undelivered_messages on the DLQ sub > 0 Messages are being permanently dropped from main flow
Push errors subscription/push_request_count by code non-2xx rising Push endpoint failing → backlog growth
Publish errors topic/send_request_count by code errors rising Producer-side failures (schema, quota, auth)

Security notes

The security controls and what each prevents:

Control Mechanism Prevents
Granular IAM roles pubsub.publisher / pubsub.subscriber on the resource A compromised app doing more than its job
Per-workload service accounts Distinct SAs + Workload Identity / attached SA Shared blast radius; un-revocable credentials
OIDC on push push-auth-service-account + run.invoker + require-auth An open, anonymous webhook
No secrets in messages Payload hygiene; references only Secret leakage via retention/DLQ/sinks
Scoped service-agent grants Destination role on destination only Over-privileged platform identity
CMEK Customer-managed keys Loss of control over at-rest encryption
VPC Service Controls Service perimeter Data exfiltration across the boundary

Cost & sizing

Pub/Sub billing is driven by data volume, not by message count or topic/subscription count, plus storage for retention and any seek/snapshot retention. The mental model: you pay for throughput (bytes published, and bytes delivered — fan-out multiplies delivery), and for retained storage (longer retention = more storage). There is a generous free tier (the first 10 GiB of throughput per month), which makes small systems effectively free.

A rough monthly picture for a mid-size event system (~50 GB/month published, 3 subscriptions, 7-day retention): well within or just past the free tier on throughput, with delivery ≈ 3× published — order of a few hundred to low thousands of rupees for Pub/Sub itself, dwarfed by the compute consuming it. The cost drivers and what each buys:

Cost driver What you pay for Rough scaling How to control it
Published throughput Bytes published to topics Per GiB (free first 10 GiB/mo) Right-size payloads; claim-check large blobs to GCS
Delivered throughput Bytes delivered to subscriptions Per GiB × number of subscriptions Don’t over-fan-out; consolidate consumers
Retention storage Retained message-bytes × days Per GiB-month Set retention to real need, not 31 days
Snapshots / seek retention Retained acked messages for replay Per GiB-month Enable retain-acked-messages only where needed
BigQuery subscription Reduced PS rate + BQ storage Per GiB Cheaper than Dataflow for plain loads
GCS subscription Reduced PS rate + GCS storage Per GiB + class Use Nearline/Coldline for archival via lifecycle
Dataflow consumer Worker vCPU/RAM-hours Continuous Reserve for true transforms; autoscale workers
Pub/Sub Lite Provisioned MiB/s + GiB Per provisioned unit Use for steady high volume to cut per-GB cost

Interview & exam questions

1. What is the difference between a topic and a subscription, and how does fan-out work? A topic is the named fan-out point publishers send to; a subscription is one consumer’s durable, independent view of that topic. Every subscription gets its own copy of every message published after the subscription was created, with its own backlog and ack state. Fan-out is simply attaching multiple subscriptions to one topic — publish once, each subscription consumes independently, so a slow consumer can’t affect the others.

2. Pub/Sub delivers at-least-once by default. What does that force on your design? That duplicates are normal — network blips, consumer restarts, or a slow ack cause the same message to be redelivered. Every handler must therefore be idempotent: processing a message twice yields the same result as once. You either dedup on messageId/a natural key, or enable exactly-once delivery (accepting its region-pinning and slightly higher latency).

3. A consumer’s work takes 30 seconds but messages are processed two or three times. Why, and the fix? The ack deadline (default 10 s) is shorter than the processing time, so the lease expires mid-processing and Pub/Sub redelivers. Fix by raising the ack deadline above your real p99 and letting the client library extend the lease (max_extension_period) for long work; keep handlers idempotent because at-least-once never fully eliminates duplicates.

4. How do ordering keys work and what do they cost? Messages sharing an ordering key are delivered in publish order to a single subscriber, one at a time, when ordering is enabled on both the subscription and publisher (and the publisher pins to a region endpoint). The cost is throughput — roughly 1 MB/s per key — plus head-of-line blocking, so a stuck message stalls its key. Choose a high-cardinality key (entity ID) so distinct keys still parallelise.

5. When would you enable exactly-once delivery instead of relying on idempotency? When the side-effect is genuinely hard to make idempotent (a non-idempotent legacy API, a single email, an unforgiving financial action) and a dedup store is more complex/expensive than the constraints exactly-once imposes (subscriber pinned to the message-storage region, slightly higher latency). Exactly-once guarantees an acked message is never redelivered and gives reliable ack status.

6. What is a dead-letter topic and why is it essential in production? A DLQ is a separate topic a message is moved to after exceeding max-delivery-attempts (5–100), quarantining a poison message so it stops being redelivered forever and the subscription keeps flowing. Without it, an unprocessable message retries indefinitely (and, with ordering, blocks its key). You attach a subscription to the DLQ to inspect, alert, and reprocess.

7. Compare push and pull subscriptions. With pull/streaming pull, the consumer fetches messages (the client library opens a stream, manages leases and flow control) — best for self-run, high-throughput workers. With push, Pub/Sub POSTs each message to your HTTPS endpoint and a 2xx acks it — best for serverless handlers (Cloud Run/Functions) that scale to zero. Push throughput is bounded by your endpoint; pull scales with parallel streams.

8. What are BigQuery and Cloud Storage subscriptions, and what do they replace? They deliver messages directly to a sink with no consumer code — Pub/Sub writes rows to a BigQuery table or batched files to a GCS bucket. They replace the old pattern of a Dataflow job or a small Cloud Function whose only job was to copy events into the warehouse or lake. They need the Pub/Sub service agent to hold IAM on the destination.

9. How do schemas help, and what schema changes are safe? A schema (Avro/Protobuf) bound to a topic makes Pub/Sub validate every publish, rejecting bad payloads at the producer before fan-out multiplies the damage. Safe changes: adding optional fields with defaults; unsafe: adding required fields, renaming, or changing types. Version breaking changes with schema revisions.

10. Pub/Sub vs Pub/Sub Lite — when each? Pub/Sub is global, auto-scaled, feature-rich (push, native sinks, exactly-once), billed per data volume — the default for most workloads. Pub/Sub Lite is zonal/regional, capacity-provisioned (you size throughput and storage), much cheaper at sustained high volume, but with manual capacity management and fewer features. Use Lite only for steady, very high-throughput streams where the per-GB bill is material.

11. Why might a BigQuery subscription show a growing backlog with no errors in your code? Because there is no code — Pub/Sub’s service agent does the writing, so a missing IAM grant on the destination silently fails every write while the subscription looks healthy. Grant service-<PROJECT_NUMBER>@gcp-sa-pubsub.iam.gserviceaccount.com roles/bigquery.dataEditor on the dataset. The same applies to GCS subscriptions (storage.objectCreator) and DLQs (pubsub.publisher).

12. How do you reprocess events after fixing a consumer bug? Use snapshots and seek: snapshot the subscription’s ack state (or rely on retention), deploy the fix, then seek the subscription back to the snapshot or a timestamp within the retention window to redeliver everything since. For replay to reach before acks, enable retain-acked-messages and sufficient retention. This is how you backfill a new consumer or recover from a processing bug without data loss.

These map to the Google Cloud Professional Cloud Architect and Professional Data Engineer exams (event-driven design, streaming ingestion, Dataflow, exactly-once, BigQuery streaming), and to the Associate Cloud Engineer for the core Pub/Sub mechanics and IAM. A compact cert mapping:

Question theme Primary cert Objective area
Topics/subscriptions, fan-out, IAM Associate Cloud Engineer Deploying and managing GCP resources
Push/pull, ack deadline, DLQ, ordering Professional Cloud Architect Designing reliable, decoupled systems
Exactly-once, schemas, replay Professional Cloud Architect Reliability and data integrity
Dataflow, BigQuery subscriptions, streaming Professional Data Engineer Building/operationalising data pipelines
Pub/Sub vs Lite, cost, capacity Professional Data Engineer Selecting storage/messaging; cost optimisation
Service agents, OIDC push, least privilege Professional Cloud Security Engineer Securing GCP workloads

Quick check

  1. You attach a third subscription to an existing topic and it receives none of the messages published last week. Why, and what mechanism would let you replay them?
  2. A consumer’s handler takes 40 seconds, and you’re seeing every message processed twice. What single setting is wrong, and what else must the handler be?
  3. A Cloud Storage subscription’s backlog is climbing but your application logs show nothing — because there are none. What’s the most likely cause?
  4. You enable message ordering on the subscription but messages still arrive out of order. What did you forget?
  5. True or false: a dead-letter topic is optional in production because Pub/Sub retries failed messages anyway.

Answers

  1. A subscription only receives messages published after it was created, so last week’s messages were never copied to it. To replay them, the messages must still be within the topic/subscription retention window and a subscription that existed at publish time can seek to a past timestamp; a brand-new subscription cannot retrieve messages published before it existed. (Plan retention and, if needed, retain-acked-messages ahead of time.)
  2. The ack deadline is too short (default 10 s < 40 s processing), so the lease expires mid-processing and Pub/Sub redelivers. Raise the ack deadline above your real p99 and rely on the client library’s lease extension — and because at-least-once never fully eliminates duplicates, the handler must also be idempotent.
  3. The Pub/Sub service agent (service-<PROJECT_NUMBER>@gcp-sa-pubsub.iam.gserviceaccount.com) lacks IAM on the bucket. Native subscriptions are written by the service agent, not your code, so a missing roles/storage.objectCreator fails every write silently while the subscription looks healthy. Grant it the role on the bucket.
  4. The publisher side — ordering must be enabled on the publisher (enable_message_ordering) and the publisher must pin to a single region endpoint, and the messages must share an ordering key. Enabling it only on the subscription is not enough.
  5. False. Without a DLQ, a message the consumer can never process retries forever, consuming quota, polluting logs, and (with ordering) blocking its key. A dead-letter topic with max-delivery-attempts quarantines it after N tries so the main flow keeps moving.

Glossary

Next steps

You can now design, secure and operate an event-driven system on Pub/Sub. Build outward:

GCPPub/SubEvent-DrivenCloud RunDataflowBigQueryMessagingStreaming
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading