Managing Connection Limits in Cloud Run and Cloud Functions

This guide is part of Serverless Function Connection Management. Cloud Run differs from Lambda in one respect that changes every calculation: an instance can serve many requests concurrently. That restores the case for a pool larger than one, makes the arithmetic two-dimensional, and introduces a failure that Lambda does not have — a pool sized for one request while the instance is serving eighty.

The signature is a service that works at low traffic and degrades sharply at a threshold, with max-instances never reached:

Error: timeout exceeded when trying to connect
    at /workspace/node_modules/pg-pool/index.js:45:11
# Cloud Run metrics: instance count 3 of 100, container concurrency 80/80
# Cloud SQL: 12 connections, instance at 8% CPU

Three instances, eighty concurrent requests each, and twelve connections total. The pool is the bottleneck and both the platform and the database look idle.

Rapid Incident Diagnosis

1. Read the concurrency setting. gcloud run services describe <svc> --format='value(spec.template.spec.containerConcurrency)'. The default is 80, which is very different from Lambda’s effective 1 and is the number the pool must serve.

2. Compare pool size to concurrency. A pool of 1 or 2 on an instance with concurrency 80 will queue almost immediately. This is the most common cause and the cheapest to check.

3. Compute the ceiling. max-instances × pool_size is the maximum backend demand. Compare it to the service’s share of Cloud SQL’s max_connections.

4. Confirm what the connector is doing. The Cloud SQL connector and Auth Proxy authenticate and encrypt; neither pools. Backend count equals client connection count regardless of which is used.

Two dimensions on Cloud Run Connection demand is the product of pool size and instance count, while the pool must be large enough to serve the per-instance concurrency, so the two settings pull in opposite directions. containerConcurrency 80 requests served at once pushes the pool UP max-instances 100 instances that may exist pushes the pool DOWN the conflict 80 needs a pool of ~15 100 instances × 15 = 1500 far beyond any Cloud SQL tier The resolution is to bound max-instances, not to shrink the pool below what concurrency needs A pool too small for the instance's own concurrency queues locally while the platform reports plenty of headroom
Concurrency and instance count pull the pool size in opposite directions. Resolving the conflict by shrinking the pool produces local queueing that neither platform nor database metrics explain.

The Arithmetic

Two constraints, both of which must hold.

pool_size    ≥ concurrent_queries_per_instance      (from Little's Law, per instance)
pool_size    ≤ service_allowance ÷ max_instances    (from the budget)

The first uses the instance’s own concurrency, not the request concurrency directly. An instance serving 80 concurrent requests, each holding a connection for 8 ms out of a 120 ms response, has 80 × (8/120) ≈ 5.3 concurrent queries — so a pool of 7 or 8 covers it, not 80. This is the async advantage described in Connection Pool Sizing Formulas, and on Cloud Run it is what makes high concurrency affordable.

The second is the familiar division. With a 200-connection allowance and max-instances: 100, the ceiling is 2 — below what the first constraint needs, and the signal that max-instances is too high for a direct connection.

# Bound the instance count so the two constraints can both be satisfied.
gcloud run deploy orders-api \
  --concurrency=80 \
  --max-instances=25 \        # 200 allowance ÷ 25 = 8 per instance
  --min-instances=1 \         # keeps one warm; holds pool_min connections
  --set-env-vars=DB_POOL_MAX=8,DB_POOL_MIN=2,DB_ACQUIRE_TIMEOUT_MS=2000

max-instances is the lever with no downside other than throttling: requests beyond the capacity of 25 instances queue at the platform rather than failing at the database, which is a far better failure mode and affects only this service.

Cloud Functions and the Concurrency Difference

Cloud Functions (2nd gen) runs on Cloud Run and inherits the concurrency setting; 1st gen is fixed at one request per instance and behaves like Lambda. Checking which generation is deployed is worth doing before applying either model, because the correct pool size differs by an order of magnitude.

Platform Requests per Instance Correct Pool Size Dominant Multiplier
Cloud Functions 1st gen 1 1 Instance count
Cloud Functions 2nd gen configurable, default 1 concurrency-derived Instance count
Cloud Run, default 80 5–15 typically Instance count
Cloud Run, concurrency 1 1 1 Instance count
Cloud Run + PgBouncer 80 5–15 Proxy pool size

The last row is the durable answer at scale. Cloud SQL has no managed equivalent of RDS Proxy, so a pooler has to be run — typically PgBouncer as a shared Cloud Run service or on GKE, sized independently of the application’s instance count. Running it as a sidecar in each instance collapses nothing, since each sidecar serves only its own instance.

Connector, Auth Proxy, or Direct

All three connection paths open one backend per client connection. The choice is about authentication and network topology, not about connection count — which is the single most common misconception on this platform.

The connector library is the modern default. It handles IAM authentication and mTLS in-process, needs no sidecar, and adds no hop. Its max-instances interaction is nil.

The Auth Proxy does the same as a separate process. Its --max-connections flag caps how many client connections it accepts, which makes it a useful circuit breaker in front of the database — but only when it is a shared deployment; as a sidecar the flag applies per instance and the product is unchanged.

Direct connection over private IP avoids both, at the cost of managing credentials yourself. It is the lowest-latency path and the one with the least operational surface, and it is entirely reasonable when the service runs inside a VPC with Secret Manager for credentials.

Pooler placement on Cloud Run A pooler deployed as a shared service collapses connections across all instances. A pooler running as a sidecar in each instance serves only that instance and reduces nothing. sidecar per instance instance + pooler → 8 instance + pooler → 8 … × 25 + pooler → 8 200 backends exactly what it was without the pooler shared pooler service 25 instances PgBouncer × 2 transaction mode 30 backends and max-instances stops mattering The shared deployment introduces a dependency whose availability now matters — run at least two, behind a service.
Only a shared pooler collapses connections across instances. As a sidecar it multiplexes one instance's connections onto the same number it started with.

Common Failure Patterns & Remediation

Symptom Root Cause Exact Fix Validation
Timeouts with instance count far below max Pool smaller than per-instance concurrency Size the pool from concurrency × hold-time ratio Local queueing stops; instance count rises normally
too many connections as instances scale max-instances × pool exceeds the allowance Bound max-instances; add a shared pooler Backends flat as instances scale
Pooler deployed, backend count unchanged Pooler running as a sidecar Move it to a shared service Backends fall to the pooler’s pool size
Cold-start latency includes a handshake Client constructed per request Construct at module or package scope First-request latency falls after warm-up
Backends persist after traffic stops Instances retained with min-instances Expected — account for it in the floor Trough count matches min-instances × pool_min
Connections lost after a quiet period Idle reaping between requests Retry once on a connection error Errors absent across quiet cycles

Validation and Verification

Run a deliberate load test to max-instances before traffic does it for you.

# Drive enough load to reach the instance ceiling.
hey -z 3m -c 400 https://orders-api-xxxx.a.run.app/health

# In parallel, watch both sides:
gcloud run services describe orders-api \
  --format='value(status.traffic)'      # instance count via Cloud Monitoring
-- On Cloud SQL, confirm the total stays inside the allowance.
SELECT count(*) AS backends
FROM pg_stat_activity
WHERE application_name = 'orders-api' AND backend_type = 'client backend';

Two conditions must hold: the backend count settles at or below the derived allowance, and no request fails with a connection error. If instance count reaches its maximum and requests queue at the platform instead, that is the intended behaviour — throttling is a much better failure mode than exhausting a shared database.

Bounding max-instances bounds the demand With max-instances unbounded, backend demand rises until the allowance is crossed. Bounding it caps demand at a computable value, and requests beyond that capacity queue at the platform instead. 0 backends allowance — 200 max-instances 100 — crosses at ~25 max-instances 25 — caps at the allowance offered load → Beyond the cap, requests queue at Cloud Run — a throttle that affects one service rather than a database that affects all of them.
Bounding `max-instances` converts an unbounded database problem into a bounded platform one. Throttling one service is a far better failure than exhausting a shared database.

Frequently Asked Questions

Should containerConcurrency be lowered to reduce connections?
Rarely. Lowering it increases the instance count needed for the same throughput, which multiplies pools rather than reducing them. High concurrency with a modest pool is the configuration Cloud Run is designed for and the one that uses connections most efficiently.
Does min-instances hold connections continuously?
Yes — retained instances keep their pools, so the trough backend count is min-instances × pool_min. That is usually a small number and worth it for the cold-start reduction, but it should appear in the budget rather than being a surprise.
Is the Cloud SQL connector slower than a direct connection?
Marginally, and only at establishment: it performs the IAM token exchange and mTLS handshake in-process. Once connected, query latency is identical. On a pool that reuses connections the cost is amortised to almost nothing.
What is the equivalent of RDS Proxy on GCP?
There is no managed equivalent. Running PgBouncer as a shared Cloud Run service or a GKE deployment is the standard approach, with the session-state audit that transaction pooling always requires.
How does this apply to Cloud Functions 1st gen?
It does not — 1st gen serves one request per instance, so the Lambda model applies: a pool of one, module-scope construction, and instance count as the sole multiplier. Confirm the generation before choosing a model, because the two differ by an order of magnitude in correct pool size.
Does Cloud Run’s CPU throttling between requests affect connections?
It can. With CPU allocated only during request processing — the default outside of --cpu-boost and --no-cpu-throttling — background timers in the connection pool do not run reliably between requests, which reproduces the Lambda freeze problem on a platform that otherwise avoids it. Enabling always-allocated CPU restores normal pool timer behaviour at a billing cost.
Should the pool be warmed at container start?
Yes on Cloud Run, where the start-up probe gives a natural place to do it. Opening pool_min connections before reporting ready keeps the first requests off the handshake path and means a container that cannot reach the database never receives traffic.