Home› Blog› Week 25 - Serverless Microservices: The Neighbour …
AWS Weekly Lab AWS Terraform

The neighbour that starved checkout

Three Lambda functions that never call each other, in one account, sharing one number. A reporting job nobody considered critical took the whole pool, and the checkout service failed 95% of its requests without a single line of its own code changing.

Verified against current vendor documentation on 30 October 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
AWS Platform Engineering Lab · Week 25 of 52

Lab Overview

A shared office printer is fine until the person next to you sends a 400-page document. Nothing about your one-page memo changed. You just cannot get to the printer any more.

The problem. Lambda's concurrency limit is a property of the account and Region, not of a function. Every function without a reservation draws from the same pool. Teams discover this during an incident: a batch job scales up, and an unrelated customer-facing service starts returning errors that look like its own fault.

The objective. Build three services that share nothing but an account, starve the one that matters, and measure the damage. Then change exactly one attribute and measure again.

The outcome. Under identical load, checkout served 5.4% of its requests when it shared the pool and 99.5% when it held a reservation of 50. CloudWatch recorded 3,006 throttles against it in the first run and none in the second. The code, the region and the traffic were the same in both.

DifficultyIntermediate
Measured cost$0.81, of which $0.00 was Lambda — from Cost Explorer, not a rate card
DeploymentTerraform via HCP Terraform with OIDC, pinned to a known commit
Regionus-east-2 — deliberately empty, so a load test cannot touch anything else
Full lab guideGitHub — prerequisites, Terraform, commands, validation, cleanup
A terminal showing us-east-1 with 13 Lambda functions and us-east-2 with 0.
The fence, before anything was deployed: 13 functions in us-east-1, none in us-east-2. A test that deliberately exhausts a Regional quota needs a Region with nothing in it to exhaust.

Cloud Services and Concepts

ServicePurpose hereWhat it is
AWS LambdaThe three services, identical but for how long each holds its slot Runs a piece of code on demand with no server to manage. You are billed per request and per millisecond it runs, and each in-flight request occupies one execution environment.
API Gateway (HTTP API)One route per service — and the only thing that charged for this lab A managed front door that accepts HTTP requests and forwards them to a backend. The HTTP API flavour is the cheaper, simpler one, billed per million requests.
DynamoDBOne table all three write to, so they behave like a system A managed key-value database with no servers to size. You are billed per read and write rather than per hour.
CloudWatchConcurrentExecutions and Throttles — the only trustworthy evidence here AWS's metrics and logs service. Most services publish numbers into it automatically, without being asked to.
HCP TerraformRemote applies with OIDC, pinned to a commit so a run cannot plan stale code HashiCorp's hosted Terraform. It runs plans and applies on its own machines and keeps the state file, instead of your laptop doing both.

Concurrency is a multiplication, not a request rate

AWS states the formula plainly: concurrency is average requests per second multiplied by average request duration in seconds. A function answering in 50 ms at 20 requests a second occupies one slot. The same request rate against a 10-second function occupies 200. This single line decides everything below, including why my first two attempts failed.

The pool is never the number in the documentation

The default quota is 1,000 concurrent executions per Region. What a function can actually reach is 1,000 minus everyone else's reservations. AWS is explicit that reserving for one function reduces the pool available to the others even while the reserving function is idle, and that you may reserve only up to the unreserved total minus 100.

A reservation is a floor and a ceiling

Reserved concurrency "acts as both a lower and upper bound". It guarantees capacity and forbids borrowing beyond it. That makes it a protection and a cap in the same setting — and it costs nothing to configure.

Architecture and Workflow

aws AWS Cloud Region: us-east-2 User 20 req/s 1 API Gateway HTTP API 2 checkout 50 ms per request reports 10 s per request exports 10 s per request 3 DynamoDB one shared table CloudWatch watches the functions REGIONAL CONCURRENCY POOL 1,000 for the account, in this Region 900 reserved by exports 100 unreserved checkout and reports share those 100. A reservation is subtracted from the pool even while the reserving function is idle. 4 5
Three services that never call each other, and the one Regional pool they draw from. Those 100 unreserved slots are what checkout is competing for.
  1. A client posts to one of three routes on the same HTTP API.
  2. Each route invokes its own function. The handlers are byte-identical apart from one environment variable, DURATION_MS, so no result can be argued away as a property of the code.
  3. Every invocation writes to one shared DynamoDB table, which is what makes them a system rather than three unrelated demos.
  4. exports holds a reservation of 900. That capacity leaves the pool every other function draws on, whether or not exports is busy.
  5. Which leaves 100 unreserved for checkout and reports together. Each in-flight request holds a slot for as long as its handler runs — 10 seconds for reports, 50 ms for checkout — so the neighbour fills those 100 easily. Once they are gone Lambda throttles, API Gateway returns 503, and checkout fails for a reason that has nothing to do with checkout.

Design decisions

Identical handlers, one variable. The alternative was three realistic services, which would have been a better demo and a worse experiment: any difference in memory, runtime or work done becomes a competing explanation for the result.

An empty Region instead of a tagged sandbox. The test deliberately exhausts a quota that is scoped to the Region. Tags do not contain a quota. An empty Region does, and it also made the cost figure unambiguous: filtering the bill to us-east-2 isolates this lab completely.

Client-side measurement. The success rate is recorded by the thing making the requests, not inferred from CloudWatch. A service is up or down from its caller's position. CloudWatch then explains why.

Prerequisites

RequirementDetail
AWS accountWith the default Lambda quota of 1,000 concurrent executions in the target Region — confirm with aws lambda get-account-settings
An empty Regionus-east-2 here. Anything else running Lambda in it will be affected by the load test, and will contaminate the cost figure
Terraform>= 1.10, AWS provider ~> 6.0
HCP Terraform workspaceVCS-connected, with AWS reached by dynamic credentials (OIDC). No static keys
Reservation headroomReservations across all functions cannot exceed the account limit minus 100. Plan the numbers before applying — see Troubleshooting
A realistic view of your clientGenerating concurrency means holding that many requests open at once. One laptop does not reach 1,000

Implementation

week-25-serverless-microservices/
|
+-- terraform/
|   +-- environments/dev/     the three services, pinned to us-east-2
|   +-- modules/service/      one module, three instances, one differing variable
|   +-- modules/experiment/   throttle alarms and the concurrency dashboard
|
+-- src/                      the handler, identical for all three services
|
+-- scripts/                  load generation, recording, and the summary
Only the directories that matter. The full repository is on GitHub.

The control variable lives on the function, because reserved concurrency is an attribute rather than a resource of its own:

reserved_concurrent_executions = (
  var.protect_checkout && each.key == "checkout"
  ? var.checkout_reserved_concurrency   # run 2
  : lookup(var.neighbour_reservation, each.key, -1)
)

-1 means "no reservation, share the pool". That single expression is the whole experiment: nothing else differs between the two runs.

HCP Terraform workspace variables showing OIDC provider auth and a sensitive run role ARN.
The workspace reaches AWS through dynamic credentials. The run role ARN is stored write-only, and no static access key exists anywhere in this lab.
An applied HCP Terraform run pinned to a commit, showing 28 resources added.
Applied, pinned to a named commit. A run created through the HCP API reuses the last configuration version it ingested, which is how you apply yesterday's code and get told it succeeded.

Validation and Evidence

Both runs used the same load: checkout at 20 requests a second throughout, with 60 seconds of clean baseline before a neighbour ramped on and held 200 requests open continuously for three minutes.

TestExpectedActualStatus
Baseline, no neighbour load checkout serves its traffic 83.6% (run 1) and 97.6% (run 2) — cold starts account for the first run's lower figure Pass
Neighbour fills the unreserved pool ConcurrentExecutions pins at the unreserved total reports reported Maximum 100.0 every minute of both load phases Pass
Run 1 — checkout shares the pool checkout is throttled by a service it never calls 5.4% served; 913 invocations against 3,006 throttles Pass
Run 2 — checkout reserved 50 checkout is unaffected by identical load 99.5% served; 3,720 invocations, 0 throttles Pass
Control: a function holding its own reservation exports never throttles in either run 0 throttles in both runs, while its neighbour was being refused hundreds of thousands of times Pass
CloudWatch dashboard showing concurrent executions pinned at 100 and large throttle spikes during two separate load windows.
Both runs, side by side. The concurrency line sits flat at 100 — the entire unreserved pool — while the throttle count climbs into six figures. The pool is not nearly full and then full; it is full, and everything after that is refused.
A terminal comparing the two recorded runs: unprotected 5.4 percent under load, protected 99.5 percent.
The comparison, read back from the recordings rather than retyped. Same load, same code, same Region; the only difference is whether checkout reserved concurrency.

Security and Cost

Security

HCP Terraform authenticates to AWS with dynamic credentials over OIDC, so no static access key exists for this lab in any file, variable or shell profile. The run role ARN is stored write-only in the workspace. Each function receives its own execution role and writes only to the one table it needs.

The honest caveat: this is a concurrency experiment, not a hardened service. The HTTP API is public and unauthenticated, acceptable only because it lives in an empty Region, writes a UUID to a scratch table, and was destroyed afterwards. A real deployment needs an authorizer and an API-level throttle — which matters more than it looks; see the cost below.

Cost

Measured from Cost Explorer for 30 October, filtered to us-east-2. The Region had no API Gateway usage at all the day before, so nothing else in the account can contaminate these figures.

Usage typeQuantityCost
API Gateway HTTP requests798,228$0.798228
DynamoDB write request units21,446$0.013404
Lambda requests and duration21,446 requests, 30,056 GB-seconds$0.00
Total$0.81

The shape of that bill is the finding. Lambda cost nothing and API Gateway cost everything, because roughly 773,000 of those requests were throttled before they reached a function. At $1.00 per million requests you pay full price at the front door for work that never ran. Throttling protects the pool; it does not protect the bill. The control that would have is a throttle on the API itself.

A terminal showing Cost Explorer output for us-east-2: API Gateway $0.798, DynamoDB $0.013, Lambda $0.
The bill, by usage type, filtered to the Region the lab was fenced into. Lambda ran 30,056 GB-seconds inside the free tier; the 503s did not.

Reservations themselves are free — AWS charges nothing for configuring reserved concurrency. The protection in run 2 cost $0.00.

Troubleshooting and Lessons

A convincing collapse that was entirely my own fault

Problem. An early run showed checkout falling to 2% success under load. It looked exactly like the result the lab was built to produce.
Root cause. It was the client dying, not the service. The failures were connection errors rather than HTTP 503s, they began ten seconds before the neighbours ramped on, and CloudWatch recorded zero throttles on all three functions with the pool peaking at 292 of 1,000.
Resolution. Change the success criterion from a success-rate curve to Throttles in CloudWatch, then re-run.
Lesson. A graph will show you a collapse whoever caused it. If the metric you celebrate can be produced by your own test harness failing, it is not evidence. Pick a metric only the system under test can move.

One machine cannot fill a 1,000-slot pool

Problem. Two attempts failed to exhaust the pool, peaking at 319 and 292 concurrent executions with no throttling at all.
Root cause. Concurrency is requests per second times duration, so occupying 1,000 slots means holding 1,000 requests open simultaneously. The original generator started a thread per request and delivered 13 requests a second when asked for 60. Rewriting it in asyncio — coroutines holding keep-alive connections, working in concurrency rather than request rate — raised the ceiling and still fell short.
Resolution. Stop trying to fill the pool and shrink it instead: reserve 900 to a neighbour, and AWS leaves exactly 100 unreserved for the others to contend over.
Lesson. This is closer to production than the original design was. Nobody's unreserved pool is 1,000 — it is 1,000 minus every reservation anyone has made, including idle ones.

A Terraform apply that was legal at the end and illegal in the middle

Problem. Setting checkout's reservation to 50 while lowering a neighbour's from 900 to 850 failed: InvalidParameterValueException: Specified ReservedConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of [100].
Root cause. Terraform applied checkout's increase before the neighbour's decrease. The requested end state totals 900 reserved and is perfectly legal; the intermediate state asked for 950 and breached the account floor.
Resolution. Two applies — lower first, then raise.
Lesson. Reserved concurrency on two functions looks like two independent attributes and is really one shared account-level budget. Terraform orders by dependency, and these resources have none, so it cannot know. Any quota expressed as a sum across resources has this property.

Cleanup and Key Takeaways

Destroyed from the HCP workspace: 35 resources removed in one run. Removal was then verified by asking AWS rather than by reading the destroy output, because a destroy reporting success is a claim and an empty list is evidence — zero functions, zero APIs, zero tables, zero alarms, zero dashboards.

A terminal querying AWS after teardown: zero functions, APIs, tables, alarms and dashboards, and the unreserved pool back at 1,000.
The last line is the one worth reading: reservations are released with the functions that held them, so the pool returns to the full 1,000 on its own.

Nothing here billed while idle: the whole $0.81 came from the two four-minute runs.

Key takeaways

  • Concurrency is an account-and-Region number, not a function property. A function with no reservation can be denied service by any other function in the account, including one another team deployed.
  • Your unreserved pool is the quota minus everyone else's reservations. Idle reservations still take capacity, and AWS refuses to let you reserve the last 100.
  • A reservation is a floor and a ceiling in one setting. It guarantees capacity to the function and forbids it from borrowing more. Protecting checkout with 50 also caps checkout at 50.
  • Throttling protects the pool, not the bill. 773,000 refused requests still cost full price at API Gateway while Lambda charged nothing. Rate limiting belongs at the front door too.
  • Choose a metric your own test harness cannot fake. The most convincing graph this lab produced was a measurement of my laptop running out of sockets.

References

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent