Cloud Run Concurrency: Why the Default of 80 Is Probably Wrong for Your Service
Cloud Run's default container concurrency of 80 is great for I/O-bound services and quietly terrible for CPU-bound ones. Here's how to find the right value with a simple stepped load test — and what it costs you.
25 Sept 2025, 21:30 UTC

Your Cloud Run service is "slow," but only sometimes. P50 looks fine, p99 is a disaster, and Cloud Monitoring shows just one or two instances running. The usual suspect isn't your code or Cloud Run's autoscaler — it's container concurrency, the setting almost nobody changes from its default of 80.
The thesis of this post: concurrency is a latency-versus-cost dial, not a free performance knob, and the right value depends almost entirely on whether your handlers are I/O-bound or CPU-bound. Getting it wrong in either direction costs you either tail latency or money.
What container concurrency actually does
Container concurrency is the maximum number of simultaneous requests Cloud Run will send to a single instance of your container. With the default of 80, one instance can be juggling 80 in-flight requests at once. That's the magic behind scale-to-zero economics: a small number of instances absorb a large number of mostly-waiting requests.
Cloud Run's autoscaler doesn't wait until an instance hits the limit before adding capacity — it targets a utilization well below the configured maximum (historically around 60%, though treat that number as approximate and verify against current docs). This matters because it means lowering concurrency doesn't just cap each instance harder; it directly tells the autoscaler to run more instances.
You set it per service. With the gcloud CLI (run this anywhere the Cloud SDK is authenticated; you need the run.services.update permission, typically via the Cloud Run Admin role):
gcloud run services update my-service \
--region=us-central1 \
--concurrency=10The same value appears in the console under "Container concurrency," and in service YAML as the annotation autoscaling.knative.dev/maxConcurrency. The documented range has historically been 1 up to 1000 — confirm the current maximum before relying on it, since these limits have changed over time.
When 80 is fine, and when it quietly ruins your p99
If your handlers spend most of their time waiting — calling a database, calling another API, reading from Storage — high concurrency is exactly what you want. The instance's CPU is mostly idle during those waits, so multiplexing 80 requests onto one vCPU is efficient and cheap. Leave the default alone.
The failure mode is CPU-bound work: image resizing, PDF generation, heavy JSON serialization, template rendering, anything where the handler is actually computing. A Cloud Run instance has a fixed CPU allocation. If 80 requests arrive and each needs 100ms of real CPU, they don't run in parallel — they timeshare. The last request in the pile waits for roughly all the others' CPU slices before finishing. Your p50 might survive; your p99 becomes a multiple of your handler's actual duration.
There's a subtler version of this for single-threaded runtimes. A classic synchronous Python app (Flask without async, a single-worker WSGI setup) can only execute one request at a time regardless of the concurrency setting. Cloud Run will happily deliver 80 concurrent requests to that container, and 79 of them queue inside it. This is behind a lot of "Cloud Run is slow" reports that are actually "my runtime serializes everything." Either use an async/multi-worker setup, or set concurrency to 1 and let the autoscaler do the parallelism.
A worked example: finding the knee
Say you have a Go or Node service with a handler that burns about 100ms of CPU per request. Deploy it, then load-test it three times with a tool like hey or k6 (run these from your workstation against the deployed service URL — no special GCP permissions needed beyond an invoker token if the service is private):
# Terminal 1: set concurrency to 80
gcloud run services update my-service --region=us-central1 --concurrency=80
hey -z 60s -c 50 https://my-service-xyz.a.run.app/work
# Then repeat with --concurrency=10, and again with --concurrency=1What you typically see: at concurrency 80, instance count stays at 1–2 but p99 latency balloons under load. As you step down toward 10, p99 drops sharply while instance count climbs. Somewhere in that sweep is the knee — the point where p99 flattens out and lowering concurrency further just adds instances (and cost) without buying more latency. That's your number. Verify it in Cloud Monitoring by comparing the container/instance_count metric and request latency percentiles across the three runs. Don't trust a single run; cold starts and load-generator noise can skew short tests.
The trade-off you can't avoid
Lowering concurrency is not a free fix. Because the autoscaler targets a fraction of the configured maximum, going from 80 to 10 can multiply your steady-state instance count several-fold for the same traffic — and Cloud Run bills per instance-time. You're converting queueing delay into billable instances. For a latency-sensitive user-facing endpoint, that's usually the right call. For a background-ish webhook consumer, high concurrency and a worse p99 might be exactly the cost profile you want.
Three more caveats before you touch the dial in production:
- Thread safety. Bugs from shared mutable state — package-level caches, global counters, reused buffers — only appear when concurrency is above 1. Audit before raising the limit, not after.
- CPU allocation mode. If you use "CPU always allocated" for background work between requests, the same concurrency limit still applies, and your background threads now compete with request handlers for the same vCPU. Size both accordingly.
- Connection pools. More instances at lower concurrency means more outbound connections to your database. Check pool sizes and database connection limits after the change, not just latency.
What to do on Monday
Pick your most CPU-heavy endpoint, run the stepped load test above, and check whether your p99 improves more than your instance count grows. If your runtime is synchronous and single-threaded, set concurrency to 1 today and revisit once you've moved to async. And whatever number you land on, write it down in your service YAML next to a comment explaining why — the next person to deploy with --concurrency unset will silently inherit the default, and you'll be debugging the same p99 spike again in six months.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.