GCP Cloud Run vs AWS Fargate for Bursty APIs: What We Learned Running Both
We ran the same Node API on Cloud Run and Fargate for six months. Cold starts, egress costs, autoscaling behaviour, and the operational papercuts nobody warns you about.
We spent about six months running effectively the same Node.js API on both GCP Cloud Run and AWS Fargate, for two different clients with similar traffic shapes: spiky, unpredictable, and cheap-to-serve when idle. Both platforms sell you the same pitch — containers without the cluster — but they behave very differently once real traffic hits. This is what actually mattered.
The workload, so the numbers mean something
Both services were HTTP APIs fronting Postgres, doing roughly 40–60ms of work per request, with request volume that idled around 2–5 RPS overnight and spiked into the low thousands during promo windows. Container image was ~180MB, cold boot to first useful response was around 900ms–1.4s depending on the runtime.
Neither service was CPU-bound. Neither had strong latency SLOs beyond p95 < 600ms. This is the sweet spot both platforms are designed for, which is exactly why the differences we saw are worth writing down.
Cold starts and scale-to-zero
This is where the two products diverge most, and the marketing gets least honest.
Cloud Run: real scale-to-zero, real cold starts
Cloud Run genuinely scales to zero. If nothing is hitting your service, you pay nothing for compute. When traffic returns, you eat a cold start. In our experience, cold starts for a moderate Node image landed in the 700ms–1.5s range, with occasional outliers around 2s when the platform was clearly provisioning a new node underneath us.
Cloud Run gives you min-instances to pin warm containers. Setting min-instances=1 killed the cold-start problem for the primary path but you're now paying for a container 24/7, which somewhat defeats the point. For a low-traffic internal API, one warm instance still ran us cheaper than the equivalent Fargate task.
Fargate: no true scale-to-zero, but predictable
Fargate doesn't scale to zero out of the box. You run at least one task, or you build something around it (EventBridge + Lambda to start tasks, or Fargate Spot with an ALB, both of which are more moving parts than they sound). If you leave a single 0.25 vCPU / 0.5GB task running, that's roughly $9–11/month per service in us-east-1 before data transfer — cheap, but not zero.
The upside: no cold starts once the task is up. p99 latency was noticeably tighter on Fargate for the low-traffic hours because there was nothing to warm up.
If your traffic pattern has genuine dead hours and you can tolerate 1s cold starts, Cloud Run wins on cost. If you have any latency-sensitive user staring at a spinner during those dead hours, Fargate's floor cost is worth it.
Autoscaling behaviour under bursts
This is where we got surprised, in both directions.
Cloud Run scales aggressively, sometimes too aggressively
Cloud Run's concurrency model is per-instance concurrency (default 80). It'll spin up instances fast — we saw it go from 2 to ~40 instances inside 20 seconds during a promo push. Great for latency. Less great when those 40 instances all opened Postgres connections simultaneously and briefly exhausted our connection pool.
We ended up doing two things:
- Lowered
max-instancesto a sane ceiling based on Postgresmax_connectionsdivided by expected connections per container. - Put PgBouncer in front of Postgres. Should have done this from day one.
# Cloud Run service config (excerpt)
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/minScale: "1"
autoscaling.knative.dev/maxScale: "25"
run.googleapis.com/cpu-throttling: "true"
spec:
containerConcurrency: 40
timeoutSeconds: 30
We dropped containerConcurrency from 80 to 40 to keep per-instance memory in a healthier range for our workload. Your mileage varies entirely with what your handlers actually do.
Fargate scales like ECS, because it is ECS
Fargate scaling is bound to your ECS service's autoscaling policy. Target tracking on CPU or ALB request count is the usual setup. In practice, scale-out lag from a burst to a new task being in service behind the ALB was 45–90 seconds. That's a long time when traffic just 10x'd.
Workarounds we've used:
- Step scaling instead of target tracking, with aggressive first-step policies.
- Pre-scaling on a schedule for known events (promo drops, campaign launches).
- Keeping headroom by running fewer, larger tasks so a single new task adds meaningful capacity.
None of these feel as clean as Cloud Run's default behaviour. Fargate is more predictable and more manual.
Cost, honestly
We won't publish exact bills, but the shape:
- Steady low traffic (100k requests/day, mostly during business hours): Cloud Run was materially cheaper — roughly half — because it went idle for most of the night.
- Steady moderate traffic (2M requests/day, 24/7): Roughly a wash. Cloud Run's per-request billing catches up to Fargate's per-second billing once you're always-on. Fargate edged ahead by maybe 10–15% at sustained load.
- Egress: This is the one nobody talks about. GCP egress to the public internet is priced painfully if you don't use Cloud CDN or a similar layer. AWS egress isn't cheap either, but if your consumers are AWS-hosted, VPC peering and PrivateLink can meaningfully cut it. Model this before you commit.
If you're comparing quotes for a client project, make sure the egress line item is in the same column as the compute line item. We've seen "cheap" compute get eaten alive by data transfer.
Operational papercuts
Both platforms are fine. Both have edges.
Cloud Run
- Deploys are genuinely fast.
gcloud run deployfrom a Cloud Build artifact was consistently under 90 seconds end-to-end for us. Traffic splitting between revisions is a first-class feature and we used it for every deploy. - VPC access is a mild pain. Serverless VPC connectors work, but they're another resource to size and pay for, and they have their own scaling quirks.
- Logging is Cloud Logging. Fine if you're already in GCP. If you need to ship logs to a third party, you're setting up a sink, and the sink has its own failure modes.
Fargate
- IAM is powerful and exhausting. Task role, execution role, and the fifteen policies you'll attach to each. Worth it once you've done it three times. Painful the first time.
- ALB + target groups + service discovery is a lot of Terraform for what Cloud Run gives you as a single resource. Not bad, just more.
- Fargate Spot is real savings (up to ~70% off) if your workload tolerates interruption. We've used it for async workers, not for user-facing APIs.
# Fargate task with sensible defaults (excerpt)
resource "aws_ecs_service" "api" {
name = "api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 2
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
network_configuration {
subnets = var.private_subnets
security_groups = [aws_security_group.api.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 8080
}
}
Observability: neither gives you enough
Both platforms expose basic metrics — request count, latency percentiles, instance counts, CPU/memory. Neither is enough on its own for anything you actually care about debugging.
We run OpenTelemetry inside the container in both cases, exporting to a vendor-neutral collector, and Sentry for errors. The container doesn't care what's underneath. This is the part of the stack you should optimise for portability, because it's the part you'll rebuild if you migrate.
One concrete gotcha: Cloud Run's request-scoped CPU allocation (the default) can pause your process between requests. If you're batching OTel spans and flushing on a timer, you might drop spans. Either flush on request end or enable "CPU always allocated" and eat the cost.
Which one we'd pick for a new project
Honestly, it depends on where the rest of the stack lives. Latency-sensitive API with a bursty pattern and no other AWS dependencies? Cloud Run, with min-instances=1 and PgBouncer in front of the database. Team already deep in AWS, with VPC-native services, RDS, and existing Terraform? Fargate, with step scaling and Spot for anything async.
The worst answer is picking one because a blog post said it was faster. Both platforms are good. Neither is magic.
Where we'd start
If you're evaluating for a real workload, do this before you commit:
- Deploy a representative container to both, behind a load test that mimics your actual burst shape (not a flat RPS curve).
- Measure p50/p95/p99 during scale-out, not just steady state.
- Put the egress line item on the same spreadsheet as the compute line item.
- Instrument with OpenTelemetry from day one so the migration cost, if it ever happens, is small.
If you'd like a hand sizing this for a specific workload, that's the sort of thing we do on our DevOps and Cloud engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
The Terraform State Lock That Held Our Deploys Hostage for 6 Hours
A stuck DynamoDB lock froze every pipeline in the org. Here's what actually happened, why our runbook made it worse, and the guardrails we put in afterward.
The S3 Egress Bill That Doubled Overnight: Tracing a Rogue Prefetch
A quiet Tuesday, a doubled AWS bill, and a client-side prefetch nobody remembered shipping. Here's how we traced 3.2 TB of surprise S3 egress back to a single React hook.
The DynamoDB Hot Partition That Only Showed Up on Black Friday
A DynamoDB table that behaved perfectly in load tests fell over during a Black Friday spike. Here's the partition key mistake we made, how we found it, and the redesign that stopped it happening again.
