Skip to content

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.

Concurrency quotas for a 6,292-tile Sentinel-2 fan-out on AWS, GCP and AzureComparison grid across AWS Lambda, GCP Cloud Functions 2nd generation and Azure Functions on the Consumption plan covering the published ceiling, the scope that ceiling applies to, requests served per unit, the resulting tiles in flight for the example workload, memory and timeout per unit, and whether the ceiling can be raised.The published ceilings, normalised to tiles in flightAWS LambdaGCP Functions (2nd gen)Azure (Consumption)Published ceiling1,000concurrent executions3,000instances200instancesScope of the ceilingAccount, per regionProjectOne function appRequests per unit1always1–1,000default 801alwaysTiles in flight1,0003,000at concurrency 1200Memory per unit10,240 MB32,768 MB1,536 MBTimeout per unit15 min60 min10 minCeiling raisableYessoft quotaYesproject quotaNomove to PremiumTiles in flight is the published ceiling multiplied by requests per unit. For a GDAL worker holding 1.2 GB per tile, requests per unit on GCPshould be 1.
Only the fourth row is comparable across providers. The three published ceilings count different things, and GCP's advantage disappears entirely if the worker cannot share an instance.

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:GetServiceQuota on AWS, serviceusage.quotas.get on GCP, and Microsoft.Web/sites/read plus 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_LIB and LD_LIBRARY_PATH pinned 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.

Waves required to process one Sentinel-2 scene on each providerA bar chart of how many sequential waves a 6,292-tile fan-out needs at four different widths: three waves at GCP's 3,000 instances, seven at the AWS default quota of 1,000, sixteen at a reserved slice of 400, and thirty-two at Azure Consumption's 200 instances per function app.Sequential waves for one 6,292-tile sceneGCP — 3,000 instances3 wavesAWS — 1,000 default quota7 wavesAWS — 400 reserved16 wavesAzure — 200 per app32 waves032 wavesThe reserved-400 row is deliberately narrower than the quota allows: it leaves 600 executions for the metadata, catalog and tile-servingfunctions beside it.
Every wave that adds capacity pays a cold start, and every wave is bounded by its slowest tile. Azure's ramp is gradual, so its thirty-two waves take longer than the arithmetic alone predicts.

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.

python
"""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.

python
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:

code
{"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:

bash
# 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

Choosing a provider from the required fan-out widthA decision tree asking how many tiles must be in flight at once, with three outcomes: under 200 tiles suits Azure Consumption provided each tile fits ten minutes and 1,536 megabytes; 200 to 1,000 tiles suits AWS Lambda on its default quota with a reserved slice and a capped Map state; and over 1,000 tiles points to GCP Cloud Run or a raised AWS quota.Pick the provider from the width, then check the second constraintHow many tiles must be in flight at once?under 200Azure Consumption200 instances per appeach tile in 10 min / 1,536 MB200 to 1,000AWS Lambda, default quotareserve the slicecap MaxConcurrency below itover 1,000GCP Cloud Run, or raise AWS3,000 instances per projectconcurrency 1 for GDAL
The width picks the shortlist; the memory and timeout ceilings decide it. Azure's narrow fan-out also forces small tiles, which produces more of them — the two constraints push against each other.

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 1 for 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-1 and 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.


Back to Concurrency and Throttling for Tile Fan-Out