Comparing Concurrency Quotas Across AWS, GCP, and Azure
AWS Lambda gives you 1,000 concurrent executions per account per region, GCP Cloud Functions 2nd gen gives 3,000 instances per project, and Azure Functions on the Consumption plan gives 200 instances per function app. Those three numbers are not measuring the same thing: AWS counts one in-flight request per execution environment, GCP counts instances that can each serve up to 1,000 concurrent requests, and Azure counts instances scoped to one app rather than to the whole subscription. Normalise all three to tiles in flight before comparing them, or a fan-out sized on one provider will be wrong by an order of magnitude on the next.
Why the Three Numbers Are Not Comparable As Published
Take a concrete workload and hold it constant: one Sentinel-2 scene, thirteen bands, tiled at 512 px, giving 6,292 tile jobs. Each job opens a windowed read against a COG, computes an index, and writes an output tile plus a catalog record. On a warm environment it takes about 900 ms and needs roughly 1.2 GB of resident memory for the GDAL block cache and the numpy working array.
AWS counts execution environments, one request each. A concurrency of 1,000 means exactly 1,000 tiles in flight. Nothing about the runtime changes that — Lambda dispatches one event per environment, always. The number is honest and easy to reason about, and it is shared with every other function in the region, which is the part that catches people.
GCP counts instances, each of which may serve many requests. The default per-instance concurrency for Cloud Functions 2nd gen and Cloud Run is 80; set it to 1 and 3,000 instances mean 3,000 tiles in flight, set it to 8 and the same quota means 24,000. For GDAL workloads the low setting is usually correct, because each concurrent request wants its own block cache and its own share of the instance’s memory allocation — eight concurrent tiles at 1.2 GB each exceeds every practical instance size, and the failure mode is an out-of-memory kill rather than a throttle. Choosing per-instance concurrency is therefore a memory decision disguised as a concurrency decision, and it is the reason GCP’s 3,000 cannot be compared to AWS’s 1,000 without stating it.
Azure counts instances per function app, not per subscription. Two hundred is the Consumption ceiling for one function app; deploying the tile worker as its own app gives it its own 200 without competing with anything else. That sounds generous until you notice the app also carries a 10-minute timeout and a 1,536 MB memory ceiling, both of which bind before the concurrency does for raster work — the same conclusion the timeout ceiling comparison for geospatial jobs reaches from the other direction.
| Property | AWS Lambda | GCP Cloud Functions (2nd gen) | Azure Functions (Consumption) |
|---|---|---|---|
| Published ceiling | 1,000 concurrent executions | 3,000 instances | 200 instances |
| Scope | Account, per region | Project | Function app |
| Requests per unit | 1 | Configurable, default 80 | 1 |
| Tiles in flight for our workload | 1,000 | 3,000 at concurrency 1 | 200 |
| Raisable | Yes, soft quota via Service Quotas | Yes, project quota increase | No on Consumption; move to Premium |
| Per-function cap | Reserved concurrency | --max-instances |
Host functionAppScaleLimit |
| Guaranteed floor | Reserved concurrency | --min-instances |
Premium plan only |
| Memory ceiling per unit | 10,240 MB | 32,768 MB | 1,536 MB |
| Timeout | 15 min | 60 min | 10 min |
| Throttle signature | TooManyRequestsException, 429 |
429, no available instance |
429 with Retry-After |
Prerequisites
- A stated fan-out width. Nothing below is meaningful without the number of tiles a scene produces and how many scenes can arrive together. Derive it from the raster header as the concurrency and throttling for tile fan-out overview shows.
- A measured per-tile memory high-water mark. On GCP this decides per-instance concurrency; on Azure it decides whether Consumption is viable at all.
- Quota-read permissions on each provider:
servicequotas:GetServiceQuotaon AWS,serviceusage.quotas.geton GCP, andMicrosoft.Web/sites/readplus subscription usage read on Azure. - The same worker image on all three. Comparing quotas is only useful when the work per unit is identical — a native GDAL build with
GDAL_DATA,PROJ_LIBandLD_LIBRARY_PATHpinned identically, as pinning GDAL and PROJ versions across build and runtime sets out.
How Fast Each Provider Reaches Its Ceiling
The ceiling is only half the story. A fan-out wider than the ceiling runs as a series of sequential waves, and a fan-out that takes minutes to reach full width spends those minutes underutilised. On a workload measured in seconds per tile, the ramp dominates the total.
AWS starts with a burst allowance of 500 to 3,000 concurrent executions depending on the region — 3,000 in the largest regions, 500 in the smallest — and then adds up to 1,000 concurrent executions every 10 seconds per function. In a 3,000-burst region the default account quota of 1,000 is reached instantly, so the burst allowance is invisible until the quota is raised; in a 500-burst region a fan-out reaches 500 immediately and the remaining 500 arrive 10 seconds later.
GCP’s Cloud Run substrate scales aggressively and will add hundreds of instances within the first few seconds, but each new instance pays the full container start plus the GDAL initialisation described in cold start comparison across AWS, GCP, and Azure. Setting --min-instances on the worker converts part of that ramp into a standing cost, which is worthwhile when scene deliveries are frequent and wasteful when they are daily.
Azure’s scale controller is the most conservative of the three. It samples the trigger — queue depth, for a queue-triggered tile worker — on an interval and adds instances gradually rather than in one step. Reaching 200 instances from cold takes minutes, not seconds, so on Azure the practical fan-out width for a short job is well below the published ceiling. This is the strongest argument for keeping tile jobs coarse on Azure: fewer, longer invocations amortise the ramp instead of fighting it.
Applying the Right Cap on Each Provider
The three providers expose the same idea through three different mechanisms. All three should be set; the failure mode of leaving them at defaults is identical everywhere.
"""Bound a tile fan-out on whichever provider it lands on."""
import subprocess
WORKER = "tile-worker"
TILES_IN_FLIGHT = 200 # the width this pipeline is allowed
def cap_aws(region: str = "eu-west-1") -> None:
"""Reserved concurrency: a guarantee and a ceiling at the same value."""
subprocess.run(
[
"aws", "lambda", "put-function-concurrency",
"--function-name", WORKER,
"--reserved-concurrent-executions", str(TILES_IN_FLIGHT),
"--region", region,
],
check=True,
)
def cap_gcp(region: str = "europe-west1") -> None:
"""max-instances plus per-instance concurrency 1: instances == tiles."""
subprocess.run(
[
"gcloud", "run", "deploy", WORKER,
"--region", region,
"--max-instances", str(TILES_IN_FLIGHT),
"--concurrency", "1", # one tile per instance, GDAL-safe
"--memory", "4Gi",
"--set-env-vars",
"GDAL_DATA=/opt/share/gdal,PROJ_LIB=/opt/share/proj,"
"LD_LIBRARY_PATH=/opt/lib,GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR",
],
check=True,
)
def cap_azure(app: str, group: str) -> None:
"""functionAppScaleLimit caps instances below the 200-instance plan ceiling."""
subprocess.run(
[
"az", "resource", "update",
"--resource-group", group,
"--name", f"{app}/config/web",
"--resource-type", "Microsoft.Web/sites/config",
"--set", f"properties.functionAppScaleLimit={TILES_IN_FLIGHT}",
],
check=True,
)
subprocess.run(
[
"az", "functionapp", "config", "appsettings", "set",
"--name", app, "--resource-group", group,
"--settings",
"GDAL_DATA=/home/site/wwwroot/share/gdal",
"PROJ_LIB=/home/site/wwwroot/share/proj",
"LD_LIBRARY_PATH=/home/site/wwwroot/lib",
],
check=True,
)
Note what is symmetric and what is not. AWS’s reserved concurrency both guarantees and caps; GCP’s --max-instances only caps, and --min-instances is the separate guarantee; Azure’s functionAppScaleLimit only caps, and there is no guarantee available on Consumption at all. A pipeline that depends on a floor as well as a ceiling has one fewer provider option than the quota table suggests.
Verification
Read the quota from the control plane on each provider rather than trusting the documentation, because soft quotas drift as accounts age and previous increase requests are easy to forget.
import boto3
import json
sq = boto3.client("service-quotas", region_name="eu-west-1")
lam = boto3.client("lambda", region_name="eu-west-1")
# L-B99A9384 is "Concurrent executions" for AWS Lambda.
CONCURRENCY_QUOTA_CODE = "L-B99A9384"
def aws_effective_ceiling(function_name: str) -> dict:
quota = sq.get_service_quota(
ServiceCode="lambda", QuotaCode=CONCURRENCY_QUOTA_CODE
)["Quota"]["Value"]
account = lam.get_account_settings()["AccountLimit"]
fn = lam.get_function_concurrency(FunctionName=function_name)
reserved = fn.get("ReservedConcurrentExecutions")
report = {
"provider": "aws",
"account_quota": int(quota),
"unreserved_available": account["UnreservedConcurrentExecutions"],
"function_reserved": reserved,
"tiles_in_flight": reserved or account["UnreservedConcurrentExecutions"],
}
assert report["tiles_in_flight"] <= report["account_quota"], "quota mismatch"
print(json.dumps(report))
return report
Expected output on an account whose quota has never been raised, with the worker reserved at 200:
{"provider": "aws", "account_quota": 1000, "unreserved_available": 300,
"function_reserved": 200, "tiles_in_flight": 200}
The equivalents on the other two providers are single commands, and both print the effective ceiling rather than the plan default:
# GCP — the per-service cap, and the project instance quota behind it
gcloud run services describe tile-worker --region europe-west1 \
--format="value(spec.template.metadata.annotations['autoscaling.knative.dev/maxScale'])"
gcloud services quota list --service=run.googleapis.com \
--consumer=projects/geo-pipeline --filter="metric:instances"
# Azure — the app's scale limit, which must be at or below the plan's 200
az resource show --resource-group geo-rg --name tile-worker/config/web \
--resource-type Microsoft.Web/sites/config \
--query properties.functionAppScaleLimit
If the AWS number returned by Service Quotas is 1,000 and someone remembers raising it, the increase was applied in a different region — the quota is regional, and a raise in us-east-1 does nothing for eu-west-1.
Choosing a Provider for a Given Fan-Out Width
The decision is usually decided by the second constraint rather than the first. A fan-out narrow enough for Azure Consumption’s 200 instances is also a fan-out whose tiles must each finish in 10 minutes inside 1,536 MB, which for raster work means small tiles and therefore more of them — the constraints push against each other. GCP’s 3,000-instance ceiling combined with a 32,768 MB memory ceiling and a 60-minute timeout is the most permissive combination available, and it is the reason wide vector-tile fan-outs frequently land on Cloud Run, as in generating MVT tiles with Tippecanoe in Cloud Run.
AWS remains the default for orchestrated raster pipelines despite the lowest published ceiling of the three, for one reason that does not appear in any quota table: the ceiling is a soft quota that is routinely raised on request, and Step Functions Distributed Map gives fine-grained control over how the fan-out approaches it. A raised quota of 5,000 with MaxConcurrency and reserved concurrency both set is a more controllable arrangement than an unbounded 3,000 on another provider.
Gotchas
-
A GCP per-instance concurrency above 1 silently multiplies memory pressure. The instance memory limit is shared by every concurrent request on it. Two tiles at 1.2 GB on a 2 GB instance is an out-of-memory termination, which appears in Cloud Logging as an instance restart rather than as a request error — so the failed tiles look like transient network faults. Set
--concurrency 1for GDAL workers unless you have measured otherwise. -
Azure’s 200 is per function app, and everything in the app shares it. Co-locating the tile worker with a timer-triggered catalog sweeper and an HTTP status endpoint means all three compete for the same 200 instances. On Azure the equivalent of reserved concurrency is deployment topology: give the fan-out worker its own function app.
-
AWS burst allowance varies by region and is not visible in the console. The same account fans out to 3,000 immediately in
us-east-1and to 500 in a smaller region, with identical configuration. If a pipeline behaves differently after a regional migration and nothing in the code changed, the burst allowance is the first thing to check — the quota is identical, the ramp is not. -
Raising a quota does not raise the downstream store’s. Every provider will let you fan out wider than the catalog table, the database pool, or the external tile API can accept. The narrowest quota in the chain still sets the real width, and it is rarely the compute one.
Frequently Asked Questions
Which provider allows the widest spatial fan-out by default?
GCP. Cloud Functions 2nd gen allows 3,000 instances per project, against 1,000 concurrent executions per account per region on AWS Lambda and 200 instances per function app on Azure Consumption. The advantage grows if the worker can safely serve more than one request per instance, and shrinks to nothing if it cannot — which is usually the case for GDAL.
Is the AWS 1,000-execution limit per function or per account?
Per account, per region, shared by every function in that region. Reserved concurrency subdivides the pool per function. GCP’s 3,000 is per project and Azure’s 200 is per function app, so the scope differs on all three — and scope matters more than the absolute number when several pipelines share an environment.
Can a 6,292-tile scene run on Azure Functions Consumption?
Yes, but as roughly 32 sequential waves of 200 instances, each of which must finish inside the 10-minute timeout with 1,536 MB of memory. The scale controller also ramps gradually, so the effective width is lower than 200 for short jobs. For scenes of that size, Premium or a container runtime is the realistic choice.
Does GCP throttle or queue when the instance ceiling is reached?
It returns HTTP 429 with The request was aborted because there was no available instance. Pub/Sub push subscriptions retry it automatically with their own backoff, so a Pub/Sub-driven fan-out degrades into a slower fan-out rather than failing — which is closer to SQS back-pressure on AWS than to a raw Lambda throttle.
Related
- Concurrency and Throttling for Tile Fan-Out — the ceilings, the throttle signatures, and the back-pressure patterns in full
- Reserved Concurrency for Geospatial Lambda Fan-Out — the AWS-side cap referenced throughout this comparison
- Handling Throttling Errors in Step Functions Map State — what the orchestrator does when the ceiling is reached anyway
- Cold Start Comparison: AWS vs GCP vs Azure — the initialisation cost each new instance in the ramp pays
- Ephemeral Storage Comparison Across Serverless Platforms — the
/tmpbudget each concurrent worker holds while it runs