Serverless in Production The demo is one Lambda. Production is a distributed system with a metered bill. API Gateway the front door Lambda the compute SQS / EventBridge the seams DynamoDB / S3 the state The part nobody demos: partial failure · retries without idempotency · poison messages · lost ordering cold starts · connection exhaustion · the CloudWatch bill you didn't budget for Every arrow above is a boundary where "exactly once" quietly becomes "at least once."

Someone on your team shipped a Lambda behind an API Gateway. It cost eleven cents last month, and now the team says "we're going serverless." Six months later you have forty functions. Three DLQs fill up quietly. The CloudWatch bill is bigger than the compute. The incident channel is full of the phrase "but it worked in the demo."

None of that is Lambda's fault. It's the tax on a truth nobody says out loud. Serverless doesn't remove the distributed system — it removes the servers you used to debug it on. Let's talk about what actually happens in production.

1. Serverless is a boundary, not a runtime

The mental model that gets people in trouble is "serverless = Lambda = a function." The useful model is different. Serverless is a collection of managed integration points, and Lambda is just the glue that runs your code between them. The interesting engineering is never inside a single handler — it's in the seams.

The pieces you actually compose with:

ServiceWhat it really isThe trap
API GatewaySynchronous HTTP front door — auth, throttling, request mappingPriced per request + per GB. REST costs 3.5× HTTP API. The 29s integration timeout
LambdaStateless compute, 15 min max, scales by concurrency not CPUConcurrency is an account-wide budget, not per-function
SQSBuffer + retry + backpressure between producers and consumersAt-least-once by default. A slow consumer silently redelivers
EventBridgeContent-routed pub/sub bus — fan-out by rule, not by topicAt-least-once, no ordering, ~24h retry then it's gone
Step FunctionsDurable state machine — the retries/timeouts/branching live in JSON, not your codeStandard bills per state transition. The cost grows fast on hot paths
DynamoDBThe only database that scales the way Lambda does (no connections)Access patterns are forever. A bad partition key means a rewrite
S3 eventsObject writes as a trigger sourceEvent delivery is at-least-once and occasionally minutes late

Notice the column that repeats: at-least-once. That word is the whole ballgame. Every managed integration in AWS will, under the right timing, hand your code the same event twice. Design around that and serverless is wonderful. Ignore it and you'll ship double-charged customers.

2. A reference architecture that isn't a toy

Here's an order-processing system — the kind of thing that's genuinely a good fit for serverless because traffic is spiky and the work is event-shaped. I'll use it as the running example for every failure mode below.

Order-processing reference architecture Short synchronous path. Everything flaky runs behind a queue Client POST /orders synchronous · must be fast API Gateway throttle · authorize Lambda create-order idempotent write DynamoDB · orders conditional put · PK=orderId publish OrderPlaced EventBridge one rule routes one event to N consumers SQS payment λ charge-card SQS inventory λ reserve SQS notify λ send-email rule: order.placed Step Functions · fulfillment saga: reserve → charge → ship compensate on failure repeated failure DLQ CloudWatch alarm on-call human

Read the shape, not the boxes. The synchronous path (client → API GW → Lambda → DynamoDB) is deliberately short: validate, write once, publish an event, return 202. Everything expensive or flaky — charging a card, reserving stock, sending email — happens behind a queue, asynchronously. A failure there is a retry instead of a 500 in the user's face.

The one rule that makes this work: the synchronous handler does the smallest durable thing (a conditional write) and then hands off. If it tries to do the payment inline, you've built a distributed monolith with a 29-second fuse. API Gateway kills any integration that runs longer than 29 seconds, and your user watches a spinner die.

3. The failure modes nobody demos

Partial failure is the default, not the exception

In the diagram above, EventBridge fans one OrderPlaced event to four consumers. Payment succeeds. Inventory succeeds. Notifications succeed. Fulfillment's Lambda throws because a downstream API had a bad 30 seconds. Now you have an order that's paid for, decremented from stock, the customer got a "thanks!" email — and nothing is shipping.

There is no transaction across those four consumers. There cannot be. This is life beyond distributed transactions in its purest form. Each consumer is its own scope of atomicity, and the only tools you have are retries, idempotency, and compensation. If your design assumes all four either happen or none do, it's already wrong.

Retries mean idempotency isn't optional

Every async invocation in AWS retries. Async Lambda invokes retry twice on error (three attempts total) and then stop. SQS redelivers until the consumer deletes the message or the maxReceiveCount is hit. EventBridge retries for up to 24 hours. So the same create-order or charge-card handler will run twice on the same input. The only defense is making the operation idempotent.

Two patterns carry almost everything:

Conditional writes for the "create once" case — let DynamoDB reject the duplicate instead of trusting your code:

// create-order: safe to run twice with the same orderId
await ddb.putItem({
  TableName: "orders",
  Item: marshall({ orderId, status: "PLACED", amount, createdAt }),
  // fails with ConditionalCheckFailedException on a retry — that's success, not error
  ConditionExpression: "attribute_not_exists(orderId)",
}).catch((e) => {
  if (e.name !== "ConditionalCheckFailedException") throw e;
  // duplicate delivery — the order already exists, so we're done
});

An idempotency table for side effects you can't make naturally idempotent (charging a card, calling a partner API). Take an idempotency key from the message, record it before the side effect, and short-circuit if you've seen it:

// charge-card: the payment provider gets called AT MOST once per orderId
const key = `charge#${orderId}`;
try {
  await ddb.putItem({
    TableName: "idempotency",
    Item: marshall({ pk: key, status: "IN_PROGRESS", ttl: now + 86400 }),
    ConditionExpression: "attribute_not_exists(pk)",
  });
} catch (e) {
  if (e.name === "ConditionalCheckFailedException") return; // already charged (or charging)
  throw e;
}
const receipt = await paymentProvider.charge(orderId, amount); // the non-idempotent part
await ddb.updateItem({ /* set status = DONE, store receipt */ });
War story. A team ran a Stripe charge directly in an SQS consumer with no idempotency key. One afternoon a downstream timeout made the handler take longer than the queue's visibility timeout (30s). SQS assumed the consumer died and redelivered. Same message, second Lambda, second charge. Stripe charged about 400 customers twice before anyone noticed — because each individual invocation succeeded. Nothing errored. The metric that would have caught it (charges > orders) didn't exist yet. Eight lines of idempotency table fixed it. The refund and the apology email took a week.
The visibility-timeout rule: your SQS visibility timeout must be larger than your Lambda's maximum execution time. AWS recommends at least 6× the function timeout when you use the SQS→Lambda event source. Get this backwards and you don't get an error. You get silent duplicate processing, which is far worse.

Poison messages: the message that kills the batch

SQS delivers to Lambda in batches (up to 10 messages, or up to 10,000 with a batch window). Here's the trap in the naive setup: if one message in a batch of ten throws, the whole batch goes back on the queue. The nine good messages get reprocessed. If that one bad message carries a malformed body and always throws — a "poison" message — it retries forever. It drags its nine innocent neighbors through reprocessing every time, until it finally hits maxReceiveCount and the whole batch lands in the DLQ. You lose good messages to one bad one.

The fix is ReportBatchItemFailures — tell Lambda exactly which messages failed so it retries only those:

exports.handler = async (event) => {
  const batchItemFailures = [];
  for (const record of event.Records) {
    try {
      await process(JSON.parse(record.body));
    } catch (err) {
      // report ONLY this message as failed; the rest are deleted from the queue
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };
};
Enable this on every SQS→Lambda mapping. Set FunctionResponseTypes: ["ReportBatchItemFailures"] on the event source mapping. It's the single most important correctness setting in the whole SQS/Lambda integration, and it's off by default.

DLQs vs failure destinations — they are not the same thing

People say "DLQ" to mean "where failed stuff goes," but AWS has two different mechanisms and they capture different things:

  • SQS redrive to a DLQ — for the SQS→Lambda path. After maxReceiveCount receives, the message moves to the dead-letter queue. You get the original payload. This is what you want for queue consumers.
  • Lambda failure destinations — for async invokes (EventBridge, S3, direct async). On final failure, Lambda sends a rich record (request, response, error, stack) to a target — SQS, SNS, EventBridge, or another Lambda. You get the invocation context, not just the payload. Strictly better than the old "on-failure DLQ" for async, which only gave you the event.
The mistake that loses data: configuring a DLQ but never wiring an alarm to it. A DLQ with no CloudWatch alarm on ApproximateNumberOfMessagesVisible > 0 is a silent graveyard. I've walked into systems with 40,000 messages sitting in a DLQ that nobody opened in four months. Every one of those was a customer action that silently vanished. A DLQ is a fire alarm, not a trash can.

Ordering: you probably don't have it

Standard SQS and EventBridge make no ordering guarantee. If your OrderPlaced and OrderCancelled events can race, a naive consumer will happily cancel-then-place and leave an order live that the customer cancelled. Options, in order of cost:

  • Don't need order? Make handlers commutative — apply a version number or timestamp and ignore stale updates. Cheapest and most reliable.
  • Need per-entity order? SQS FIFO with MessageGroupId = orderId gives ordering within a group and dedup — but caps throughput and reduces consumer parallelism per group.
  • Need a strict sequence of steps? That's not a queue problem, it's a workflow. Use Step Functions.

4. Cold starts, honestly

Cold starts are the most over-discussed and least-measured topic in serverless. Here's the honest version.

A cold start is init + your code's module-load time, paid once when Lambda spins a new execution environment. Rough numbers on a warm-ish account:

RuntimeTypical cold startReality check
Node.js / Python (small deps)~100–400 msUsually fine. Users won't notice on a P50.
Node.js / Python (fat deps, big SDK)~500 ms–1.5 sYour node_modules is the problem, not Lambda.
Java / .NET (JVM/CLR spin-up)~1–6 sThis is where cold starts actually hurt.
Anything inside a VPC+ once-brutal, now ~sub-secondHyperplane ENIs fixed the old 10s VPC penalty.

When cold starts matter: low-traffic synchronous APIs, where each new concurrent request may hit a cold env. They also matter for spiky traffic that forces many simultaneous new environments, and for heavy runtimes. When they don't: steady-traffic functions, where envs stay warm, and anything async. Nobody is watching a spinner behind a queue, so a 2s cold start is invisible.

Your levers, cheapest first:

  • Trim the package. Import only what you use. const { DynamoDBClient } = require("@aws-sdk/client-dynamodb"), not the whole SDK. This alone often halves a Node cold start. Free.
  • SnapStart — Lambda snapshots an initialized environment and restores from it. Originally Java-only. Now also Python and .NET. Turns multi-second JVM starts into ~sub-second restores. Free to enable (you pay a small caching cost). Watch for uniqueness bugs: anything randomized at init (seeds, connection IDs) is frozen in the snapshot and restored identically across environments.
  • Provisioned concurrency — keep N environments warm and pre-initialized. Kills cold starts entirely for that N. But you pay for it 24/7 whether traffic comes or not. This knob quietly converts serverless back into "servers you rent by the hour." Use it for a known, latency-sensitive endpoint. Drive it with Application Auto Scaling on a schedule. Don't sprinkle it everywhere.
War story. A team put provisioned concurrency on 30 functions "to be safe." The cold-start problem was on exactly one — the login endpoint. The other 29 were async workers where nobody cared about a 300ms start. They paid for 29 always-on warm pools to solve a problem that existed on one synchronous function. Removing it everywhere except login cut the Lambda bill by 60% with zero latency impact.

5. Concurrency and scaling traps

Concurrency is an account-wide budget

The default account concurrency limit is 1,000 across all functions in a region. AWS caps burst too — you get an initial burst (500–3,000 depending on region) and then scale by 1,000 concurrent invocations per minute per function. This has a nasty consequence: one runaway function can starve every other function in the account.

War story. A batch job spiked to 900 concurrent Lambdas. It consumed the account's concurrency budget. The customer-facing API — a completely unrelated function — started throttling with 429 TooManyRequestsException because there were no concurrency slots left. The blast radius of a background job was the entire product. Fix: reserved concurrency on the critical functions (guarantees them a floor) and a concurrency cap on the batch job (prevents it eating the pool). Reserved concurrency is both a guarantee and a limit — use it as a bulkhead.

SQS batch size × concurrency is a multiplication you must do on purpose

The SQS→Lambda scaler will start consumers up to your concurrency limit, each pulling batches. If each of 1,000 concurrent Lambdas opens a database connection, you now have 1,000 connections hitting a database that maxes out at 100. The queue that you built to protect the database becomes the mechanism that DDoSes it — faster than any human traffic could.

Downstream connection exhaustion — why RDS Proxy exists

This is the single most common way serverless meets a relational database and loses. Lambda scales by process count. Every concurrent invocation is its own process with its own connection. Postgres and MySQL are the opposite of Lambda. Connections are expensive, each costs memory, and there's a hard ceiling (see why your connection pool is probably wrong). 1,000 Lambdas want 1,000 connections. Your db.r6g.large wants maybe 100.

RDS Proxy exists precisely for this impedance mismatch. It sits between Lambda and the database and maintains a warm pool. The proxy multiplexes thousands of short-lived Lambda connections onto a small number of real database connections. It also handles failover faster. If you put Lambda in front of RDS/Aurora at any real concurrency, you need it. Or you need DynamoDB, which has no connections at all and is the reason it's the "native" serverless database.

Lambda → a relational database, with and without RDS Proxy WITHOUT proxy 1000 Lambdas each opens its own connection 1000 conns connection storm Postgres max ~100 conns → OOM · refused connections WITH RDS Proxy 1000 Lambdas short-lived connections RDS Proxy pools · multiplexes ~20 real conns Postgres healthy

6. Observability: the actually-hard part

Here's what nobody tells you: the hardest thing about serverless isn't building it, it's debugging it at 2 a.m. A request flows client → API Gateway → Lambda → EventBridge → SQS → Lambda → Step Functions. AWS scatters a single logical "order failed" across seven CloudWatch log groups, and no thread connects them. There's no server to SSH into. There's no single stack trace. There's just log soup.

The three things that make it survivable:

1. A correlation ID on everything. Generate one at the edge (or accept the API Gateway request ID), then thread it through every event payload, every log line, every message attribute. Without it, correlating logs across services is manual archaeology.

// structured logging — JSON, not console.log("thing happened")
const log = (level, msg, extra = {}) =>
  console.log(JSON.stringify({
    level, msg, correlationId, orderId,
    requestId: context.awsRequestId,
    ...extra,
  }));

log("info", "charge.started", { amount });
// → CloudWatch Logs Insights: filter @message like /correlationId":"abc-123/
//   now you can follow ONE order across every function it touched

2. Structured JSON logs. CloudWatch Logs Insights can query JSON fields. It cannot usefully query console.log("failed for user " + id). Log objects, not sentences. This is the difference between a 30-second query and a 40-minute grep.

3. Distributed tracing. X-Ray (or OpenTelemetry via the ADOT layer) draws the actual service map and shows you where the latency and errors live across the hops. Turn it on before you need it. You can't trace a request that already failed. It is the only tool that turns "the order is slow" into "the fulfillment Step Function waits 4 seconds on the inventory Lambda's cold start."

The CloudWatch bill nobody budgets for. CloudWatch Logs charges for ingestion (~$0.50/GB) plus storage. A chatty Lambda at high volume logging full request/response bodies can generate a logs bill larger than the compute bill. I've seen a fleet where CloudWatch was 40% of the total AWS spend. Debug-log locally. Log events not payloads in prod. Set log retention, because the default is "never expire," which bills storage forever. And consider sampling high-volume debug logs.

7. The cost math — and where it flips

The serverless pitch is "pay only for what you use, zero when idle." That's true, and it's genuinely unbeatable at low and spiky volume. The lie of omission is that per-request pricing has a crossover point where a steadily-loaded container gets cheaper — and it's lower than people expect.

The rough shape: Lambda bills per request + per GB-second of duration. A Fargate/ECS task bills per hour whether it's busy or not. So:

  • Spiky, low-average, bursty traffic → serverless wins, often by a lot. You pay nothing between spikes. A container sits idle and burns money.
  • High, steady, predictable traffic → containers win. Once a Fargate task is ~70%+ utilized around the clock, its per-request cost drops below Lambda's. The crossover is often somewhere in the millions-of-requests-per-day, steady range. Measure it for your duration and memory. A 2 GB / 800 ms Lambda flips much sooner than a 128 MB / 50 ms one.
API Gateway pricing shock. The compute is often not what gets you — the front door is. REST API Gateway costs ~$3.50 per million requests. HTTP API costs ~$1.00 per million. At high volume the gateway can cost more than all the Lambdas behind it. Two fixes people discover too late. (1) Use HTTP API instead of REST unless you specifically need REST-only features (usage plans, API keys, WAF-in-gateway). (2) At very high volume, an ALB in front of Lambda is cheaper per request than API Gateway. You trade away the API features for a flat-ish LB cost.
The NAT Gateway trap. Put a Lambda in a VPC (to reach RDS, say) and have it also call the internet or an AWS API over the public endpoint. Traffic then routes through a NAT Gateway: ~$0.045/hour plus ~$0.045 per GB processed. That per-GB charge on every S3/DynamoDB/external call from thousands of invocations is a line item that surprises people every single time. Fix: use VPC endpoints so AWS-bound traffic never touches the NAT. Gateway endpoints for S3/DynamoDB are free, and Interface endpoints for other services avoid the NAT per-GB. Only route genuine internet egress through NAT.

8. Local development and testing — what actually works

The dream is "run the whole stack on my laptop." The reality is different. Heavy local emulation (LocalStack for everything, SAM local for complex event flows) drifts from real AWS behavior. It drifts in exactly the places that matter — IAM, throttling, eventual consistency, event timing. So it gives you false confidence. Here's what holds up in practice:

  • Test handlers as pure functions. Structure code so the AWS wiring is thin and the logic is a plain function you can unit-test with no cloud at all. Handler parses the event → calls doTheThing(input) → formats the response. doTheThing is 90% of your logic and needs zero emulation. This is the highest-leverage testing decision you'll make.
  • Integration-test against real AWS. Every developer (and CI) gets an ephemeral stack in a real account. Deploy is fast. The behavior is actually AWS — real IAM denials, real eventual consistency, real throttles. This catches the class of bug emulators cannot.
  • Use emulators narrowly. LocalStack is genuinely fine for a single DynamoDB table in a fast unit test. It is a trap as a substitute for an integration environment.
The uncomfortable truth: in serverless, "works on my machine" is nearly meaningless. Your machine isn't running IAM, isn't enforcing concurrency limits, and isn't redelivering messages. The feedback loop that matters is deploy-to-a-real-account. Your job is to make that loop fast (small stacks, fast deploys) rather than to recreate AWS locally.

9. When NOT to go serverless

The honest part. Serverless is a fantastic default for event-driven, spiky, glue-heavy workloads. It's the wrong tool when:

  • Steady high throughput. If you run flat-out 24/7, you pay serverless's per-request premium for elasticity you never use. A right-sized Fargate/ECS or EKS deployment is cheaper and simpler. (See the crossover in §7.)
  • Long-running or compute-heavy jobs. Lambda caps at 15 minutes. Video transcoding, big ML inference, long ETL — that's Fargate, Batch, or EC2, not Lambda. Shoehorning it in leads to grotesque Step Function fan-out just to dodge a timeout.
  • Sustained low latency with zero tolerance for cold starts. If a P99.9 cold start is unacceptable and provisioned concurrency for your whole surface would cost more than just running warm containers, run the containers.
  • Chatty inter-service calls / heavy connection reuse. Your workload may be dozens of calls to the same database, or a persistent connection (WebSockets with server-side state, gRPC streaming). A stateful container that keeps warm connections is a better fit than 1,000 stateless processes fighting over RDS Proxy.
  • You want portability. Deep serverless is deep AWS lock-in — EventBridge rules, Step Functions ASL, DynamoDB access patterns don't port. A container does. Sometimes that trade is worth it. Make it on purpose.
The pragmatic answer is usually "both." Serverless for the spiky, event-driven edges (ingestion, webhooks, fan-out, glue, cron). Containers for the steady, stateful, long-running core. The teams that get burned are the ones who picked a religion instead of a fit.

The takeaway

After enough production time, the serverless lessons compress into a few opinions I'd defend:

  • Serverless doesn't remove complexity, it relocates it. The complexity moves out of your servers and into the seams between managed services. The seams are harder to see, because there's no box to log into. You traded ops toil for distributed-systems reasoning. That's often a good trade. It is never a free one.
  • Idempotency is the price of admission, not an optimization. Everything retries. If you can't run a handler twice safely, you don't have a serverless system, you have a time bomb with good uptime.
  • Every DLQ needs an alarm, every async path needs a failure destination, and every SQS mapping needs ReportBatchItemFailures. These three settings are the difference between "resilient" and "silently losing data."
  • Observability is the real cost, in dollars and in effort. Budget for correlation IDs and structured logs on day one, and budget for the CloudWatch bill — both will surprise you if you don't.
  • Do the cost math with your own numbers. "Serverless is cheaper" and "serverless is more expensive" are both true, on different sides of a crossover point. That point depends on your traffic shape, your memory, and your duration. Measure it. Don't argue it.
  • Pick serverless for the shape of the work, not the hype. Spiky and event-driven? Reach for it. Steady and heavy? Reach for a container. The senior move is knowing you're allowed to use both in the same system — and usually should.

Serverless in production is not "no servers." It's "someone else's servers, your distributed system, and a bill that tells the truth about your architecture." Design for the failures nobody demos, and it's one of the best tools AWS has ever shipped.