Benchmark writeup · Google Cloud Run · Two rounds, July 2026
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.
If you read nothing else
insertAll, ~50 ms into Cloud Storage, ~2 s
per external-Iceberg commit, and ~70+ s per queued BigQuery DML statement under load.
Across every rate, concurrency, and CPU config in round 1, API latency never moved until the
sink write did.min-instances ≥ 1. Within its shape, it is
cheap: ~$0.05–0.42 of compute per million messages, with the sink usually the bigger bill.Setup
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.
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
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
| Rate (req/s) | Go | Python | Rust |
|---|---|---|---|
| 10 | 63 ms | 63 ms | 60 ms |
| 50 | 56 ms | 57 ms | 55 ms |
| 100 | 56 ms | 52 ms | 51 ms |
| 250 | 56 ms | 55 ms | 53 ms |
| 500 | 54 ms | 56 ms | 52 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
| Sink path | Go p50 | Python p50 | Unit |
|---|---|---|---|
| BQ insertAll, per row (sync) | 7.9 ms | 8.8 ms | per row |
| BQ Storage Write API append | 16.3 ms | 49.5 ms | per batch |
| BQ insertAll, 500-row batch | 38.0 ms | 34.0 ms | per batch |
| Managed Iceberg insertAll batch | 49.6 ms | 41.0 ms | per batch |
| Pub/Sub publish (batched) | 39.9 ms | 20.8 ms | per batch |
| External Iceberg commit | 1.92 s | 2.79 s | per commit |
| BQ DML INSERT (overloaded) | 71.0 s | 95.8 s | per statement |
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
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
| Rate | Go | Python | Rust | Python, conc 10 | Python, batched |
|---|---|---|---|---|---|
| 100 /s | 133 ms | 800 ms | 129 ms | — | — |
| 500 /s | 20 ms | 779 ms | 20 ms | 88 ms | — |
| 1,500 /s | 22 ms | 9,413 ms | 22 ms | — | 22 ms |
| 3,000 /s (batch) | 23 ms | 25 ms | 24 ms | — | 25 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
| Language | Sync p50 | Async p50 | Success |
|---|---|---|---|
| Go | 54.3 ms | 10.0 ms | 100% |
| Python | 56.4 ms | 6.6 ms | 81.6% (shed) |
| Rust | 51.5 ms | 6.7 ms | 100% |
Finding 3
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
| Strategy | Sustained | Backlog |
|---|---|---|
| External Iceberg, per-message commits | 0.9 rows/s per writer | 98% 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/s | 4.7% |
| BQ insertAll 500-row batches, 1 vCPU × 1 inst | 15,000 msg/s | 0 |
| BQ insertAll 500-row batches, 2 vCPU × 1 inst | 25,000 msg/s † | 0 |
| Dataproc Serverless Spark → Iceberg (batch) | 37,856 rows/s | — |
| BQ Storage Write API, 2 vCPU × 4 inst ‡ | 49,930 rows/s | 0 |
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
“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
| Configuration | Rate | Delivered | Conflicts |
|---|---|---|---|
| Per-message commits, 1 inst | 50 /s | 1.8% | 75 |
| 1k rows / 5 s, 1 inst | 2,000 /s | 3.6% | 1,621 |
| 1k rows / 5 s, 4 inst | 2,000 /s | 5.9% | 464 |
| 1k / 5 s, 4 inst, partitioned | 2,000 /s | 4.0% | 450 |
| 5k rows / 10 s, 1 inst | 2,000 /s | 49.5% | 212 |
| 5k rows / 10 s, 1 inst | 500 /s | 95.3% | 35 |
The working recipes that fall out of the data:
Finding 5
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
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).
| Language | Boost off | Boost on | First-request failures |
|---|---|---|---|
| Go | 573, 591 ms | 491, 491 ms | 0 / 4 |
| Python | 3,097, 2,440 ms | 3,461, 1,578 ms | 0 / 4 |
| Rust | 405, 532 ms | 3,191, 361 ms | 4 / 4 (HTTP 500) |
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.
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.
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).
--vpc-egress=all-traffic without Cloud NAT silently breaks Google API access —
round 2 ran private-ranges-only from the start.Cost
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.
| Path | Cloud Run compute / 1M | Dominant other cost |
|---|---|---|
| HTTP API → GCS (conc 80, 55 ms) | ≈ $0.42 | GCS Class-A writes ≈ $5.00 / 1M |
| Pub/Sub push → BQ insertAll | ≈ $0.42 | Pub/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.00 | GCS 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
Pick the requirement; every recommendation below is backed by a measured configuration from these runs, with its caveat attached.
I need…
Appendix
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: