Benchmark writeup · Google Cloud Run · Two rounds, July 2026

Cloud Run in the middle

Two benchmark rounds put Cloud Run between real sources and real sinks — an HTTP API in front of Cloud Storage, Pub/Sub push into BigQuery, and Kafka consumers feeding BigQuery, Iceberg lakehouses, and Pub/Sub — in Go, Python, and Rust, across ~140 measured runs. This is what the data says about where latency actually comes from, what throughput costs, and which architecture to pick.

Project warm-airline-165212 Region europe-west3 Runs ~60 (round 1) + 80 (round 2) Total spend ≈ $21–29 Gen Cloud Run gen2, 1 vCPU / 1 GiB baseline

If you read nothing else

Setup

The experiment: six pipelines, two rounds

Both rounds share one shape: a load generator with known send timestamps, Cloud Run in the middle, and a sink whose contents can be counted and timed afterward. Round 1 varied the source and the language; round 2 fixed the source (Kafka, 12 partitions, 1 KB Avro) and varied the sink, after an external review added binding methodology amendments (same-clock primary metrics, per-run consumer-group health gates, drain timeouts with backlog accounting, and scripted teardown).

What was measured

Round 1: three source→sink pairs × three languages. Round 2: one source, six sink paths × two languages.

Round 1 also measured cold starts (scale-from-zero, all three languages). Round 2 added failure injection, a rolling-restart rebalance test, and a Dataproc Serverless Spark batch as a non-Cloud-Run reference point. Kafka: single broker (e2-medium, KRaft) — a deliberate, documented ceiling of the harness, not of Kafka.

Numbers quoted below are p50 unless stated; all raw JSON, orchestration code, and the two per-round HTML reports live in the repo (round-1-cloudrun-pipelines/, round-2-kafka-sinks/). Round 2's primary latency metric is same-clock: consume→sink-ack measured entirely on the instance, immune to cross-machine clock skew. Cross-clock numbers (produce→consume) are labeled as such wherever they appear.

Finding 1

Your sink is your latency floor

Nothing you do to the service — language, concurrency, CPU, rate — moves the median until you change what the request path waits for.

Round 1 made this point by exhaustion. The HTTP→Cloud Storage API held p50 ≈ 51–63 ms across a 50× rate sweep, concurrency 1→80, and a doubling of CPU and memory — in all three languages at once. The GCS write (~35–50 ms server-side) simply dominates everything else in the request.

Round 1 · API→GCS median latency vs offered rate

1 KB synchronous writes, p50 per language · 120 s runs · concurrency 80, 1 vCPU

Flat within ±6 ms across a 50× load increase. The same held for concurrency (1/10/40/80: 51–59 ms) and hardware (1 vCPU/512 MiB vs 2 vCPU/2 GiB: no change). Reads were faster (28–32 ms) and 1 MB writes slower (71–78 ms) — the object, not the platform, sets the number.
Table view
Rate (req/s)GoPythonRust
1063 ms63 ms60 ms
5056 ms57 ms55 ms
10056 ms52 ms51 ms
25056 ms55 ms53 ms
50054 ms56 ms52 ms

Round 2 then priced the floors themselves, with the skew-free instrument: the same Kafka consumer body, swapping only the write call. The spread is not percent — it is four orders of magnitude.

Round 2 · What one write costs, by sink

Same-clock sink-write p50 per operation · single instance · 2,000 msg/s (DML row: 500 msg/s) · Go vs Python

Milliseconds panel: per-operation write cost. Seconds panel: the two sinks that leave the millisecond world entirely — external-Iceberg commits (~2–3 s, pyiceberg / iceberg-go against a BigLake REST catalog) and BigQuery DML INSERT under load, where statements queue behind a concurrency quota measured in tens (the 71–96 s is queue time, not execution). “SWA” = Storage Write API append; note Go 16 ms vs Python 50 ms — the one real language gap round 2 found, a client-library difference that was pre-registered before the run.
Table view
Sink pathGo p50Python p50Unit
BQ insertAll, per row (sync)7.9 ms8.8 msper row
BQ Storage Write API append16.3 ms49.5 msper batch
BQ insertAll, 500-row batch38.0 ms34.0 msper batch
Managed Iceberg insertAll batch49.6 ms41.0 msper batch
Pub/Sub publish (batched)39.9 ms20.8 msper batch
External Iceberg commit1.92 s2.79 sper commit
BQ DML INSERT (overloaded)71.0 s95.8 sper statement
The corollary that saves the most money: round 1's latency-opt scenario (min-instances, CPU boost, tuned concurrency) bought nothing against the default config — because none of it touches the sink. Optimization effort orders as: sink client first, concurrency second, language last.

If the caller's response time matters more than write durability, the floor can be dodged rather than lowered: round 1's async mode acknowledged first and wrote to GCS in the background, taking the caller-visible p50 from ~54 ms to 7–10 ms — at the price of an at-least-once-after-ack durability window and mandatory backpressure (bounded queue, 503 shedding). Fine for telemetry; wrong for payments.

Finding 2

Language is mostly a configuration problem

Across both rounds there is exactly one scenario family where a language collapsed — and two cheap configuration changes that fully repaired it.

Python's synchronous BigQuery path fell apart under Pub/Sub push at concurrency 80: eighty concurrent requests multiplexed onto a single uvicorn event loop, each blocking on an insertAll. Go and Rust, with real per-request parallelism, never noticed.

Round 1 · Pub/Sub→BigQuery sync inserts: p50 by publish rate

Concurrency 80, per-message insertAll · log scale · publish→row-visible e2e

At 1,500 msg/s Python's p50 is 428× Go's — and it dropped ~15% of rows within the window. The two rescues, measured: cap concurrency at 10 → 88 ms (more instances, less event-loop contention); or batch 500 rows / 200 ms → 25 ms at 3,000 msg/s, indistinguishable from Go (23 ms) and Rust (24 ms). One anomaly for honesty: at a gentle 100 msg/s everyone is slower (129–800 ms) — cold, unamortized connections.
Table view
RateGoPythonRustPython, conc 10Python, batched
100 /s133 ms800 ms129 ms
500 /s20 ms779 ms20 ms88 ms
1,500 /s22 ms9,413 ms22 ms22 ms
3,000 /s (batch)23 ms25 ms24 ms25 ms

Everywhere else, the languages converged — or inverted expectations:

Round 1 · The async trade: caller-visible p50, sync → async write

API→GCS at 500 req/s, 32 writer workers · arrowed pairs; label shows load shed

Rust ran it clean at the full 500 req/s. Python answered in 6.6 ms but shed ~18% of requests as deliberate 503 backpressure — its single event loop saturates before GCS does (a multi-worker config was not tested and would likely close this). Go's first attempt showed an unexplained 5 s p99 tail; the clean rerun (shown) did 10 ms at 100% success. Async numbers are acknowledgment latency, not durability latency.
Table view
LanguageSync p50Async p50Success
Go54.3 ms10.0 ms100%
Python56.4 ms6.6 ms81.6% (shed)
Rust51.5 ms6.7 ms100%
Rule extracted: judge a language by what is inside its hot loop, not by its label. C-backed clients + pinned instances → Python is fully competitive. Many concurrent synchronous requests per instance → Python needs capped concurrency, batching, or multiple workers. Pick the language your team maintains best; spend the reclaimed effort on the sink.

Finding 3

Throughput is bought with batching, not hardware

The measured ladder spans 0.9 to 50,000 rows per second — and every rung is a batching decision, not a CPU decision.

Round 2 · Maximum sustained ingestion, by write strategy

1 KB messages · zero undrained backlog unless noted · log scale

† Broker-capped, not service-capped: the single e2-medium Kafka broker ran out before the 2-vCPU consumer did — 25,000 msg/s is a lower bound. The 1-vCPU instance also finished 15,000 msg/s with zero backlog. ‡ Total across Go + Python (24,965 msg/s each, in parallel, p50 35 ms produce→consume). Dataproc figure is a scheduled batch job, minutes of latency, shown as the non-streaming reference.
Table view
StrategySustainedBacklog
External Iceberg, per-message commits0.9 rows/s per writer98% undrained
Sync sink call per message (rule of thumb)~50 msg/s per instance
External Iceberg, 5k-row / 10 s commits, 1 writer~950 rows/s4.7%
BQ insertAll 500-row batches, 1 vCPU × 1 inst15,000 msg/s0
BQ insertAll 500-row batches, 2 vCPU × 1 inst25,000 msg/s †0
Dataproc Serverless Spark → Iceberg (batch)37,856 rows/s
BQ Storage Write API, 2 vCPU × 4 inst ‡49,930 rows/s0

Three things in that chart deserve emphasis:

Round 1 adds the autoscaling caveat: none of this scales itself for pull-based consumers. Cloud Run's autoscaler watches HTTP, not consumer lag — the Kafka services only performed when pinned (min-instances = max-instances, CPU always allocated), scaling near-linearly with pinned count (one instance ≈ 2,000 msg/s in round 1's consumer; four ≈ 5,000/s per language, then broker-capped).

Finding 4

The lakehouse has a toll booth

“Iceberg” is one word for two very different write paths. Managed Iceberg behaves like BigQuery. External Iceberg behaves like a distributed systems exam.

BigQuery managed Iceberg tables are a free choice. Writes go through the same insertAll and land at the same cost (41–50 ms per batch vs 34–38 ms native); read benchmarks came back in the same class (sub-1.5 s aggregates on tens of millions of rows, with occasional cold-scan outliers). Pick managed Iceberg for format openness — you pay approximately nothing in performance.

External Iceberg — Parquet on GCS behind a BigLake REST catalog, written by pyiceberg / iceberg-go — is a different sport. Every commit is a catalog round-trip plus object writes: a ~2–3 s floor per commit regardless of size. And commits from concurrent writers contend optimistically:

Round 2 · External Iceberg: delivery vs commit strategy

Percent of expected rows landed within run + drain window · conflict count labeled per row

“Writers” counts committing processes (instances × languages sharing one table). The only configuration that kept up was one writer per language, 5,000-row / 10 s commits, at 500 msg/s. Doubling rate halved delivery; adding writers added conflicts faster than throughput; partitioning the table did not help (450 vs 464 conflicts at 4 instances — commits contend on the table's metadata pointer, not on data files). Failed batches were retried; the drain timeout (A2) makes the shortfall a recorded result rather than a hidden hang.
Table view
ConfigurationRateDeliveredConflicts
Per-message commits, 1 inst50 /s1.8%75
1k rows / 5 s, 1 inst2,000 /s3.6%1,621
1k rows / 5 s, 4 inst2,000 /s5.9%464
1k / 5 s, 4 inst, partitioned2,000 /s4.0%450
5k rows / 10 s, 1 inst2,000 /s49.5%212
5k rows / 10 s, 1 inst500 /s95.3%35

The working recipes that fall out of the data:

Finding 5

Operational physics: cold starts, rebalances, failure & the bill for pinning

Cold starts rank Go ≈ Rust ≪ Python — with an asterisk on Rust

Round 1 · Cold-start latency, first request after scale-to-zero

Two trials per language × CPU-boost setting · ≥15 min idle · red ring = request returned HTTP 500

Every Rust first-request failed (4/4, HTTP 500): its hand-rolled metadata-token fetch had no retry, where the Go/Python SDKs retry transparently — so its headline 0.36 s is time-to-error. Startup CPU boost did not change the ranking (and coincided with one 3.2 s Rust outlier). Small samples (n=2 per cell) — treat as indicative. The fix is boring: retry startup dependencies, and set min-instances ≥ 1 on any latency-sensitive path, which also removed round 1's only p99 blip (a 1,029 ms scale-up spike at 500 req/s).
Table view
LanguageBoost offBoost onFirst-request failures
Go573, 591 ms491, 491 ms0 / 4
Python3,097, 2,440 ms3,461, 1,578 ms0 / 4
Rust405, 532 ms3,191, 361 ms4 / 4 (HTTP 500)

A rolling restart pauses a consumer group for ~24–28 s

Round 2 measured what a redeploy does to a live Kafka consumer group: the longest gap with zero consumed messages was 27.7 s with the default eager assignor and 23.7 s with cooperative-sticky — a pause, not a slowdown (between pauses, consume spacing was unchanged). The assignor choice is worth ~15%; graceful shutdown that commits offsets on SIGTERM is worth more. And the nastiest failure mode found in either round remains: after one round-1 redeploy, librdkafka wedged mid-rebalance holding zero partitions, logging no error, at half throughput — which is why round 2 gated every run on “12/12 partitions assigned + canary rows flowing.” Monitor group assignment yourself; Cloud Run won't.

Failure semantics: measured, and gentle

Killing 1 of 2 consumers mid-run at 2,000 msg/s (at-least-once inserts, 500-row batches, 500 ms offset commits) produced 9 duplicate rows and 0 lost messages out of 240,018 — 0.0019% duplication, visible only because every run counts distinct_ids against sent. That is the at-least-once contract working as designed: plan for dedup keys downstream, not for loss.

What pinning costs

The price of “Cloud Run as a Kafka consumer” is that the request-driven billing story inverts: pinned, always-allocated instances bill ~$24.50/month each (1 vCPU / 1 GiB) whether or not traffic flows. Four pinned instances ≈ $98/month — still cheap in absolute terms, but it is GKE-shaped economics. Round 2's Worker Pools runs showed the cleaner way to buy the same thing: identical pipeline results with no HTTP shim, purpose-built for pull workloads (and, since this study ran, Google ships a lag-based Kafka autoscaler for worker pools — the pin-everything workaround now has an official alternative worth testing).

Gotchas that cost real debugging time

Cost

Cloud Run is rarely the expensive line item

Both rounds' cost models agree: at high concurrency the compute in the middle costs fractions of a dollar per million operations — the sink and the messaging fabric dominate.

PathCloud Run compute / 1MDominant other cost
HTTP API → GCS (conc 80, 55 ms)≈ $0.42GCS Class-A writes ≈ $5.00 / 1M
Pub/Sub push → BQ insertAll≈ $0.42Pub/Sub throughput ($40/TiB)
Kafka → BQ insertAll batch (15k/s/inst)≈ $0.05–0.10≈ nothing (insertAll ingestion free tier)
Kafka → BQ Storage Write API≈ $0.05–0.10>2 TiB/mo: ~$25/TiB
Kafka → external Iceberg (commit-bound)≈ $1.50–6.00GCS ops + compaction jobs
Kafka → Pub/Sub → BQ subscription≈ $0.05–0.10+ ~$0.04 / 1M Pub/Sub (1 KB)
Dataproc Spark → Iceberg (batch)≈ $0.10–0.30 / 100M rows (DCU-h)

Two structural rules: concurrency is a discount (conc 80 vs conc 10 cut the per-request compute ~30% at identical latency, since I/O-bound requests share instances), and pinning is a subscription (~$24.50/inst/month, traffic or not). The studies themselves cost ≈ $12–16 (round 1) and ≈ $9–13 (round 2, under its $22 hard stop).

Synthesis

Decision guide

Pick the requirement; every recommendation below is backed by a measured configuration from these runs, with its caveat attached.

I need…

Appendix

Method & limitations

How latency was measured. Round 1: client-side (vegeta) for HTTP; publish→row-visible or produce→pulled for pipelines, stamped by the load-generator VM. Round 2's primary metric is consume→sink-ack, both timestamps from the same instance clock (skew-free); cross-machine numbers are reported separately. Per-service clock probes ran before and after every round-2 run — though the probe's offset estimate (~65 ms ≈ its own RTT) is an artifact of an uncorrected formula, which is precisely why the primary metric avoids cross-clock arithmetic.

Run hygiene (round 2, per binding amendments). Every measured run required 12/12 Kafka partitions assigned plus flowing canary messages before the window; carried a 300 s drain timeout with undrained_backlog, conflict, retry, and duplicate counts as mandatory result fields; and the matrix ran under a tested teardown script with a $22 spend stop. Aggregates regenerate byte-identically from the raw JSON in both rounds.

Know the edges before transplanting numbers: