Notes · Learn AWS · CHAPTER 11

Serverless patterns

The whole-stack synthesis chapter. Everything from chapters 2-9 (IAM, VPC, Lambda, S3, DynamoDB, SQS, KMS, alarms) comes back together as a working serverless system: API Gateway in front, Lambda in the middle, DynamoDB + EventBridge behind, Step Functions tying it all up. By the end you'll know when to reach for Lambda vs Step Functions, and which of the four API Gateway flavors to pick.

Azure Functions, APIM, Logic Apps, Durable Functions and Cosmos-triggered Functions all roughly translate to AWS - but not 1-to-1. AWS splits the same conceptual surface across more pieces (Lambda, two flavors of API Gateway, Step Functions Standard vs Express, DynamoDB Streams, EventBridge, SQS event source mapping) and asks you to wire them yourself. That extra wiring is the cost; the benefit is that each piece scales independently and you pay only for what fires.

In this chapter
  1. The cheat table
  2. Lambda deep-dive (beyond chapter 4)
  3. API Gateway: REST vs HTTP vs WebSocket
  4. Step Functions: workflow as code
  5. DynamoDB Streams + Lambda triggers
  6. End-to-end pattern
  7. Anti-patterns to avoid
  8. Try it: list everything serverless
  9. Quick check (quiz)
  10. Gotchas for Azure devs
  11. Project Compass: expose the query API
  12. Recap & next

The cheat table: Azure serverless → AWS serverless

ConceptAzureAWS
Function-as-a-serviceAzure Functions (Consumption / Premium / Flex)AWS Lambda (on-demand / provisioned concurrency)
HTTP API in frontAPI Management (APIM) or Functions HTTP triggerAPI Gateway (HTTP API for most cases; REST API for advanced features)
Visual workflow / orchestrationLogic Apps (low-code) or Durable Functions (code)Step Functions Standard (1yr max, $25/M state transitions)
High-throughput workflowDurable Functions (no explicit Express tier)Step Functions Express (5 min max, billed per-ms)
DB change feed → functionCosmos DB change feed + Functions triggerDynamoDB Streams + Lambda event source mapping
Queue → functionService Bus / Storage Queue bindingSQS event source mapping (poll-based) or EventBridge Pipe
Event busEvent Grid topics + subscriptionsEventBridge (default bus + custom buses + Pipes)
Long-poll / streaming eventsEvent Hubs + Functions triggerKinesis Data Streams + Lambda
Scheduled triggerFunctions Timer trigger (NCRONTAB)EventBridge Scheduler (cron + rate; replaces CW Events Rules)
Container-image-as-functionFunctions custom image (Premium plan)Lambda container images (up to 10 GB)
Edge computeAzure Front Door rules + Functions on Static Web AppsLambda@Edge (CloudFront) or CloudFront Functions (lighter)
Serverless frameworkBicep / azd / PulumiSAM (Serverless Application Model), Serverless Framework, CDK, Terraform
Cold start mitigationPremium plan "always-ready" instancesProvisioned Concurrency (pre-warmed) or SnapStart (Java/Python)
The row to internalize: APIM → API Gateway, but with a fork. AWS has two production HTTP-front products that aren't priced the same: HTTP API ($1.00 per million) is the modern default, and REST API ($3.50 per million) is the older, feature-heavy one. Choosing wrong is the most common cost mistake in AWS serverless. We'll dig into the trade-off in the API Gateway section.
What's in a name? - the serverless glossary
Lambda
Named after lambda calculus (Alonzo Church, 1930s), the foundational model of computation where everything is a function that takes input and returns output. The service launched at re:Invent 2014 and was the first true serverless compute offering anywhere - the entire FaaS category took its shape from this product.
API Gateway
Originally "Amazon API Gateway", launched July 2015 to expose Lambda functions over HTTP. The product was a single SKU at launch; HTTP API was added in 2019 as a cheaper, simpler alternative when AWS realized most customers were using ~5% of REST API's feature set.
Step Functions
Launched 2016. The "step" is the unit of work in a state machine - each state transition is billed (Standard) or each ms of execution is billed (Express). The visual designer in the console is genuinely useful, unlike most AWS console UIs.
DynamoDB Streams
A change-log feed for a DynamoDB table. Each item insert/update/delete produces a stream record, retained for 24 hours, partitioned by hash key. Predates Kinesis but uses the same shard model under the hood. Conceptually identical to Cosmos DB change feed.
EventBridge
The old name was "CloudWatch Events" (2016). Renamed to EventBridge in 2019 when AWS added partner sources (Zendesk, PagerDuty, etc.) and a schema registry. Same underlying service - same rules, same targets - just rebranded toward "event bus" framing.
SAM
Serverless Application Model. A CloudFormation transform that lets you describe a Lambda + API Gateway + DynamoDB stack in ~10 lines instead of ~200. sam local invoke runs the function in a Docker container; sam deploy wraps CloudFormation. Useful for quick local iteration; production teams typically migrate to CDK or Terraform.
Provisioned Concurrency
The AWS marketing term for "keep N copies of this function warm and ready". Launched 2019 to solve cold starts. Costs a flat hourly rate per pre-warmed copy even when idle - so it's a trade of compute cost for predictable latency.

Lambda deep-dive (beyond chapter 4)

ELI5: cold starts and concurrency
Imagine a coffee shop where the barista isn't paid until a customer orders. First customer of the morning waits a few seconds for the barista to put on her apron - that's a cold start. Once she's working, the next 100 customers get coffee fast (warm). If 200 customers arrive at once, the shop spins up extra baristas - up to the per-region limit. Provisioned concurrency = paying for baristas to be on the clock all day. Reserved concurrency = capping how many baristas can ever be on duty so other shops on the same street still have staff.

Chapter 4 covered Lambda's basic shape. Here we look at the four knobs that determine real-world cost and latency: cold starts, concurrency model, CPU architecture, and packaging.

Cold starts and the two concurrency knobs

Lambda invocation lifecycle Init download code ~100-500 ms Runtime init start interpreter ~50-300 ms Handler init your imports depends on code Invoke (1st) handler runs billed wall-clock Invoke 2, 3, 4 ... (warm) container reused init skipped COLD START (only first invoke) WARM PATH (next ~5-15 min) Idle container is retired after a few minutes of inactivity, then the next invoke pays init again. Provisioned Concurrency keeps N containers permanently past the "Handler init" stage.
A cold start is the four red/orange/yellow segments. The green is the only part you paid for in chapter 4's mental model - but the cold-start segments determine whether your API feels snappy or laggy.

On-demand concurrency

The default. Lambda scales out to whatever load arrives, up to your account's per-region concurrency limit (1,000 by default; raise via support). Each new concurrent invocation may pay a cold start.

Cost: pay only for actual ms billed.

Provisioned Concurrency

Pre-warmed copies. You declare "keep 10 copies always ready". Cold starts are eliminated for those 10 slots; the 11th concurrent invocation still pays one. Useful for latency-sensitive APIs.

Cost: hourly rate per provisioned copy plus the regular invoke ms.

Reserved Concurrency

A cap, not a floor. Sets the maximum concurrent executions for this function. Protects downstream systems (DynamoDB, RDS) from a runaway Lambda. Also subtracts from the account-wide pool.

Cost: free; pure limit.

ARM (Graviton2) vs x86

Lambda runs on either x86_64 or arm64. ARM (Graviton2) costs ~20% less per ms, often performs comparably or better, and is a one-line config change for most runtimes (Python, Node, Java, Ruby). If your code has no native x86 binaries, switch.

Terraform · x86 (default)
resource "aws_lambda_function" "api" {
  function_name = "reports-api"
  role          = aws_iam_role.lambda.arn
  handler       = "index.handler"
  runtime       = "python3.12"
  filename      = "build.zip"
  memory_size   = 512
  timeout       = 10

  # Default = x86_64
  # Costs: $0.0000166667 / GB-second
}
Terraform · arm64 (Graviton2)
resource "aws_lambda_function" "api" {
  function_name = "reports-api"
  role          = aws_iam_role.lambda.arn
  handler       = "index.handler"
  runtime       = "python3.12"
  filename      = "build.zip"
  memory_size   = 512
  timeout       = 10

  architectures = ["arm64"]   # ~20% cheaper
  # Costs: $0.0000133334 / GB-second
}
Fun fact Lambda functions running on ARM/Graviton2 are ~20% cheaper than x86 per GB-second and usually 10-30% faster for typical workloads. Despite this, AWS estimates the majority of Lambda compute is still on x86 - mostly because nobody bothered to change architectures = ["x86_64"]. If you have time to do exactly one cost-optimization across your serverless stack, this is the one with the highest ROI.

Container-image Lambdas

Until 2020 a Lambda deployment package was a ZIP, max 250 MB unzipped. Now you can also publish a Lambda as an OCI container image, up to 10 GB. The image has to use one of AWS's base images (which include the Lambda Runtime API client). Use this for ML inference, large dependency trees (think pandas + scikit), or when you want the same image to run in Lambda and ECS/EKS.

Dockerfile (container Lambda)
FROM public.ecr.aws/lambda/python:3.12

COPY requirements.txt ${LAMBDA_TASK_ROOT}/
RUN pip install -r requirements.txt

COPY app.py ${LAMBDA_TASK_ROOT}/

# Handler in <file>.<function> form
CMD [ "app.handler" ]

Lambda@Edge and CloudFront Functions

Two flavors of running code at the CDN edge:

CapabilityCloudFront FunctionsLambda@Edge
RuntimeJavaScript (cfjs runtime)Node.js or Python
Max exec time1 ms5 sec (viewer events) or 30 sec (origin events)
Memory2 MBup to 10 GB
Network callsNone - pure computeFull SDK, can call other AWS services
Cost$0.10 / million requests$0.60 / million + $0.00005 / GB-s
Use casesHeader rewrites, URL normalization, auth-cookie checksPersonalization, A/B routing, SSR, image resize

The takeaway: if your edge logic fits in 1 ms of pure JS, CloudFront Functions is 6x cheaper and lives even closer to the user. Reach for Lambda@Edge only when you need real compute or AWS SDK access at the edge.

API Gateway: REST vs HTTP vs WebSocket

ELI5: which API Gateway?
Imagine a restaurant chain with three door styles. The fancy door (REST API) has a doorman, a coat check, a guest book, and printed menus - costs more but handles every requirement. The new minimalist door (HTTP API) is just a door with a card reader - 3.5x cheaper, fast, covers 90% of real needs. The third style (WebSocket API) is a phone line that stays open both ways, for chat apps. Default to the minimalist door; upgrade only when you need a specific feature.

"API Gateway" is one product name with three SKUs. They're not feature-equivalent and they're not priced equivalent. Picking is the most consequential decision in any new serverless API.

Three flavors of API Gateway HTTP API (preferred) $1.00 / million requests + JWT authorizers built-in + CORS one-liner config + Lambda authorizer + AWS_IAM authorizer + ~60% lower latency - no API keys / usage plans - no WAF direct integration - no request validation - no caching Use for: 90% of modern APIs REST API (legacy / heavy) $3.50 / million requests + API keys + usage plans + Request/response transforms + Schema validation + Built-in caching ($1.50/M) + Direct WAF attach + Private endpoint (VPC) + Cognito user pools - 3.5x cost - 29 sec integration timeout Use for: legacy migrations, WebSocket API $1.00 / million msgs + $0.25/M connection-min + Persistent connections + Route by message body + Lambda backend per route + Up to 2 hr connection + Push from server-side via   PostToConnection API - No SSE / HTTP2-push - 128 KB max message size Use for: chat, live dashboards
For a brand-new API, start with HTTP API. Move to REST API only when you hit a specific blocker. Use WebSocket only when you need bidirectional persistent connections.

Authentication options

Authorizer typeHTTP APIREST APIUsed for
NoneyesyesPublic endpoints (you handle auth yourself)
JWT (built-in)yesnoVerify JWTs from any OIDC provider (Auth0, Okta, Cognito, Azure AD)
Cognito user poolvia JWTyesAWS-native user auth
Lambda authorizeryes (simple/IAM)yes (token/request)Custom logic - you write a Lambda that returns Allow/Deny
AWS_IAM (SigV4)yesyesInternal service-to-service, callers sign with IAM credentials
API keynoyesThrottling / metering per consumer (usage plans)
mTLSyesyesClient-certificate auth (B2B integrations)
Cost trap: the REST API default $3,000+ / year burnt

A team builds a new public API for a partner integration. They click through the API Gateway console - the first option is "REST API", so they pick it. The API ships, hits ~100M requests/month at steady state. JWT auth from the partner's Okta. No request validation, no caching, no API keys. After three months a FinOps review flags $350/month on API Gateway alone. Why is the bill so high, and what's the easy fix?

Click to reveal the trap
REST API costs $3.50 per million requests. HTTP API costs $1.00 per million. That's a 3.5x markup, paid forever, for features the team isn't using.

The math: 100M req/month × $3.50/M = $350/month = $4,200/year. The same workload on HTTP API: 100M × $1.00/M = $100/month = $1,200/year. Savings: $3,000/year for every 100M requests, just by picking the right SKU.

What the team gave up (and didn't use): API keys / usage plans (they use JWT instead), request schema validation (they validate in the Lambda), built-in caching (their Lambda is already fast enough), direct WAF integration (they put CloudFront in front instead), private endpoints (their API is public).

The reason teams pick REST API by default is that the console shows it first. The reason they should pick HTTP API is that the console's order doesn't reflect 2026's best practice. Fix: rebuild as HTTP API in a new stage, run both side-by-side, swap the DNS, decommission REST.

Caveat: if you genuinely need API keys (e.g., metered B2B billing) or request validation, REST API earns its 3.5x. Only ~10% of new APIs actually do.

Integration patterns

An API Gateway "integration" is the thing your route forwards to. The common ones:

IntegrationWhat happensWhen to use
Lambda proxyThe whole HTTP request is JSON-ified and handed to Lambda; Lambda returns a JSON shape that becomes the HTTP response.Default for ~all serverless APIs. Both flavors support it.
AWS service directAPI Gateway calls another AWS service (DynamoDB, S3, SQS) directly without a Lambda in between.Simple CRUD where Lambda would just be a pass-through. Skip the cold start.
HTTP proxyForwards to an arbitrary URL.Front an internal ALB / on-prem service through API Gateway for auth + logging.
VPC LinkCalls a private resource (ALB, NLB) inside your VPC.Expose an internal microservice publicly.
MOCKAPI Gateway returns a canned response, no backend.Health endpoints, stubs during development.
Bug hunt: my async Lambda's failures are vanishing

A teammate set up an async-invoked Lambda (e.g., triggered by S3 events). To debug failures they configured a Lambda destination = an SQS queue for the OnFailure case. They expected failed invocations' event payloads to land in that queue so they could replay them. Nothing arrives. The Lambda is failing (CloudWatch shows errors), but the SQS queue stays empty. Below is the function's execution-role policy and the destination config.

execution-role-policy.json (current state)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": "arn:aws:s3:::raw-events/*"
    }
  ]
}
terminal · function destination config
aws lambda put-function-event-invoke-config \
  --function-name process-event \
  --destination-config '{
    "OnFailure": {
      "Destination": "arn:aws:sqs:us-east-1:111111111111:event-dlq"
    }
  }'
Click to reveal the bug
The execution role is missing sqs:SendMessage on the failure-destination queue.

Lambda destinations are sent using the function's execution role. When the role doesn't have sqs:SendMessage on the target queue's ARN, Lambda silently drops the destination message - the invocation already failed, the user code already returned, so the "delivery to destination failed" is just a log entry buried in CloudWatch. No alarm, no retry, no exception bubbling up.

Fix: add this statement to the execution role:

{
  "Effect": "Allow",
  "Action": "sqs:SendMessage",
  "Resource": "arn:aws:sqs:us-east-1:111111111111:event-dlq"
}

Also: enable an alarm on the DestinationDeliveryFailures CloudWatch metric for every Lambda you configure destinations on. That's the canary you wish you'd had.

Step Functions: workflow as code

ELI5: Step Functions
Imagine you want one Lambda to call another which calls a third, with retries and a branch in the middle. You could hard-code that in Lambda #1 - but then Lambda #1 sits idle waiting (and paying) while #2 and #3 run. Step Functions is a separate service that holds the state and orchestrates the calls. Each Lambda runs and returns immediately; Step Functions remembers where it is, retries on failure, and waits as long as needed - up to a full year for the Standard tier. Same idea as Durable Functions in Azure.

A Step Functions state machine is a JSON document describing a workflow: states, transitions, parallel branches, error handling, retries. Each state typically invokes a Lambda (or any of 200+ AWS services). The Step Functions service holds the running state of every workflow execution.

Standard workflows

1-year max duration. Exactly-once execution. Full visual history of every state transition in the console. Billed per state transition: $25 / million transitions.

Use for: human approval flows, multi-day data pipelines, anything you need to audit step-by-step.

Express workflows

5-minute max duration. At-least-once execution. No visual replay history (logs go to CloudWatch). Billed per execution + per ms of compute - $1 / million at the small end.

Use for: high-throughput chains (10K+/sec), event-driven flows, API request handlers that need ordering.

Express (Sync)

Synchronous variant. Same Express engine but the caller waits for the result. Used as the "all-in-one" target for an API Gateway HTTP route: client calls API, API calls Step Functions sync, gets the result, returns it.

Use for: short orchestrations behind an API.

Fun fact Step Functions Standard workflows can wait for up to 1 year. The classic use is human-approval flows: "send Bob an email; wait up to 7 days for his approval; if he approves, deploy; if he times out, escalate to his manager." The state machine sits paused, costing nothing while waiting (only state transitions are billed). Durable Functions can do similar but Azure caps the waitable timer at 7 days by default - longer needs special handling.

State machine: a tiny example

Azure · Durable Functions (C# fan-out)
[FunctionName("Orchestrator")]
public static async Task Run(
  [OrchestrationTrigger] IDurableOrchestrationContext ctx)
{
  var input = ctx.GetInput<Order>();

  var tasks = new List<Task>();
  foreach (var item in input.Items)
  {
    tasks.Add(ctx.CallActivityAsync(
      "ProcessItem", item));
  }
  await Task.WhenAll(tasks);

  await ctx.CallActivityAsync(
    "SendConfirmation", input);
}
AWS · Step Functions (ASL JSON)
{
  "Comment": "Process each item, then confirm",
  "StartAt": "ProcessItems",
  "States": {
    "ProcessItems": {
      "Type": "Map",
      "ItemsPath": "$.items",
      "Iterator": {
        "StartAt": "ProcessOne",
        "States": {
          "ProcessOne": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:...:function:process",
            "End": true
          }
        }
      },
      "Next": "Confirm"
    },
    "Confirm": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:...:function:confirm",
      "End": true
    }
  }
}
The trade-off: Durable Functions express workflow in code (familiar to devs but hides the state machine). Step Functions express workflow in declarative JSON / YAML (verbose but every state is inspectable in the console and auditable in CloudTrail). For a single team owning everything, Durable is faster to write. For workflows that cross team boundaries or need compliance trails, Step Functions wins.

When to skip Step Functions

Step Functions costs money per transition. For a 3-step linear flow that runs 10M times/month, that's 30M transitions = $750/month on Standard. If the flow doesn't need waits, retries-with-backoff, or visual auditing, just chain the Lambdas yourself via async invoke or EventBridge - the orchestration is free. Use Step Functions when the orchestration is the value, not the glue.

DynamoDB Streams + Lambda triggers

ELI5: change-feed-driven Lambda
Every time someone writes to a DynamoDB table, a copy of the change goes into a tiny side-channel "stream" - like a tape recorder of every edit. You point a Lambda at that tape and AWS auto-reads new entries and calls your Lambda with them. Now any write to the table can trigger a downstream reaction: send email, update a cache, sync to ElasticSearch. Same as Cosmos DB change feed + Functions trigger.

A DynamoDB Stream captures every INSERT, MODIFY, and REMOVE on a table as an ordered, partitioned log. Records live 24 hours. The most common consumer is a Lambda via event source mapping: AWS polls the stream, batches records (default batch size 100), and invokes your Lambda once per batch.

Reactive pattern: any table change fans out via Lambda App writes PutItem / UpdateItem DynamoDB reports table Streams: NEW_AND_OLD Stream 24hr log, sharded Event source batches + polls Lambda: cache invalidate Lambda: index push to OpenSearch Lambda: notify send SNS / email
Multiple Lambdas can subscribe to the same stream via separate event source mappings. Each consumer maintains its own checkpoint, so they don't interfere.

The four "view types"

StreamViewTypeWhat's in each recordWhen to use
KEYS_ONLYJust the partition + sort key of the changed itemCache invalidation - you only need to know which item changed
NEW_IMAGEFull new state of the itemReplicating writes downstream (search index, cache populate)
OLD_IMAGEFull pre-update stateAudit logs - "what did this look like before?"
NEW_AND_OLD_IMAGESBoth before and afterDiff-based logic (e.g., "only fire if status changed")
main.tf · DynamoDB Streams + Lambda event source mapping
resource "aws_dynamodb_table" "reports" {
  name             = "gfn-reports"
  billing_mode     = "PAY_PER_REQUEST"
  hash_key         = "report_id"
  stream_enabled   = true
  stream_view_type = "NEW_AND_OLD_IMAGES"

  attribute {
    name = "report_id"
    type = "S"
  }
}

resource "aws_lambda_event_source_mapping" "reports_to_search" {
  event_source_arn  = aws_dynamodb_table.reports.stream_arn
  function_name     = aws_lambda_function.indexer.arn
  starting_position = "LATEST"
  batch_size        = 100
  maximum_batching_window_in_seconds = 5

  # Error handling - send poison records to a DLQ instead of stalling the shard
  destination_config {
    on_failure {
      destination_arn = aws_sqs_queue.indexer_dlq.arn
    }
  }
  maximum_retry_attempts = 3
}
The hidden hazard: Streams are ordered per partition. If your Lambda errors on a record and you haven't configured a retry+DLQ policy, the shard stops on that record until you manually fix or skip it. Always set maximum_retry_attempts + an on_failure destination - otherwise a single bad record halts the whole consumer.

End-to-end pattern: API GW → Lambda → DynamoDB + EventBridge → Lambda

Here's the canonical "modern serverless" shape, end to end. A client calls an HTTP API. A write Lambda persists to DynamoDB and emits an event to EventBridge. EventBridge routes the event to one or more downstream Lambdas (notifications, analytics, audit log). DynamoDB Streams gives you a second async fan-out path if you need ordered table-change processing.

Canonical modern serverless flow Client API GW HTTP API + JWT Lambda: write-report validates, persists DynamoDB reports table EventBridge "reports" bus Lambda: notify-slack match: status=published Lambda: refresh-cache match: any change Lambda: audit-log archive to S3 Step Functions multi-step workflow Lambda: search-index via Streams (ordered) HTTPS invoke PutItem PutEvents via DDB Streams
Two async fan-out paths: EventBridge (loose, rules-based, unordered) for notifications and audit; DynamoDB Streams (ordered, per-shard) for replicating writes to a search index. Choose by whether ordering matters.

The shape in Terraform (sketched)

main.tf · the wiring (excerpt)
# 1. The HTTP API itself
resource "aws_apigatewayv2_api" "reports" {
  name          = "reports-api"
  protocol_type = "HTTP"
}

# 2. Lambda integration
resource "aws_apigatewayv2_integration" "write" {
  api_id             = aws_apigatewayv2_api.reports.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.write_report.invoke_arn
  payload_format_version = "2.0"
}

# 3. Route
resource "aws_apigatewayv2_route" "write" {
  api_id    = aws_apigatewayv2_api.reports.id
  route_key = "POST /reports"
  target    = "integrations/${aws_apigatewayv2_integration.write.id}"
}

# 4. EventBridge custom bus
resource "aws_cloudwatch_event_bus" "reports" {
  name = "reports-bus"
}

# 5. Rule: route published-status events to notify-slack Lambda
resource "aws_cloudwatch_event_rule" "published" {
  name           = "report-published"
  event_bus_name = aws_cloudwatch_event_bus.reports.name
  event_pattern  = jsonencode({
    source      = ["reports.api"]
    detail-type = ["ReportPublished"]
  })
}

resource "aws_cloudwatch_event_target" "slack" {
  rule           = aws_cloudwatch_event_rule.published.name
  event_bus_name = aws_cloudwatch_event_bus.reports.name
  arn            = aws_lambda_function.notify_slack.arn
}

Sketched, not complete (the IAM roles, permissions, Lambda functions, and EventBridge-to-Lambda permissions are omitted here). Chapter 12 builds a similar stack as a complete Terraform module.

Anti-patterns to avoid

1. Lambda-calling-Lambda synchronously. Function A invokes function B via the SDK and waits for the response. Both are billed in parallel - you pay twice for the same wall-clock seconds. Worse, if B takes 10 seconds, A times out before B returns. Fix: use Step Functions (Express for short flows, Standard for long) or fire-and-forget via async InvocationType=Event. If A genuinely needs B's result, ask whether the two should be one function.
2. The monolithic Lambda. One Lambda handles GET, POST, PUT, DELETE for ten endpoints via internal routing. Now you can't tune memory per route, can't set per-route concurrency, can't observe metrics per route, and one bad import slows down every endpoint's cold start. Fix: one Lambda per route (or close to it). Cold-start cost scales with code size, so smaller deps = faster warmup.
3. Putting Lambda in a VPC when you don't need to. Before 2019, putting a Lambda in a VPC added 10+ seconds to every cold start (ENI attachment was per-invocation). AWS fixed this with Hyperplane ENIs - cold-start penalty is now small. But: a VPC-bound Lambda can't reach the public internet without a NAT Gateway ($32+/month), can't reach DynamoDB/S3 without VPC endpoints, and adds complexity. Only put a Lambda in a VPC when it must talk to private resources (RDS, ElastiCache, internal ALB).
4. Using REST API for a new public API. See the cost-trap box above. The default for any new HTTP-front-of-Lambda is HTTP API. Reach for REST API only when you've confirmed you need a feature HTTP API lacks (API keys / usage plans, request validation, built-in caching, direct WAF attach, private endpoints).
5. Chaining EventBridge → Lambda → EventBridge → Lambda for sequential steps. Each EventBridge hop costs $1/M events and adds ~50-200 ms latency. If the steps are sequential and short, just use Step Functions Express - cheaper, faster, observable end-to-end. EventBridge is for fan-out and decoupling, not for orchestration.
Real incident · 2018 The recursive Lambda that DDoS'd its own account $15K in 4 hours

A team set up an S3-events-trigger-Lambda pipeline for image processing: drop a JPG into raw-bucket, a Lambda reads it, generates thumbnails, writes them to the same bucket under a thumbs/ prefix. The S3 event notification was configured with no prefix filter. No reserved concurrency on the function. No alarms beyond basic error rates.

A user uploaded a batch of 2,000 photos. The Lambda fired 2,000 times in parallel and wrote 6,000 thumbnails (small, medium, large). Each thumbnail-write was itself an S3 event, which triggered the Lambda again - which wrote three more thumbnails of each thumbnail. By minute 5, concurrent executions hit the account-wide cap of 10,000. The DynamoDB metadata table (provisioned capacity, 100 WCU) was being slammed by every invocation; throttles cascaded. The downstream notification SNS topic hit its publish quota; failures cascaded back to Lambda retries, multiplying load further.

By the time pagers fired and the on-call deleted the event trigger, the bill for the 4-hour window had crossed $15,000 - Lambda compute, S3 PUT requests, DynamoDB throughput overage, CloudWatch Logs ingestion, and SNS publishes combined.

Lessons: (1) Set reserved concurrency on every Lambda - even a small cap (50) would have throttled the recursion to a slow drip. (2) Never let a Lambda's output land in a location that triggers the same Lambda. Use a different bucket / different prefix + S3 event filter. (3) Set a billing alarm at 2x normal daily spend so a runaway gets noticed in minutes, not hours. (4) AWS now offers "Recursive loop detection" for Lambda (auto-stops after 16 cycles), but it's opt-in and post-incident only.

Try it: inventory your account's serverless surface

Lab: list every Lambda, API, and state machine in your account $0

Goal: get a quick survey of what serverless plumbing already exists in the account you're connected to. Three list calls, all free, all read-only.

Step 1. List all Lambda functions and their architecture / memory / runtime.

terminal
aws lambda list-functions \
  --query 'Functions[].[FunctionName, Runtime, Architectures[0], MemorySize, Timeout]' \
  --output table

# Look for: x86_64 functions you could move to arm64,
# oversize MemorySize (most workloads are fine at 512 MB),
# Timeout=900 (the 15-min hard cap - usually means a Step Functions candidate)

Step 2. List API Gateway v2 (HTTP and WebSocket) APIs.

terminal
# HTTP and WebSocket APIs
aws apigatewayv2 get-apis \
  --query 'Items[].[Name, ProtocolType, ApiEndpoint]' \
  --output table

# REST APIs (different SDK namespace, different price tier)
aws apigateway get-rest-apis \
  --query 'items[].[name, id, createdDate]' \
  --output table

# Any REST APIs you find are 3.5x more expensive than HTTP API equivalents.
# For each one, ask: does it actually use a REST-API-only feature?

Step 3. List Step Functions state machines.

terminal
aws stepfunctions list-state-machines \
  --query 'stateMachines[].[name, type, creationDate]' \
  --output table

# For each one, check the type:
#   STANDARD = $25/M transitions, 1-year max
#   EXPRESS  = $1/M + per-ms compute, 5-min max
# If you find STANDARD machines that finish in seconds and run millions of times,
# EXPRESS is usually 10-100x cheaper.

What you learned: a 30-second sweep with three CLI calls usually surfaces at least one obvious optimization in any non-trivial account: x86 Lambda that could be ARM, REST API that should be HTTP, or Standard workflow that should be Express.

Quick check

Test yourself - 5 questions

Serverless economics rewards picking the right SKU. Make sure these stick.

1. You're building a new public HTTP API that needs JWT auth. Your traffic is 100M requests/month. What's the rough monthly API Gateway cost difference between REST API and HTTP API?

  • Roughly the same - both are around $100/month.
  • REST API is ~$350/month, HTTP API is ~$100/month - HTTP API is 3.5x cheaper.
  • HTTP API is more expensive because it bundles WAF.
  • REST API is free if usage is under 1B requests/month.
Show answer
Answer: b. REST API: $3.50/M = $350/month. HTTP API: $1.00/M = $100/month. That's a 3.5x markup paid forever for features (API keys, request validation, built-in caching) most new APIs don't use. Default to HTTP API.

2. What's Lambda's maximum payload size for synchronous vs asynchronous invocations?

  • 6 MB synchronous, 256 KB asynchronous.
  • 256 KB for both.
  • 10 MB for both.
  • 6 MB for both.
Show answer
Answer: a. Sync (RequestResponse) max request+response is 6 MB. Async (Event invocation, e.g., from S3 or EventBridge) max event payload is 256 KB. If you need to pass larger data, write it to S3 first and pass the S3 key in the event. The 256 KB limit on async is the one that bites most often - large EventBridge payloads silently fail to deliver.

3. You're building a workflow that runs ~10,000 times/second, each execution finishes in under 30 seconds, and you don't need per-execution audit replay. Which Step Functions tier?

  • Standard - it's the default and most reliable.
  • Express - high throughput, short duration, billed per ms.
  • Use Durable Functions - Step Functions doesn't scale to 10K/s.
  • Use EventBridge Pipes instead of Step Functions.
Show answer
Answer: b. Express workflows are designed for this: high throughput (100K+/sec), max 5-minute duration, billed per ms of compute + per execution. Standard at $25/M transitions would be ~$25K/month for a multi-state workflow at 10K/s. Express drops it to ~$1K/month for the same workload. Trade-off: no replay history, at-least-once instead of exactly-once.

4. What's the default per-region Lambda concurrency limit, and what happens at the limit?

  • 10,000 concurrent executions; further invokes are throttled with 429 errors.
  • 1,000 concurrent executions; further invokes are throttled. Raisable via support.
  • Unlimited; Lambda scales infinitely.
  • 100 concurrent executions; hard cap, not raisable.
Show answer
Answer: b. Default is 1,000 concurrent executions per region across all functions in an account. Once hit, sync invokes return TooManyRequestsException (429), async invokes go to the function's queue (up to 6 hours), event source mappings back off. Raise via AWS Support - production accounts often run with 10K-50K. Reserved concurrency on a function carves out a guaranteed slice from this pool; provisioned concurrency doesn't change the cap but pre-warms slots.

5. What's the default API Gateway throttling limit for a new account, per region?

  • 1,000 requests/second steady, 2,000 burst.
  • 10,000 requests/second steady, 5,000 burst.
  • 100 requests/second steady, 200 burst.
  • No default - unlimited until you set one.
Show answer
Answer: b. API Gateway defaults to 10,000 requests/second steady-state with a 5,000-request burst, per account per region, across all APIs. Beyond that you get 429 Too Many Requests. You can set per-stage, per-route, or per-API-key (REST API only) throttles below this account-wide cap, and request a raise via Support. The defaults are generous enough that most teams never need to think about it - but a viral launch can hit it.

Gotchas for Azure devs

1. Lambda has a hard 15-minute timeout. The maximum value for timeout is 900 seconds. If your workload routinely runs longer, Lambda is the wrong tool - use Step Functions to chain shorter Lambdas, or move to ECS Fargate / EC2. Azure Functions Premium / Consumption has the same kind of ceiling (10 min on Consumption) but Durable Functions can run indefinitely; the AWS equivalent for indefinite duration is Step Functions Standard (up to 1 year), not Lambda itself.
2. API Gateway REST has a 29-second integration timeout. Default is 29s, max is 29s (it can be raised slightly in special cases but the documented ceiling is 29s for REST API and 30s for HTTP API). If your Lambda runs longer, the client sees a 504 while the Lambda keeps executing and billing. Anything longer = async pattern (return 202, write to SQS/EventBridge, client polls or webhooks).
3. Recursive Lambda triggers are not blocked by default. A Lambda whose output triggers itself will recurse until you hit the concurrency cap. AWS added "recursive loop detection" in 2024 (stops after 16 cycles) but it's per-function-opt-in and arrives after 16 invocations have run. Always: use different bucket/prefix for outputs, set reserved concurrency, alarm on invocation-rate spikes.
4. Step Functions Express maxes out at 5 minutes. If an Express workflow exceeds 5 minutes, it fails - no warning, no partial state. Standard goes to 1 year. Watch for workflows on the edge: ones that started at 2 minutes and grow to 4 minutes as data scales are 30 days from a production outage.
5. Pre-2019, VPC-bound Lambdas had horrific cold starts. ENI attachment was per-invocation and could add 10+ seconds. Many "Lambda is slow" myths in older blog posts trace here. AWS rebuilt this with Hyperplane ENIs in 2019 - the penalty is now hundreds of ms at most. If you're working from a doc written before 2020 that says "avoid VPC Lambda", the advice is stale. Still worth avoiding when not needed, but for different (cost/complexity) reasons.
6. EventBridge custom buses are not free at scale. $1 per million events published to a custom bus. For high-throughput fan-out (10s of millions/day) consider SNS fan-out at $0.50/M or SQS with multiple consumers. EventBridge wins on filtering richness and partner sources; SNS wins on raw cost.

Project Compass: expose the query API

Picking up from chapter 10 where the worker moved to EKS via IRSA. The reports table already exists (chapter 6). Now we expose a query API: GET /reports/{report_id} backed by a small Lambda that reads from DynamoDB. We deliberately pick HTTP API over REST API (saving 3.5x cost), and use AWS_IAM authorization since the API is internal-only - callers from inside NVIDIA sign requests with their IAM credentials.

Project Compass · Step 11 of 12 HTTP API → Lambda → DynamoDB query path

Why now: the table has data, the role exists, IAM is sane. The missing piece is a thin read API for dashboards and other services to query against. Internal-only, IAM-authenticated, HTTP API tier (not REST).

Step A · The query Lambda (AWS CLI)
handler.py · the read Lambda (8 lines)
import json, os
import boto3

ddb = boto3.resource("dynamodb").Table(os.environ["TABLE"])

def handler(event, _context):
    report_id = event["pathParameters"]["report_id"]
    item = ddb.get_item(Key={"report_id": report_id}).get("Item")
    if not item:
        return {"statusCode": 404, "body": json.dumps({"error": "not found"})}
    return {"statusCode": 200, "body": json.dumps(item, default=str)}
terminal · create the Lambda
# Package and create
zip handler.zip handler.py

aws lambda create-function \
  --function-name gfn-reports-query \
  --runtime python3.12 \
  --architectures arm64 \
  --role arn:aws:iam::111111111111:role/gfn-reports-role \
  --handler handler.handler \
  --zip-file fileb://handler.zip \
  --environment 'Variables={TABLE=gfn-reports}' \
  --memory-size 256 \
  --timeout 5 \
  --profile compass
Step B · The HTTP API + route (AWS CLI)
terminal · create HTTP API + IAM-auth route
# 1. Create the HTTP API ($1/M, not $3.50/M)
API_ID=$(aws apigatewayv2 create-api \
  --name gfn-reports-api \
  --protocol-type HTTP \
  --target arn:aws:lambda:us-east-1:111111111111:function:gfn-reports-query \
  --query ApiId --output text --profile compass)

# 2. Update the auto-created route to require AWS_IAM
ROUTE_ID=$(aws apigatewayv2 get-routes --api-id $API_ID \
  --query 'Items[0].RouteId' --output text --profile compass)

aws apigatewayv2 update-route \
  --api-id $API_ID --route-id $ROUTE_ID \
  --route-key "GET /reports/{report_id}" \
  --authorization-type AWS_IAM \
  --profile compass

# 3. Allow API Gateway to invoke the Lambda
aws lambda add-permission \
  --function-name gfn-reports-query \
  --statement-id apigw-invoke \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:111111111111:$API_ID/*" \
  --profile compass

# 4. Note the endpoint URL
aws apigatewayv2 get-api --api-id $API_ID \
  --query ApiEndpoint --output text --profile compass
terminal · test it (SigV4-signed curl via awscurl)
# IAM-authed endpoints need SigV4 signing - curl alone won't work.
# Use awscurl (pip install awscurl) or the AWS SDK in any language.

awscurl --service execute-api \
  --region us-east-1 \
  --profile compass \
  "https://${API_ID}.execute-api.us-east-1.amazonaws.com/reports/r-12345"

# Expected: {"report_id": "r-12345", "status": "published", ...}
# Unauthed plain curl would get: {"message":"Missing Authentication Token"}
Step C · Same thing in Terraform (the canonical form)
api.tf · the whole thing in HCL
resource "aws_lambda_function" "query" {
  function_name    = "gfn-reports-query"
  role             = aws_iam_role.compass.arn
  handler          = "handler.handler"
  runtime          = "python3.12"
  architectures    = ["arm64"]
  filename         = "build/handler.zip"
  source_code_hash = filebase64sha256("build/handler.zip")
  memory_size      = 256
  timeout          = 5

  environment {
    variables = { TABLE = "gfn-reports" }
  }
}

resource "aws_apigatewayv2_api" "reports" {
  name          = "gfn-reports-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "query" {
  api_id                 = aws_apigatewayv2_api.reports.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.query.invoke_arn
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "get_report" {
  api_id             = aws_apigatewayv2_api.reports.id
  route_key          = "GET /reports/{report_id}"
  target             = "integrations/${aws_apigatewayv2_integration.query.id}"
  authorization_type = "AWS_IAM"   # internal only - callers sign with SigV4
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.reports.id
  name        = "$default"
  auto_deploy = true
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "apigw-invoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.query.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.reports.execution_arn}/*"
}

output "api_endpoint" {
  value = aws_apigatewayv2_api.reports.api_endpoint
}

Read the two side by side - same end state, but the Terraform version is the one you'd actually keep. Chapter 12 puts this whole stack (plus everything from chapters 2-10) into a versioned module.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Why HTTP API and AWS_IAM together? The API is internal: only other workloads inside the NVIDIA AWS org call it (the EKS-hosted worker, dashboards, batch jobs). All those callers already have IAM credentials. AWS_IAM authorization means every request must be signed with SigV4 - the API rejects anything else with 403 Forbidden. No tokens to issue, no JWKS to publish, no Cognito user pool to manage. If the API were public (third-party callers) we'd switch to JWT authorizer instead.

Recap & next

What stuck?

The mental model in one sentence

Azure serverless is a few products with many internal knobs. AWS serverless is many small products you compose yourself. The cost of the AWS shape is that you have to choose between HTTP-vs-REST, Standard-vs-Express, Streams-vs-EventBridge, etc. The benefit is that each piece is independently scalable and priced. Once you internalize the catalog, the composition becomes intuitive.

Common pitfalls so far

TrapFix
"My Lambda is slow on the first call"Cold start. Try ARM64 (smaller container, faster init), provisioned concurrency, or SnapStart for JVM.
"My API bill is bigger than I expected"Check if it's REST API. Move to HTTP API unless you need a REST-only feature.
"My event source mapping stalled"A poison record halted a stream shard. Set maximum_retry_attempts + on_failure destination.
"My destinations config silently drops failures"The execution role is missing sqs:SendMessage (or equivalent) on the destination ARN.
"My Lambda recursively triggers itself"Output bucket = input bucket, no prefix filter, no reserved concurrency. Fix all three.
NEXT CHAPTER
12. IaC & Multi-account
Reify the whole stack in Terraform. AWS Organizations vs Management Groups, SCPs vs Azure Policy, Control Tower, and how the gfn-reports system finally becomes a single terraform apply.