---
title: "Pub/Sub or Eventarc? Event-Driven GCP Without the Spaghetti"
description: "When to reach for Pub/Sub, when for Eventarc, and how to keep an event-driven GCP system clean: idempotency, dead-letter queues, and the trap of fan-out spaghetti."
author: Aleksei Aleinikov
date: 2026-06-29
lang: en
tags: [event-driven, pub-sub, eventarc, gcp, messaging, architecture, cloud-events]
canonical: https://www.alekseialeinikov.com/en/blog/topics/architecture/pubsub-vs-eventarc-2026-event-driven-gcp-without-spaghetti
source: alekseialeinikov.com
---

# Pub/Sub or Eventarc? Event-Driven GCP Without the Spaghetti

The first event-driven system feels like magic: a file lands in a bucket, a function fires, a record appears. The tenth feels like a crime scene. Nobody can say what triggers what, a duplicate just charged a customer twice, and one bad message has been retrying since Tuesday.

Event-driven on GCP is not hard because the tools are weak. It goes sideways because two services that look similar — **Pub/Sub** and **Eventarc** — get used interchangeably, and the boring parts (idempotency, dead-letter) get skipped until production teaches them.

![When the file lands, the function fires, and nobody can explain why.](https://www.alekseialeinikov.com/blog/pubsub-eventarc-2026.webp)

This is the field guide: which one to reach for, how to keep handlers safe, and how to stop the fan-out from turning into spaghetti.

> Routing is one half of a solid GCP platform; locking it down is the other. For the security baseline your clusters run on, see [Secure-by-Default GKE: A Reference Architecture for 2026](https://www.alekseialeinikov.com/en/blog/topics/architecture/secure-by-default-gke-reference-architecture-2026).

## Pub/Sub vs Eventarc: One Transports, One Routes

They overlap, so people pick by habit. The clean rule:

- **Pub/Sub** is the messaging backbone. You publish events *you* own to a topic; consumers subscribe. You control schema, ordering, retention. Use it for your own domain events — `order.placed`, `payment.settled`.
- **Eventarc** is a router on top. It takes events Google *already* emits — Cloud Storage uploads, Audit Logs, 90+ sources — and delivers them to a target as one CloudEvents contract. Use it to *react* to the platform without writing glue.

You rarely choose one over the other; Eventarc rides on Pub/Sub anyway. The question is "who produces this event?" If it's you, Pub/Sub. If it's Google, Eventarc.

![Event-driven GCP at a glance: Google emits to Eventarc, you publish to Pub/Sub, fan out to idempotent subscribers, dead-letter the poison.](https://www.alekseialeinikov.com/blog/pubsub-eventarc-flow-2026.webp)

```bash
# Your own event → Pub/Sub
gcloud pubsub topics create order-placed
gcloud pubsub subscriptions create fulfilment --topic=order-placed

# A GCP event (file uploaded) → Eventarc, no glue code
gcloud eventarc triggers create on-upload \
  --destination-run-service=thumbnailer \
  --event-filters="type=google.cloud.storage.object.v1.finalized" \
  --event-filters="bucket=my-uploads"
```

## At-Least-Once Means: It Will Arrive Twice

Both services deliver **at-least-once**. That is not an edge case — design for it. A retried delivery, a slow ack, a redeploy mid-flight, and the same event lands again. If "twice" means a double charge, that's a bug you wrote, not bad luck.

Make every consumer idempotent: dedupe on a stable event ID, and a re-run becomes a no-op.

```python
def handle(event):
    eid = event["id"]                 # stable, provided by Pub/Sub/Eventarc
    if seen.add_if_absent(eid):       # atomic: returns False if already there
        process(event)                # do the real work exactly once
    # duplicate → silently ack, no side effect
```

The store can be Redis with a TTL, a unique column, or Firestore. The rule never changes: the second copy must do nothing.

## Dead-Letter: One Poison Message Shouldn't Stop Everything

A single unparseable payload retries forever and blocks the line behind it. A dead-letter topic parks it after N attempts so real traffic keeps moving.

```bash
gcloud pubsub subscriptions update fulfilment \
  --dead-letter-topic=fulfilment-dead \
  --max-delivery-attempts=5
```

Then alert on the dead-letter topic. A message there is a question to answer, not noise to ignore — but the rest of the flow never stalls.

## Push or Pull: How Subscribers Get Their Events

Before topology, get the delivery model right. A Pub/Sub subscription is either **push** or **pull**, and beginners pick wrong then fight the platform:

- **Push** — Pub/Sub POSTs each message to an HTTPS endpoint (Cloud Run, a function). Zero polling, scales to zero, ideal for serverless. The endpoint must answer fast and return 2xx to ack.
- **Pull** — your worker asks for batches and acks them. Best for high-throughput backends, long jobs, or fine control over concurrency.

Rule of thumb: serverless target → push; always-on worker → pull. Eventarc to Cloud Run is push under the hood — you do not choose, and that is fine.

## Ordering: Usually You Don't Want It

Pub/Sub can keep order *within an ordering key*, but ordering throttles throughput and most flows don't need it. Design consumers so arrival order doesn't matter: handling `order.placed` then `order.cancelled` should be correct either way. Only enable ordering keys when state truly depends on sequence (a per-account ledger), and key on the entity, never globally.

```bash
gcloud pubsub topics publish order-events --message="..." --ordering-key="acct-42"
```

## Publish a Fact, Not a Blob

A topic is a contract. Events should be versioned, self-describing, and small — IDs, not whole records. Carry a `type`, a stable `id`, and a `v` so consumers can evolve without coordination:

```json
{ "id": "evt_9f3", "type": "order.placed", "v": 1, "orderId": "A42", "ts": "2026-06-29T10:00:00Z" }
```

Fat payloads couple producer and consumer; the ID lets each subscriber fetch exactly what it needs. Add fields freely, never break or rename one — bump `v` instead.

## When NOT to Go Event-Driven

Events are not free, and the worst spaghetti is async where sync would do. Reach for a plain call or **Cloud Tasks** when:

- You need a result *now* — a synchronous request, not a broadcast.
- One known producer, one known consumer — that is a queue; use **Cloud Tasks** with retries and rate limits, not a topic.
- A two-step flow — a direct call beats a topic nobody else subscribes to.

Use Pub/Sub when *many* consumers care about the same fact and producers must not know who listens. If only one does, you bought spaghetti for nothing.

## The Spaghetti: Stop Chaining Triggers Blind

The real mess isn't the tools — it's topology. A fires B, B fires C, C quietly fires A again, and nobody drew the map. Three rules keep it flat:

1. **Topic = fact, not command.** Publish `order.placed`, not `send.email`. Producers state what happened; consumers decide what to do. New behaviour = new subscriber, zero producer changes.
2. **Fan out, don't chain.** Many subscribers on one topic beat a 6-hop relay. Parallel paths are debuggable; chains hide cycles.
3. **One source of truth for routing.** Keep triggers in Terraform, not click-ops. If the graph only lives in someone's head, it's already spaghetti.

## The Field Rule

Pub/Sub when you produce the event, Eventarc when Google does. Assume every event arrives twice and make handlers idempotent. Dead-letter the poison so one bad message can't stop the line. Publish facts, fan out instead of chaining, and keep routing in code. Get those right and event-driven stays the magic it felt like on day one — instead of the crime scene it becomes by article ten.
