How a Webhook Backlog Drained Our Lambda Concurrency and Broke Checkout
← Back
August 28, 2026Architecture9 min read

How a Webhook Backlog Drained Our Lambda Concurrency and Broke Checkout

Published August 28, 20269 min read

14:32 UTC. PagerDuty pages the on-call backend engineer: checkout-confirm error rate above 20% for two consecutive minutes. Nobody deployed anything today. Nobody touched checkout this week. The function that's failing hasn't been opened in a pull request since July.


the setup

checkout-confirm is a small Lambda function sitting behind an API Gateway HTTP API. It validates the cart, calls the payment processor, and writes the order. Normal traffic is 15 to 20 concurrent invocations at peak. It has never needed more than that, so nobody ever set a concurrency limit on it, reserved or otherwise. On-demand Lambda functions default to drawing from the account's shared regional pool, and that had never been a problem worth thinking about.

A separate function, carrier-webhook-ingest, receives delivery-status updates from a regional shipping carrier. Every scan event, out-for-delivery, delivered, exception, arrives as a webhook, and API Gateway invokes the Lambda synchronously, one event in, one Lambda execution out. It also has no reserved concurrency configured. Also never a problem, because the carrier sends maybe 40 events a minute under normal load.


the scramble

First theory: RDS. Checkout writes to the orders table, and a connection pool exhaustion incident six months ago left everyone reflexively checking database connections first. RDS Proxy's connection metrics were flat, well under any limit. Dead end.

Second theory: a bad deploy, maybe from a dependent service. The deploy log for the last 48 hours showed nothing touching checkout, payments, or the shared API Gateway stage. Dead end.

Third theory, and the one that actually pointed somewhere: API Gateway's access logs showed the requests reaching Lambda fine, integration latency under 40ms, but the Lambda invocation itself returning an error before doing any work.

API Gateway execution log, checkout-confirm
14:31:58 (a3f8-...) Endpoint response code: 429
14:31:58 (a3f8-...) Lambda invoke failed: TooManyRequestsException
14:31:58 (a3f8-...) Reason: Rate Exceeded.

Not a timeout. Not a runtime error inside the function. Lambda itself was refusing to start an execution. That's a throttle, and checkout-confirm wasn't anywhere near the invocation rate that would trigger one by itself.


the hunt

TooManyRequestsException at the invoke layer means one of two things: a function-level concurrency limit, or the account's regional pool is exhausted. Checking the function directly ruled out the first.

checking checkout-confirm's own limits
$ aws lambda get-function-concurrency --function-name checkout-confirm
{
    "ReservedConcurrentExecutions": null
}

No reserved concurrency set, which meant it draws from the shared pool, which meant the pool itself was the suspect.

account-wide concurrency limit and usage
$ aws lambda get-account-settings --query 'AccountLimit'
{
    "TotalCodeSize": 80530636800,
    "ConcurrentExecutions": 1000,
    "UnreservedConcurrentExecutions": 1000
}

1,000 concurrent executions is the AWS default regional limit, and it had never been raised because nothing in this account had ever come close to it. Pulling the account-level ConcurrentExecutions CloudWatch metric for the incident window showed it pinned at 1,000 starting at roughly 14:29 UTC, three minutes before the first checkout alert fired.

Something else was eating the pool. Breaking the same metric down by function pointed straight at it.

per-function concurrency, 14:29-14:35 UTC
$ for fn in carrier-webhook-ingest checkout-confirm order-search notify-worker; do
    echo "$fn:"
    aws cloudwatch get-metric-statistics \
      --namespace AWS/Lambda --metric-name ConcurrentExecutions \
      --dimensions Name=FunctionName,Value=$fn \
      --start-time 2026-08-28T14:29:00Z --end-time 2026-08-28T14:35:00Z \
      --period 60 --statistics Maximum --query 'Datapoints[].Maximum'
  done

carrier-webhook-ingest:
[12.0, 940.0, 968.0, 971.0, 340.0, 61.0]
checkout-confirm:
[18.0, 21.0, 4.0, 2.0, 15.0, 17.0]
order-search:
[6.0, 3.0, 1.0, 2.0, 5.0, 6.0]
notify-worker:
[9.0, 7.0, 2.0, 3.0, 8.0, 9.0]

carrier-webhook-ingest jumped from 12 concurrent executions to 940 in the span of a minute, and stayed above 900 for two more. Every other function in the account, including checkout, got squeezed into whatever the pool had left, which for a two-minute window was effectively nothing.

Why did webhook ingestion spike 78x in a minute? The carrier's own status page had the answer, posted twelve minutes after our incident started: a regional outage from 08:00 to 14:00 UTC had queued every delivery-status event on their side, and at 14:29 they replayed the entire backlog in one burst. Roughly six hours of events, about 52,000 of them, landed on our webhook endpoint in under three minutes.


the find

Root cause: Lambda's concurrency limit isn't per-function by default, it's a single shared pool for the whole account and region. carrier-webhook-ingest was configured to scale as fast as invocations arrived, with no ceiling, because nobody had ever needed one. When the carrier replayed six hours of backlog at once, the function scaled to match, consumed 97% of the account's 1,000-slot pool, and left every other function, including the one that takes payment, fighting over the remainder.

Nothing about this looked like an ingest failure from the ingest side. The function wasn't erroring, it was doing exactly what an unbounded Lambda is designed to do under burst load. The damage showed up entirely on a function that had nothing to do with the carrier, which is why the first two theories both pointed at checkout and payments instead of a mailbox nobody was watching.


the fix

The immediate fix was a concurrency ceiling on the greedy function, so a burst on its side can never again consume the whole pool.

terraform, reserved concurrency cap
resource "aws_lambda_function" "carrier_webhook_ingest" {
  function_name = "carrier-webhook-ingest"
  # ...

  reserved_concurrent_executions = 150
}

A cap alone just changes who gets throttled during a burst, from checkout to the webhook processor itself. That's an acceptable tradeoff, since the carrier retries failed webhook deliveries and checkout does not get a second chance from an abandoned cart. But we also reserved a floor for checkout, since reserving concurrency for one function subtracts it from the shared pool and guarantees that capacity can't be taken by anything else.

terraform, reserved concurrency floor for checkout
resource "aws_lambda_function" "checkout_confirm" {
  function_name = "checkout-confirm"
  # ...

  reserved_concurrent_executions = 100
}

The bigger fix was architectural. A synchronous API Gateway to Lambda proxy has no buffer, every inbound event becomes an execution attempt immediately, which is exactly what turns a backlog replay into a concurrency spike. We put an SQS queue between the webhook endpoint and the processing Lambda, so a burst lands in the queue instead of spawning thousands of simultaneous executions.

terraform, buffered ingestion with bounded concurrency
resource "aws_lambda_event_source_mapping" "webhook_queue_trigger" {
  event_source_arn = aws_sqs_queue.carrier_webhooks.arn
  function_name    = aws_lambda_function.carrier_webhook_ingest.arn
  batch_size       = 25
  scaling_config {
    maximum_concurrency = 100
  }
}

The API Gateway route in front of the queue now just calls sqs:SendMessage directly through a service integration, no Lambda in that path at all. Ingestion drains the queue at a rate the account's pool can absorb no matter how large the burst on the carrier's side is, and a slow drain during a replay costs us minutes of delivery-status latency instead of a checkout outage.

We also added an account-level guardrail so the next unreserved function that spikes gets caught before it reaches another one.

cloudwatch alarm, account concurrency utilization
resource "aws_cloudwatch_metric_alarm" "account_concurrency_high" {
  alarm_name          = "lambda-account-concurrency-utilization-high"
  namespace           = "AWS/Lambda"
  metric_name         = "ConcurrentExecutions"
  statistic           = "Maximum"
  period              = 60
  evaluation_periods  = 2
  threshold           = 700  # 70% of the 1000 regional limit
  comparison_operator = "GreaterThanThreshold"
}

the aftermath

26 min Duration of the checkout throttling window
971 Peak concurrent executions on one function
214 Checkout attempts that failed outright
~$31,900 Estimated revenue from carts abandoned during the window

About half of the 214 failed checkouts came back and retried successfully once the throttling cleared. The rest we can only infer from the drop against the same hour the week before, which is where the revenue estimate comes from, not a hard count.

  • Lambda concurrency is an account-and-region resource, not a per-function one, unless you explicitly reserve it. Any unreserved function can, in principle, take the entire pool from every other function in the account.
  • A function that's scaling correctly under burst load can still be the root cause of an outage somewhere else entirely. It never errored once during this incident. It just did its job efficiently, at the expense of everything sharing its pool.
  • Synchronous proxy integrations turn every inbound spike directly into a concurrency spike. A queue in between converts a burst into a backlog, which is a much easier problem to survive.
  • Reserved concurrency is two tools in one call: a ceiling on the function you set it on, and a floor for everything you didn't, since what's reserved can no longer be taken from the shared pool.

The carrier's status page now gets a webhook subscription of its own, so a queued backlog replay shows up as an internal alert before it shows up as 52,000 events at our door. The account concurrency alarm hasn't fired since, though it came close once, during a load test someone forgot was scoped against production.

Share this
← All Posts9 min read