AWS Serverless

AWS Lambda Errors, Timeouts & Cold Starts: The Troubleshooting Playbook

At 02:14 the page says Lambda Errors > 0 and the customer says “the upload just disappeared.” You have three questions to answer before you can touch anything: which invocation model was this, which phase did it die in, and where did the event go. Get those wrong and you will “fix” a timeout that was really a throttle, add memory to a function that was starved of a NAT route, or redeploy a handler whose event was silently discarded twenty minutes ago because nobody attached a dead-letter queue. AWS Lambda fails in a small number of very specific ways, each with an exact error string, an exact metric, and an exact fix — and the entire skill of operating serverless at 3 a.m. is naming the class fast.

This is the reference you keep open during the incident. It treats Lambda as what it actually is — a lifecycle (Init → Invoke → Shutdown) wrapped in three invocation models (synchronous, asynchronous, poll-based), each with its own retry, ordering and failure-destination rules — and it walks every failure class that pages you: cold starts (what is really inside Init Duration, why a VPC function used to cost you ten seconds, and what provisioned concurrency and SnapStart do and don’t fix), timeouts (Task timed out after N seconds and its five root causes), and errors (unhandled exceptions vs Runtime.ImportModuleError, permission denials, 429 TooManyRequestsException, out-of-memory kills, and a full /tmp). Then the part that separates senior from junior: what happens after the failure — the two silent-data-loss traps (an async event that retried into the void with no DLQ, and a poison record that blocks an entire Kinesis shard while IteratorAge climbs into hours).

By the end you will read a REPORT log line like an instrument panel, know from a single metric whether you are looking at an Errors, Throttles or DeadLetterErrors problem, and carry a symptom → category → root cause → confirm → fix table you can run under pressure. Everything is shown in both aws CLI and Terraform, with real limits and error strings — never a hand-waved number. It maps directly to the Lambda troubleshooting and observability domains of DVA-C02, SOA-C02 and SAA-C03.

What problem this solves

Lambda hides the server, and with it every familiar diagnostic surface. There is no host to SSH into, no top to watch, no process list, no /var/log you can tail at will. When a function is slow, dropping events, or costing double, you diagnose it entirely from CloudWatch metrics, CloudWatch Logs, and X-Ray traces — and if you do not know which number means what, you are flying blind. The failure is rarely a clean crash with a stack trace at the top of your inbox; it is a p99 that quietly tripled after a deploy, a queue that stopped draining, a customer whose event never produced a side effect and left no obvious trace.

What breaks without this knowledge is trust and money, silently. A team ships a function behind API Gateway, never touches cold starts, and watches p99 latency spike to multiple seconds on every traffic lull — users blame “the app.” Another triggers a function directly off a high-volume source with no idempotency and no dead-letter queue; under load it throttles, async retries pile up, and events vanish after the second retry with nothing to show for it. A third puts a consumer on a Kinesis stream, hits one malformed record, and discovers six hours later that the record has been blocking the shard the whole time while IteratorAge climbed and every event behind it waited. None of these are exotic. They are the default behaviour of the platform when you do not configure against it.

Who hits this: anyone running serverless past “hello world.” It bites hardest on teams new to Lambda’s at-least-once delivery and Lambda-owned retries for async invokes, on high-throughput stream and queue consumers where batching and partial-batch responses are load-bearing, on latency-sensitive synchronous APIs where cold starts land in the user’s p99, and on anyone who assumed a failed invocation would “obviously” show up somewhere. The fix is almost never “add more memory and hope.” It is: name the invocation model, name the lifecycle phase, read the one metric that confirms the class, and apply the one setting that addresses it.

Here is the whole failure field on one screen — the class, the model where it bites hardest, the exact signal, and the one-line fix — so you can orient before the deep dive:

Failure class Bites hardest on The signal you’ll see The one-line fix
Cold start latency Sync (API GW) High Init Duration, p99 spike after idle Provisioned concurrency / SnapStart, smaller package, lazy init
VPC cold start Any VPC function Slow init only inside a VPC; ENILimitReached Spare subnet IPs, reuse SG/subnet, VPC endpoints
Timeout Sync + async Task timed out after N seconds Match timeout to work; fix downstream/NAT/DNS
Import / init error All Runtime.ImportModuleError, Runtime.HandlerNotFound Fix packaging + handler string
Permission error All AccessDeniedException, KMSAccessDeniedException Execution-role policy + trigger resource policy
Throttle Sync + poll 429 TooManyRequestsException, Throttles > 0 Raise/allocate concurrency; back-pressure
Out of memory Memory-heavy Runtime exited ... signal: killed, Max Memory Used ≈ limit Raise memory (also raises CPU); fix leak
/tmp full Big-file jobs No space left on device (ENOSPC) Raise ephemeral storage; stream, don’t buffer
Async silent drop Async Errors > 0, no downstream effect Attach DLQ / on-failure destination; idempotency
Poison-record shard block Kinesis / DDB Streams IteratorAge climbing for hours ReportBatchItemFailures, bisect, max-age cap

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with the Lambda basics: a function has an execution role (the IAM role Lambda assumes for its own AWS calls), it logs to a CloudWatch Logs group named /aws/lambda/<function>, and it is invoked by a trigger — API Gateway, S3, SNS, EventBridge, SQS, Kinesis or a direct SDK Invoke. You should be able to run aws from a shell, read JSON output with --query, and read a Terraform aws provider resource block. HTTP status codes and the ideas of “retry” and “idempotency” should be familiar.

This sits in the Serverless & Event-Driven track and is the operational companion to the design-side references. The compute decision that puts you on Lambda in the first place lives in Compute on AWS: EC2 vs Lambda vs ECS vs EKS; the invocation-model and trigger contracts are covered in depth in AWS Lambda Patterns: Event-Driven Functions That Scale to Zero and the queue/bus wiring in Event-Driven Architecture with EventBridge, SQS & Lambda. The sizing levers this article treats as fixes — memory, timeout and concurrency — are tuned methodically in Lambda Memory, Timeout & Concurrency Tuning, and if you have never deployed a function, start with Your First Lambda Function, Hands-On. API Gateway as the most common synchronous trigger is compared in ALB vs NLB vs API Gateway, Compared.

Before the deep dive, fix the mental model of where each failure lives, so you look in the right place first:

Layer What lives here Failure classes it causes First place to look
Trigger / event source The producer + delivery contract Event never arrived; too-broad routing; wrong permission Source metrics; trigger resource policy
Concurrency gate Account + reserved limits, burst rate 429 TooManyRequestsException, Throttles Throttles, ConcurrentExecutions metrics
Init phase Package download, runtime, module code Cold-start latency, Runtime.ImportModuleError, VPC ENI Init Duration; X-Ray Initialization subsegment
Handler (your code + role) Business logic, permissions, downstream calls Timeout, OOM, unhandled exception, AccessDenied CloudWatch Logs + X-Ray subsegments
Outcome / destination Retry, DLQ, on-failure destination Silent async drop; poison-record shard block DeadLetterErrors, IteratorAge, DLQ depth
Observability Metrics, Logs, traces You can’t confirm anything CloudWatch Metrics + Logs Insights + X-Ray

Core concepts

The execution lifecycle — where cold starts and import errors live

Every Lambda invocation runs inside an execution environment: a micro-VM (Firecracker) that Lambda creates, reuses, freezes and eventually destroys. The environment moves through three phases, and knowing which phase you are in is half of every diagnosis, because cold-start latency and import errors happen in Init, not Invoke.

Phase What happens Billed? Failures that live here How you see it
Init Download package/image, bootstrap the runtime, run module-level (global) code — SDK clients, DB pools, imports Billed as Init Duration (on-demand it’s now billed; historically “free”) Cold-start latency, Runtime.ImportModuleError, Runtime.HandlerNotFound, VPC ENI attach, init timeout (10 s cap on-demand) Init Duration in the REPORT line; X-Ray Initialization subsegment
Invoke Your handler(event, context) runs, once per event, in a reused environment Billed as Duration (rounded to 1 ms) Timeout, OOM, unhandled exception, throttle, AccessDenied, /tmp full Duration, Errors, Throttles metrics; handler logs
Shutdown Environment is frozen after invoke; later destroyed; extensions get a shutdown event Not billed Lost background work started after the response, dropped buffered logs Extension logs; “work after response” bugs

The single most useful artifact in all of Lambda is the REPORT line every invocation writes to CloudWatch Logs. Learn to read it cold:

Field Meaning What a bad value tells you
Duration Handler wall-clock, ms Near your timeout → about to time out
Billed Duration Rounded-up billed ms What you actually pay per call
Memory Size Configured memory (MB) The ceiling for OOM and the CPU allocation
Max Memory Used Peak memory this invoke Memory Size → OOM risk; investigate
Init Duration Cold-start init time (present only on cold invokes) Its presence means this was a cold start
XRAY TraceId / SegmentId Trace correlation Jump straight to the trace
Status (newer runtimes) timeout / error on failure The class, spelled out

The three invocation models and their retry rules

“Lambda” behaves like three different services depending on how it was invoked. This table is the one you memorize — nearly every “where did my event go?” question is answered by reading the correct row.

Model Triggers Delivery On error, Lambda… Ordering Payload cap Concurrency signal
Synchronous API Gateway, ALB, SDK Invoke (RequestResponse), Function URL Caller waits for the response Returns the error to the caller; caller decides to retry Caller-defined 6 MB request/response 429 returned to caller
Asynchronous S3, SNS, EventBridge, SES, CloudWatch Events, SDK Invoke --invocation-type Event Fire-and-forget; Lambda queues it internally Retries twice (1 min then 2 min backoff), then drops unless a DLQ / on-failure destination is set Not guaranteed 256 KB Internal queue absorbs bursts; still throttles
Poll-based (ESM), queue SQS (standard / FIFO) Lambda polls and invokes with a batch Returns the failed messages to the queue (whole batch, or just the failures with partial-batch response) after visibility timeout FIFO per message group only 6 MB per batch payload maximumConcurrency on the ESM
Poll-based (ESM), stream Kinesis, DynamoDB Streams Lambda polls shards, invokes with a batch, checkpoints on success Retries the whole batch in order until success or record age-out — blocks the shard meanwhile Strict per shard 6 MB per batch parallelizationFactor per shard

Three consequences fall straight out of this table and cause most incidents: (1) an async failure with no destination is gone after two retries — the caller never knew; (2) a stream failure blocks everything behind it on that shard; (3) a synchronous 429 is the caller’s problem to retry, so throttling shows up as client-side errors, not in your function’s Errors.

Cold starts: what’s really inside Init Duration

A cold start is the latency of creating and initializing a new execution environment before your handler can run. It is not a bug; it is the price of scaling from zero. It matters intensely for a synchronous, user-facing API (it lands in the user’s p99) and barely at all for an async batch job (nobody is waiting). The first diagnostic skill is decomposing where the time goes.

Init sub-phase What it is Typical driver Lever that shrinks it
Package download / image pull Fetch your zip or container image into the environment Package size, container image size, layers Smaller package, fewer/lighter layers, optimized image, RUN_FROM_PACKAGE hygiene
Runtime bootstrap Start the language runtime (JVM, Python, Node, .NET CLR) JVM/.NET are heaviest; Node/Python lighter; Go/Rust lightest Choose a lighter runtime; SnapStart (Java/Python/.NET)
Your init code (module top level) Everything outside the handler: SDK clients, DB pools, config fetch, big imports Heavy SDK init, sync network calls at import, large dependency graph Lazy init (create on first use), trim imports, cache in /tmp or globals
VPC ENI attach Attach a network interface for a VPC function VPC config; historically ~10 s, now sub-second via Hyperplane Keep it — Hyperplane fixed it; watch subnet IP exhaustion

The drivers, ranked by how much they hurt

Driver Effect on cold start Confirm Fix
Large deployment package More to download + parse Package size in console / aws lambda get-function Trim deps, tree-shake, use layers judiciously, prune dev deps
Heavy runtime (JVM/.NET) Slow bootstrap Compare Init Duration across runtimes SnapStart; or Node/Python/Go for latency-critical paths
Sync work at module top level Blocks Init Long Init Duration, X-Ray Initialization subsegment Move to lazy init inside the handler
VPC (new subnet/SG combo) ENI creation on first cold start Slow only for VPC functions Reuse SG/subnet sets; keep subnets with free IPs
Provisioned concurrency = 0 Every scale-out invoke is cold ProvisionedConcurrencyUtilization absent Allocate PC for the steady baseline
Large container image Longer pull; more layers to cache Image size in ECR Multi-stage build, slim base, --platform match
Many dependencies imported eagerly Parse/compile cost Init Duration dominated by import Import lazily; drop unused libraries
Cold after idle Environments reaped after ~5–15 min idle Init Duration reappears after a lull Provisioned concurrency for latency SLAs

Runtimes differ by an order of magnitude, and knowing the ballpark tells you whether a cold start is normal for your stack or a signal that your init code is the problem. These are typical, un-tuned Init Duration ranges for a small function — your mileage varies with package size and module-level work:

Runtime Typical cold Init Duration Warm invoke overhead SnapStart? Note
Node.js / Python ~100–400 ms negligible Python: yes The latency-critical default; keep the package small
Go / Rust (custom provided.al2023) ~50–150 ms negligible n/a Lightest cold starts; compiled single binary
Java (JVM) ~1–6 s negligible once warm yes SnapStart cuts it to ~200–400 ms; the single biggest lever
.NET ~600 ms–3 s negligible yes SnapStart + ReadyToRun trims it substantially
Container image (any) +100 ms–1 s pull negligible matches base runtime First pull is slowest; optimize layers + base image

The fixes, and the honest trade-offs

There are four real levers and one myth. Provisioned concurrency pre-initializes a set number of environments and keeps them warm — cold starts on that baseline go to zero, but you pay an hourly rate whether or not they are used. SnapStart takes a snapshot of the initialized environment and restores it on invoke, cutting init dramatically for Java (Corretto 11+), and now Python and .NET — at no extra charge, but with caveats (uniqueness of snapshot state, e.g. seeded randomness, and a restore hook for anything that must not be snapshotted). Smaller package + lazy init is free and always worth doing. The myth is keep-warm pings: a CloudWatch scheduled event that invokes the function every few minutes keeps one environment warm and does nothing for concurrent scale-out — the moment two requests arrive at once, the second is cold anyway. Use provisioned concurrency, not ping hacks.

Lever What it fixes Cost Caveat / when to use
Provisioned concurrency Cold starts on a known baseline of concurrent envs Hourly per PC unit (billed even when idle) + tiny per-invoke Predictable latency SLAs; combine with Application Auto Scaling schedules
SnapStart Init cost for Java/Python/.NET No extra charge (small restore/cache cost) Snapshot uniqueness (randomness, connections); use beforeCheckpoint/afterRestore hooks
Smaller package / lighter runtime Download + bootstrap Free Always do it; biggest wins on Java/.NET
Lazy init Your module-level work Free Move SDK clients/DB pools to first-use; cache in globals
Keep-warm ping (myth) One env only — not scale-out Wasted invokes Don’t rely on it; use PC instead
# Allocate provisioned concurrency on an alias (must publish a version first)
aws lambda put-provisioned-concurrency-config \
  --function-name orders-api --qualifier live \
  --provisioned-concurrent-executions 20

# Enable SnapStart (Java/Python/.NET) — applies to published versions
aws lambda update-function-configuration \
  --function-name invoice-render \
  --snap-start ApplyOn=PublishedVersions
# Terraform: provisioned concurrency + SnapStart
resource "aws_lambda_provisioned_concurrency_config" "live" {
  function_name                     = aws_lambda_function.orders_api.function_name
  qualifier                         = aws_lambda_alias.live.name
  provisioned_concurrent_executions = 20
}

resource "aws_lambda_function" "invoice_render" {
  function_name = "invoice-render"
  # ...
  snap_start { apply_on = "PublishedVersions" }
}

Timeouts: Task timed out after N seconds

The most misread failure in Lambda. The string is unambiguous — Task timed out after 30.00 seconds — but the cause is one of five, and only one of them is “the timeout is too short.” Your configured timeout is a hard ceiling from 1 second to 900 seconds (15 minutes); the platform kills the invocation the instant it is hit and bills you for the full duration.

Root cause What’s actually happening Confirm Fix
Downstream slowness A DB/API call is slow or hanging X-Ray subsegment sits near the full duration on one call Add a per-call client timeout below the Lambda timeout; fix/scale the downstream
Missing await / callback Node handler returns before async work finishes, or never calls the callback; event loop kept alive Handler “succeeds” but side effect missing, or times out with idle CPU async/await correctly; context.callbackWaitsForEmptyEventLoop; return a promise
No route to the internet/AWS (VPC) VPC function with no NAT gateway / no VPC endpoint; the call just hangs Works outside a VPC, times out inside; no error, just the timeout Add a NAT gateway (internet) or VPC endpoints (AWS services); fix route table
DNS resolution stall enableDnsSupport off, or resolving a private zone with no resolver route Timeout on the first network call; getaddrinfo in logs Enable DNS support/hostnames; Route 53 Resolver rules
Genuinely too short The work legitimately needs more time than configured Duration ≈ Timeout consistently, no single slow subsegment Raise the timeout to match the work (up to 900 s); or split the job

The nastiest of these is the VPC-with-no-NAT timeout, because there is no error — the socket simply never connects and the function dies at the timeout with a clean-looking log. The tell is that the same code works when the function is not in a VPC. A Lambda in private subnets can reach S3 and DynamoDB via gateway VPC endpoints and other AWS services via interface endpoints, but the public internet (and public AWS service endpoints without a private route) requires a NAT gateway — Lambda ENIs never get a public IP.

Match the timeout to the caller’s patience, not just the work. Several integrations impose their own ceiling in front of Lambda’s 900 s:

Front-end integration Its timeout ceiling Consequence if exceeded
API Gateway (REST/HTTP) 29 s integration timeout by default (raisable above 29 s on REST APIs via a service-quota change; HTTP APIs cap at 30 s) Client gets 504/503 even though Lambda keeps running (and billing)
Application Load Balancer Target-group timeout (default 60 s, tunable) ALB returns 504; Lambda may still complete
SQS event source Queue visibility timeout should be ≥ 6 × function timeout Too-low visibility → message reappears and is processed twice
Step Functions (sync task) State machine TimeoutSeconds State fails; may retry per the Retry block
CloudFront (Lambda@Edge) 5 s (viewer) / 30 s (origin) — separate service Edge function is far stricter than regular Lambda
# Set a 30 s timeout and confirm the visibility-timeout rule for an SQS trigger
aws lambda update-function-configuration --function-name resize --timeout 30
aws sqs set-queue-attributes --queue-url "$Q_URL" \
  --attributes VisibilityTimeout=180   # >= 6 x 30 s

Errors: the exact string tells you the class

An error in Lambda is anything that makes the invocation fail: your code threw, the runtime couldn’t load your code, a permission was denied, the process ran out of memory, or you were throttled before you ever ran. The category is written in the error errorType / message — learn to read it. This is the reference table you scan first.

Error string / type Phase Meaning Root cause Fix
Runtime.ImportModuleError Init The runtime couldn’t import your handler’s module Missing dependency in the package, wrong file name, native binary built for the wrong arch Bundle the dep; match arm64/x86_64; verify the module path
Runtime.HandlerNotFound Init The handler string doesn’t point at a real function Typo in file.function; wrong export Fix the handler setting (app.handler), export the symbol
Runtime.UserCodeSyntaxError Init Your code doesn’t parse Syntax error shipped Lint/build before deploy
Unhandled exception (errorType = your type) Invoke Your handler threw and didn’t catch Logic bug, bad input, downstream error Catch, validate input, handle downstream failures
Runtime.UnhandledPromiseRejection Invoke A rejected promise wasn’t awaited/caught (Node) Missing await/.catch Await all promises; global rejection handler
[ERROR] Runtime exited ... signal: killed Invoke Process killed — almost always OOM Max Memory Used ≈ Memory Size Raise memory; fix leak; stream large data
Task timed out after N seconds Invoke Hit the configured timeout See the timeout table above Fix downstream/route; raise timeout
AccessDeniedException / AccessDenied Invoke An AWS call was denied Execution-role policy missing a permission Add the action/resource to the role
KMSAccessDeniedException Init/Invoke Can’t decrypt env vars or a KMS-encrypted resource Role lacks kms:Decrypt on the CMK; key policy Grant kms:Decrypt; fix key policy
429 TooManyRequestsException (Rate Exceeded) Gate Throttled before/while invoking Concurrency cap hit, or reserved concurrency too low Raise account/reserved concurrency; back-pressure
EC2ThrottledException / ENILimitReached Init VPC networking couldn’t provision Subnet IP exhaustion, ENI limits Free subnet IPs; spread subnets; reduce churn
ResourceConflictException Control Concurrent update / state conflict Two deploys/configs racing Serialize updates; retry with backoff
ProvisionedConcurrencyConfigNotFoundException Control Invoked a qualifier with no PC configured Alias/version mismatch Point at the right alias; configure PC
No space left on device (ENOSPC) Invoke /tmp is full Buffering large files in ephemeral storage Raise ephemeral storage (up to 10 GB); stream instead

Permission errors — two policies, not one

A denial almost always means you fixed the wrong policy. There are two independent controls, and they fail differently:

Permission control Governs Symptom when wrong Where to fix
Execution role (IAM role on the function) What the function’s code may call (S3, DynamoDB, KMS…) AccessDeniedException in the handler logs at runtime The role’s IAM policy
Resource-based policy (on the function) Who may invoke the function (S3, SNS, EventBridge, API GW) The trigger never fires; no invoke at all aws lambda add-permission / trigger config
KMS key policy / grant Decrypt env vars and encrypted resources KMSAccessDeniedException (often at Init) CMK key policy + role kms:Decrypt
VPC endpoint / SG Whether the network call is even allowed Timeout (no error) or connection refused Security group + endpoint policy
# Let S3 invoke the function (resource-based policy) — the invoke path
aws lambda add-permission --function-name thumbnailer \
  --statement-id s3invoke --action lambda:InvokeFunction \
  --principal s3.amazonaws.com --source-arn arn:aws:s3:::uploads-bucket

# Give the function's role permission to write to a table (execution role) — the runtime path
aws iam put-role-policy --role-name thumbnailer-role --policy-name ddb \
  --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["dynamodb:PutItem"],"Resource":"arn:aws:dynamodb:ap-south-1:111122223333:table/thumbs"}]}'

Throttling — the 429 family

Throttling is not an error in your code; it is the concurrency gate saying “not now.” Every function draws from your account concurrency limit (1,000 by default, raisable via a quota request). Each function can add 1,000 concurrent executions every 10 seconds until it reaches the account ceiling. Two levers shape this per function:

Concurrency control Range Effect Gotcha
Account concurrency 1,000 default (raisable) Ceiling for all functions combined One runaway function can starve the rest
Reserved concurrency 0 – (account − 100) Carves a guaranteed slice for one function and caps it Sets both floor and ceiling; 0 = kill switch (throttles all invokes)
Provisioned concurrency ≤ reserved (if set) Pre-warmed envs; no cold start Billed hourly; spillover beyond PC is on-demand (cold)
Unreserved pool ≥ 100 must remain What’s left for functions without a reservation Reserving too much starves everything else

Throttles show up differently per model: synchronous callers get 429 TooManyRequestsException and must retry; asynchronous invokes are retried internally (and eventually dropped without a destination); poll-based sources slow their poll and the backlog grows (IteratorAge for streams, queue depth for SQS). Confirm with the Throttles metric — if it is > 0, no amount of code fixing helps.

Out-of-memory and a full /tmp

Memory is configurable from 128 MB to 10,240 MB, and CPU scales with it (roughly one full vCPU at ~1,769 MB, up to ~6 vCPUs at 10 GB) — so “add memory” is often really “add CPU” and can make a function both faster and cheaper. An OOM kill looks like Runtime exited with error: signal: killed, and the confirming tell is Max Memory Used in the REPORT line sitting flush against Memory Size. Separately, ephemeral storage (/tmp) defaults to 512 MB and is configurable to 10,240 MB; filling it (buffering a large upload, extracting an archive) throws No space left on device.

Symptom Metric / log tell Root cause Fix
signal: killed, no stack trace Max Memory Used ≈ Memory Size Memory ceiling hit (leak or genuine need) Raise memory; fix leak; stream large payloads
Slow and OOM-adjacent High Duration + high memory CPU-starved at low memory Raise memory to raise CPU (test the sweet spot)
No space left on device Handler log, ENOSPC /tmp (512 MB default) full --ephemeral-storage 4096; stream to S3, don’t buffer
Rising memory across invokes Max Memory Used creeps over time Leak in reused environment (globals grow) Don’t accumulate in globals; recreate per invoke

Many “errors” are really a hard limit you crossed without realizing there was one. Keep this reference close — the moment a symptom matches a ceiling, you have your root cause:

Hard limit Value Error when exceeded Where it bites
Sync request + response payload 6 MB each Body size is too large / truncation API GW / Invoke returning big bodies
Async invoke payload 256 KB RequestEntityTooLargeException S3/SNS/EventBridge events; put large data in S3
Function timeout 900 s (15 min) max Task timed out Long jobs → Step Functions / Fargate
Memory 128 MB – 10,240 MB signal: killed (OOM) CPU scales with it (~1 vCPU @ 1,769 MB)
Ephemeral /tmp 512 MB – 10,240 MB No space left on device (ENOSPC) Buffering/extracting large files
Deployment package 50 MB zipped / 250 MB unzipped (zip); 10 GB (image) RequestEntityTooLargeException Big deps → container image
Environment variables 4 KB total InvalidParameterValueException Move bulk config to SSM/Secrets Manager
Account concurrency 1,000 default (raisable) 429 TooManyRequestsException Shared across all functions
/tmp + layers + code 250 MB unzipped combined Runtime.ImportModuleError (space) ≤5 layers; prune

Retry & failure destinations: where events go to die

This is the section that separates people who run Lambda from people who have merely deployed it. What happens after a failure depends entirely on the invocation model, and getting it wrong means silent data loss.

Model Automatic retries Where a permanent failure goes Config knobs
Synchronous None by Lambda Returned to caller; caller owns retry Caller-side (SDK retry, API GW)
Asynchronous 2 (≈1 min, then ≈2 min backoff) On-failure destination (SQS/SNS/EventBridge/Lambda) or legacy DLQ; else dropped MaximumRetryAttempts (0–2), MaximumEventAgeInSeconds (60–21,600)
SQS (poll) Message returns after visibility timeout; retried until maxReceiveCount The queue’s redrive DLQ ReportBatchItemFailures, maximumConcurrency, batchSize
Kinesis / DDB Streams (poll) Whole batch, in order, until success or age-out On-failure destination (discarded records’ metadata) bisectBatchOnFunctionError, maximumRetryAttempts, maximumRecordAgeInSeconds, ReportBatchItemFailures

Async: the silent-drop trap

An async invoke (S3 upload, SNS message, EventBridge rule) is owned by Lambda once accepted. If your handler errors, Lambda retries twice with backoff, then — if you configured nothing — discards the event with no record anywhere. The caller got a 202 Accepted long ago and moved on. This is the “the upload just disappeared” incident. The fix is to attach an on-failure destination (preferred; richer metadata than the legacy DLQ) so exhausted events land somewhere you can inspect and redrive.

Async setting Range / values Default When to change
MaximumRetryAttempts 0–2 2 Set 0 for non-retryable work; keep 2 for transient faults
MaximumEventAgeInSeconds 60–21,600 (6 h) 21,600 Lower it so stale events don’t fire hours late
On-failure destination SQS / SNS / EventBridge / Lambda none Always set one — this is your only record of a drop
On-success destination SQS / SNS / EventBridge / Lambda none Fan-out on success without coupling the handler
Legacy DLQ (DeadLetterConfig) SQS / SNS none Older accounts; prefer destinations (more context)

If you take one thing from this section: prefer an on-failure destination over the legacy DLQ. Here is exactly why:

Aspect On-failure destination Legacy DLQ (DeadLetterConfig)
Targets SQS, SNS, EventBridge, Lambda SQS, SNS only
Payload captured Original event + response/error context + invocation metadata Original event only
Success path too Yes — separate on-success destination No (failure only)
Applies to Async and stream/poll (discarded records) Async only
AWS guidance Preferred (richer, more flexible) Supported for backward compatibility
# Async: 1 retry max, 1-hour age cap, on-failure destination to an SQS DLQ
aws lambda put-function-event-invoke-config --function-name ingest \
  --maximum-retry-attempts 1 --maximum-event-age-in-seconds 3600 \
  --destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:ap-south-1:111122223333:ingest-dlq"}}'
resource "aws_lambda_function_event_invoke_config" "ingest" {
  function_name                = aws_lambda_function.ingest.function_name
  maximum_retry_attempts       = 1
  maximum_event_age_in_seconds = 3600
  destination_config {
    on_failure { destination = aws_sqs_queue.ingest_dlq.arn }
  }
}

Poll-based: the poison-record shard block

For Kinesis and DynamoDB Streams, ordering is sacred, so the poller retries the entire failing batch in order until it succeeds or the records age out. One record that always throws — a malformed payload, a bug on a specific shape — will therefore block its shard forever (up to the stream retention), and every record behind it waits. The telltale metric is IteratorAge climbing into minutes and then hours while throughput flatlines. The fix is a set of ESM knobs that let you drop or isolate the bad record:

ESM setting What it does Default Set it to…
FunctionResponseTypes = ReportBatchItemFailures Return only the failed records’ sequence numbers; the rest checkpoint off on — the single most important stream setting
BisectBatchOnFunctionError On batch error, split in half and retry — isolates the poison record false true for streams
MaximumRetryAttempts Cap retries per record (streams) −1 (infinite) a finite number (e.g. 5–10)
MaximumRecordAgeInSeconds Discard records older than this −1 (infinite) e.g. 3600–86400 so bad records expire
On-failure destination Where discarded records’ metadata goes none an SQS/SNS target you monitor
BatchSize Records per invoke 100 (stream) / 10 (SQS) tune for throughput vs blast radius
MaximumBatchingWindowInSeconds Wait to fill a batch 0 1–5 to batch small streams efficiently
ParallelizationFactor Concurrent batches per shard 1 up to 10 to parallelize a hot shard
TumblingWindowInSeconds Aggregation window state 0 for windowed aggregation

For SQS, the model is friendlier: a message that keeps failing is retried until maxReceiveCount and then moved to the queue’s redrive DLQ — it does not block the queue. But partial batch response still matters: without ReportBatchItemFailures, a single failure in a batch of 10 makes the whole batch reappear and reprocess, duplicating the 9 that succeeded.

// Report only the failed records so the rest checkpoint (Node handler return shape)
{ "batchItemFailures": [ { "itemIdentifier": "49590338..." } ] }
# Kinesis ESM hardened against poison records
resource "aws_lambda_event_source_mapping" "kinesis" {
  event_source_arn                   = aws_kinesis_stream.events.arn
  function_name                      = aws_lambda_function.consumer.arn
  starting_position                  = "LATEST"
  batch_size                         = 100
  maximum_batching_window_in_seconds = 2
  bisect_batch_on_function_error     = true
  maximum_retry_attempts             = 5
  maximum_record_age_in_seconds      = 3600
  parallelization_factor             = 2
  function_response_types            = ["ReportBatchItemFailures"]
  destination_config {
    on_failure { destination_arn = aws_sqs_queue.stream_dlq.arn }
  }
}

The metrics that confirm each class

You never fix a Lambda problem you haven’t confirmed on a metric first. These are the ones that matter, and — crucially — which class each one confirms:

Metric Namespace/where Confirms Read it as
Invocations Lambda Baseline traffic Denominator for error rate
Errors Lambda Handler/init failures Errors/Invocations = error rate; excludes throttles
Throttles Lambda Concurrency-gate rejections > 0 → raise/allocate concurrency, not code
Duration Lambda Slow handlers / near-timeout p99 near timeout → timeout risk
ConcurrentExecutions Lambda How close to the cap Near account/reserved limit → throttles imminent
UnreservedConcurrentExecutions Lambda Shared-pool pressure Low → reservations starving others
IteratorAge Lambda (stream ESM) Stuck/lagging shard Climbing → poison record or under-provisioned consumer
DeadLetterErrors Lambda (async) The safety net itself failing > 0 → DLQ misconfigured; you’re losing events and the record of them
DestinationDeliveryFailures Lambda (async) On-failure/success destination failing > 0 → destination permission/target broken
AsyncEventsReceived / AsyncEventAge / AsyncEventsDropped Lambda (async) Async queue health + drops AsyncEventsDropped > 0 → events lost (no/failed destination)
ProvisionedConcurrencyUtilization Lambda (PC) Are you using what you pay for ≈1 → too little PC (spillover cold); ≪1 → over-provisioned
ProvisionedConcurrencySpilloverInvocations Lambda (PC) Cold invokes past your PC > 0 → raise PC or accept cold spillover
ClaimedAccountConcurrency Lambda (account) Account-level headroom Near the limit → request a raise
# Is it errors or throttles? Pull both for the last hour.
aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name Throttles \
  --dimensions Name=FunctionName,Value=orders-api \
  --start-time "$(date -u -v-1H +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --period 300 --statistics Sum

# Is a shard stuck? Watch IteratorAge.
aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name IteratorAge \
  --dimensions Name=FunctionName,Value=stream-consumer \
  --start-time "$(date -u -v-3H +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --period 300 --statistics Maximum

Metrics tell you which class; X-Ray and structured logs tell you where. Turn on active tracing and every invocation gets a trace with an Initialization subsegment (your cold-start cost, visible), plus a subsegment per downstream AWS call and any custom subsegments you add — so a “timeout” resolves instantly into “the DynamoDB Query subsegment consumed 29 of the 30 seconds.” Emit structured JSON logs (the newer runtimes and Lambda’s JSON log format make level, requestId and timestamp first-class) so CloudWatch Logs Insights queries are filters, not regex archaeology.

Tool Turn on with Answers
X-Ray active tracing --tracing-config Mode=Active + role perms Where the time went (Init vs each downstream call)
X-Ray subsegments SDK instrumentation in code Which specific dependency call stalled
CloudWatch Logs Insights Query the log group Init Duration distribution, error-string frequency, Max Memory Used p99
Structured JSON logs JSON log format / logger filter level="ERROR", correlate by requestId
Lambda Insights Enable the extension Per-invoke CPU/mem/network beyond the REPORT line
aws lambda update-function-configuration --function-name orders-api \
  --tracing-config Mode=Active   # role needs xray:PutTraceSegments, PutTelemetryRecords

Keep this CloudWatch Logs Insights cookbook — each query turns a vague “it’s slow / it’s failing” into a confirmed class in one paste:

Goal Logs Insights query (paste into the function’s log group)
Cold-start frequency + cost filter @type="REPORT" | stats count(@initDuration) as coldStarts, avg(@initDuration), pct(@initDuration,99) by bin(5m)
Memory pressure (near OOM) filter @type="REPORT" | stats max(@maxMemoryUsed/1000000) as maxMB, avg(@memorySize/1000000) as limitMB by bin(5m)
Timeout hunting filter @message like /Task timed out/ | stats count() by bin(5m)
Top error types filter @message like /ERROR/ | parse @message "errorType\":\"*\"" as et | stats count() by et
Slowest invocations filter @type="REPORT" | sort @duration desc | limit 20 | display @requestId, @duration, @billedDuration
Import/init failures filter @message like /Runtime.ImportModuleError/ or @message like /HandlerNotFound/ | stats count() by bin(5m)

A fast triage decision table for the first 60 seconds of an incident:

If you see… It’s probably… Do this first
Throttles > 0, Errors flat Concurrency cap Check reserved/account concurrency; raise or reallocate
Duration ≈ timeout, one slow subsegment Downstream stall Add client-side timeout; fix/scale the dependency
Init Duration present + p99 spike Cold starts Provisioned concurrency / SnapStart / lazy init
IteratorAge climbing Poison record / slow consumer ReportBatchItemFailures + bisect + age cap
Errors > 0, no downstream effect, async Silent drop Attach on-failure destination; check AsyncEventsDropped
Max Memory Used ≈ Memory Size OOM Raise memory; fix leak
AccessDeniedException in logs Execution-role gap Add the action/resource to the role
Trigger never fires Resource-policy gap aws lambda add-permission for the principal

Architecture at a glance

The diagram traces the real path an invocation takes and pins each failure class to the exact hop where it bites. Read left to right: an invocation arrives on one of three models (synchronous, asynchronous, or a poll-based event-source mapping), runs the Init phase on a cold environment (package download, runtime bootstrap, your module code, and — for a VPC function — an ENI attach), then the handler runs under two hard ceilings (15 minutes, 10 GB). The outcome depends on the model: a success is logged; an async failure retries twice and then must land in a DLQ / on-failure destination or it is dropped; a poll failure retries the batch and, on a stream, a poison record blocks the shard. Every class is confirmed from CloudWatch and X-Ray — the sixth badge — before you change a line of code.

AWS Lambda failure map showing an invocation entering on one of three models — synchronous API Gateway invoke capped at 6 MB, asynchronous S3/SNS/EventBridge invoke capped at 256 KB, and a poll-based SQS/Kinesis event-source mapping — flowing into an Init/cold-start zone that runs package download, runtime bootstrap, module-level code and a Hyperplane VPC ENI attach billed as Init Duration, then into an execute zone bounded by a 15-minute and 10 GB ceiling where timeout, out-of-memory and 429 throttle deaths occur, then into an outcome zone that branches to logged success, an async two-retry path that lands in a DLQ or is silently dropped, and a poll batch-retry path where a poison record blocks the shard, all converging on a CloudWatch metrics-and-logs plus X-Ray observability zone, with six numbered badges marking cold start, VPC ENI cold start, timeout/OOM/throttle, async silent drop, poison-record shard block, and the confirm-from-telemetry step

Real-world scenario

FreshCart, a mid-size grocery-delivery startup on ap-south-1, ran three Lambda workloads: a synchronous checkout API behind API Gateway, an asynchronous order-confirmation function triggered by an EventBridge “OrderPlaced” event, and a Kinesis consumer projecting order events into an analytics table. Over one Diwali weekend all three broke in ways that, on the surface, looked like “Lambda is flaky.”

The checkout API started returning 504 to a slice of users during traffic surges. The team’s first instinct was “raise the timeout,” but Duration was healthy — the REPORT lines showed Init Duration of 4.2 seconds on a cold Java function, and the 504s correlated exactly with scale-out events where API Gateway’s 29-second ceiling was never the issue; the users were simply waiting on cold starts stacked behind a slow JVM init that fetched config synchronously at module load. They moved the config fetch to lazy init, enabled SnapStart on the Java function, and allocated provisioned concurrency of 30 on the live alias sized to the weekend baseline. Cold-start p99 fell from 4.2 s to under 400 ms.

The order-confirmation function was worse because it was silent. Customers reported missing confirmation emails, but the function showed only a modest Errors bump. The tell was AsyncEventsDropped > 0: under load the function occasionally threw on a downstream SES rate limit, Lambda retried twice, and — with no on-failure destination configured — dropped the event. Thousands of confirmations evaporated with no record. The fix was three lines of Terraform: an aws_lambda_function_event_invoke_config with maximum_retry_attempts = 2, maximum_event_age_in_seconds = 3600, and an on-failure destination to an SQS DLQ they could redrive once SES calmed down. They also made the handler idempotent on order ID so a redrive couldn’t double-send.

The Kinesis consumer flatlined for six hours on Saturday night. IteratorAge had climbed to over five hours; throughput was zero. A single order event with a null total field threw on every retry, and because the stream poller retries the whole batch in order, that one record had blocked its shard since 9 p.m. — every order behind it stuck. The permanent fix was ReportBatchItemFailures (so good records checkpoint), bisectBatchOnFunctionError = true (to isolate the poison record), maximum_retry_attempts = 5 and maximum_record_age_in_seconds = 3600 (so a bad record expires instead of blocking forever), and an on-failure destination capturing the dropped record for later inspection. IteratorAge drained to seconds within minutes of deploy. The lesson the team wrote in their runbook: name the invocation model first — the same “it broke” means three completely different fixes.

Advantages and disadvantages

Understanding Lambda’s failure model is not optional overhead; it is the difference between serverless being a superpower and being a liability. But the same properties that make it powerful create the specific traps above.

Advantages (when you operate it right) Disadvantages (the traps to manage)
Scales from zero to thousands with no capacity planning Cold starts land in the user’s p99 on sync paths
Pay only for execution (GB-second) Timeouts bill for the full duration even on failure
Built-in retries + DLQ/destinations for async Silent drop if you don’t configure a destination
Ordered, at-least-once stream processing One poison record blocks a whole shard
CPU scales with memory — one knob for speed OOM kills with no stack trace; /tmp is only 512 MB by default
Fine-grained IAM per function Two separate permission surfaces (role + resource policy) confuse denials
Rich metrics, X-Ray, structured logs Blind without them — no host to inspect

The advantages dominate for spiky, event-shaped, stateless work — image processing, webhooks, ETL steps, stream consumers, glue code. The disadvantages dominate when you treat Lambda like a long-running server: heavy synchronous request/response with strict latency SLAs (cold starts hurt), very long jobs (15-minute wall), or large in-memory datasets (memory and /tmp ceilings). The skill is knowing which regime you’re in and configuring the guardrails — destinations, partial-batch responses, provisioned concurrency — before the incident, not during it.

Hands-on lab

You will reproduce and fix three classic failures on a free-tier-friendly account in ap-south-1: a timeout (no route to the internet), an import error, and an SQS poison-message batch reprocess (partial-batch response). Everything here is free-tier eligible except a few pennies of CloudWatch/SQS; teardown is at the end. Run in CloudShell (Bash) where aws is pre-authenticated.

Step 0 — Variables and an execution role.

export AWS_DEFAULT_REGION=ap-south-1
ACCT=$(aws sts get-caller-identity --query Account --output text)
ROLE_ARN=$(aws iam create-role --role-name lambda-lab-role \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
  --query Role.Arn --output text)
aws iam attach-role-policy --role-name lambda-lab-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
sleep 10   # let the role propagate

Step 1 — Reproduce an import error. Ship a handler that imports a module that isn’t in the package.

mkdir -p fn && cat > fn/app.py <<'PY'
import requests            # NOT bundled -> ImportModuleError
def handler(event, context):
    return {"ok": True}
PY
( cd fn && zip -q ../fn.zip app.py )
aws lambda create-function --function-name lab-import \
  --runtime python3.12 --handler app.handler --role "$ROLE_ARN" \
  --zip-file fileb://fn.zip --timeout 10
aws lambda invoke --function-name lab-import /dev/stdout

Expected: an error payload whose errorType is Runtime.ImportModuleError and message Unable to import module 'app': No module named 'requests'. This is a cold-start / Init failure, not a handler bug.

Step 2 — Fix the import error. Remove the unused import (the honest fix) and redeploy.

cat > fn/app.py <<'PY'
def handler(event, context):
    return {"ok": True}
PY
( cd fn && zip -q ../fn.zip app.py )
aws lambda update-function-code --function-name lab-import --zip-file fileb://fn.zip
aws lambda invoke --function-name lab-import /dev/stdout   # -> {"ok": true}

Expected: {"ok": true} with no FunctionError. (Had you genuinely needed requests, the fix would be to bundle it — pip install -t fn requests before zipping, or a layer.)

Step 3 — Reproduce a timeout with no route to the internet. Deploy a function that calls a public URL, then put it in a VPC without a NAT route so the call hangs until the timeout.

cat > fn/app.py <<'PY'
import urllib.request
def handler(event, context):
    with urllib.request.urlopen("https://checkip.amazonaws.com", timeout=8) as r:
        return r.read().decode()
PY
( cd fn && zip -q ../fn.zip app.py )
aws lambda create-function --function-name lab-timeout \
  --runtime python3.12 --handler app.handler --role "$ROLE_ARN" \
  --zip-file fileb://fn.zip --timeout 10
aws lambda invoke --function-name lab-timeout /dev/stdout     # works: prints your egress IP

Now attach it to private subnets with no NAT (create two subnets in your default VPC’s route table that has no 0.0.0.0/0 -> nat), which forces the outbound call to hang:

VPC=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true --query 'Vpcs[0].VpcId' --output text)
SG=$(aws ec2 create-security-group --group-name lab-sg --description lab --vpc-id "$VPC" --query GroupId --output text)
SUBNETS=$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$VPC --query 'Subnets[0:2].SubnetId' --output text | tr '\t' ',')
aws lambda update-function-configuration --function-name lab-timeout \
  --vpc-config SubnetIds=$SUBNETS,SecurityGroupIds=$SG --timeout 10
aws iam attach-role-policy --role-name lambda-lab-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
sleep 20
aws lambda invoke --function-name lab-timeout /dev/stdout

Expected: the invoke hangs ~10 s and returns Task timed out after 10.00 seconds. Note there is no error — just the timeout. That is the fingerprint of a missing route.

Step 4 — Fix the timeout. The correct fix depends on the target: for a public URL you need a NAT gateway (not free); for AWS services you’d add VPC endpoints. For the lab, demonstrate the diagnosis and revert the function out of the VPC (the “it works outside a VPC” proof):

aws lambda update-function-configuration --function-name lab-timeout \
  --vpc-config SubnetIds=,SecurityGroupIds=
sleep 20
aws lambda invoke --function-name lab-timeout /dev/stdout     # egress IP again -> fixed

Step 5 — Reproduce an SQS poison-message batch reprocess, then fix it with partial-batch response. Create a queue + DLQ, wire an event-source mapping, and a handler that fails on one specific message.

DLQ=$(aws sqs create-queue --queue-name lab-dlq --query QueueUrl --output text)
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ" --attribute-names QueueArn --query Attributes.QueueArn --output text)
Q=$(aws sqs create-queue --queue-name lab-q \
  --attributes "{\"VisibilityTimeout\":\"90\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}" \
  --query QueueUrl --output text)
Q_ARN=$(aws sqs get-queue-attributes --queue-url "$Q" --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws iam attach-role-policy --role-name lambda-lab-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole

cat > fn/app.py <<'PY'
def handler(event, context):
    failures = []
    for r in event["Records"]:
        if "POISON" in r["body"]:
            failures.append({"itemIdentifier": r["messageId"]})   # report ONLY this one
    return {"batchItemFailures": failures}
PY
( cd fn && zip -q ../fn.zip app.py )
aws lambda create-function --function-name lab-sqs \
  --runtime python3.12 --handler app.handler --role "$ROLE_ARN" \
  --zip-file fileb://fn.zip --timeout 15
aws lambda create-event-source-mapping --function-name lab-sqs \
  --event-source-arn "$Q_ARN" --batch-size 10 \
  --function-response-types ReportBatchItemFailures

Send a batch: nine good, one poison.

for i in $(seq 1 9); do aws sqs send-message --queue-url "$Q" --message-body "good-$i" >/dev/null; done
aws sqs send-message --queue-url "$Q" --message-body "POISON" >/dev/null
sleep 30
aws sqs get-queue-attributes --queue-url "$DLQ" --attribute-names ApproximateNumberOfMessages

Expected: only the POISON message eventually lands in lab-dlq (after maxReceiveCount retries) — the nine good messages were checkpointed and not reprocessed, because batchItemFailures reported only the poison one. Had you returned nothing (or thrown), all ten would have reappeared and the nine good ones would have been processed repeatedly. Verify the DLQ shows ApproximateNumberOfMessages = 1.

Validation checklist. You reproduced three distinct classes and confirmed each from its own fingerprint:

Step Failure reproduced The fingerprint The fix you applied
1–2 Import error (Init) Runtime.ImportModuleError: No module named 'requests' Bundle the dep / remove the import
3–4 Timeout, no NAT route Task timed out after 10.00 seconds, no error, works outside VPC NAT gateway / VPC endpoints; fix route table
5 SQS poison batch reprocess 9 good messages reprocessed without partial-batch response ReportBatchItemFailures → only the poison message to the DLQ

Teardown (⚠️ do this — the SQS queues and Lambda are tiny but real).

aws lambda delete-function --function-name lab-import
aws lambda delete-function --function-name lab-timeout
ESM=$(aws lambda list-event-source-mappings --function-name lab-sqs --query 'EventSourceMappings[0].UUID' --output text)
aws lambda delete-event-source-mapping --uuid "$ESM"
aws lambda delete-function --function-name lab-sqs
aws sqs delete-queue --queue-url "$Q"; aws sqs delete-queue --queue-url "$DLQ"
aws ec2 delete-security-group --group-id "$SG" 2>/dev/null || true
for p in AWSLambdaBasicExecutionRole AWSLambdaVPCAccessExecutionRole AWSLambdaSQSQueueExecutionRole; do
  aws iam detach-role-policy --role-name lambda-lab-role --policy-arn arn:aws:iam::aws:policy/service-role/$p; done
aws iam delete-role --role-name lambda-lab-role

Cost note. Everything here is within the Lambda and SQS free tiers; the only real charge would be a NAT gateway if you created one in Step 4 (~₹3–4/hour + data) — the lab deliberately avoids it and demonstrates the diagnosis instead. Deleting the resources above stops any lingering cost.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First the scannable table you can read at 02:14, then the three nastiest failures in full. Every row is a real failure with the exact symptom, the class it belongs to, the root cause, the precise command or metric to confirm, and the fix.

# Symptom Category Root cause Confirm (exact cmd / metric) Fix
1 p99 latency spikes after traffic lulls or scale-out Cold start New environments initialized on demand REPORT shows Init Duration; Logs Insights stats avg(@initDuration) Provisioned concurrency / SnapStart; lazy init; smaller package
2 Only VPC functions are slow to init; ENILimitReached VPC cold start ENI creation / subnet IP exhaustion Function has VpcConfig; check subnet free IPs Spare IPs, reuse SG/subnet sets, VPC endpoints
3 Task timed out after N seconds, no other error Timeout Downstream stall or no route (NAT/DNS) X-Ray subsegment near full duration; works outside VPC Client-side timeout; NAT/VPC endpoints; fix DNS/route
4 Runtime.ImportModuleError on every invoke Import error Missing dep / wrong arch / bad module path Invoke returns the error; aws lambda invoke /dev/stdout Bundle the dep; match arm64/x86_64; fix handler path
5 Runtime.HandlerNotFound Handler config handler string doesn’t point at the function aws lambda get-function-configuration --query Handler Set file.function; export the symbol
6 Trigger never fires; zero Invocations Permission (invoke) Missing resource-based policy for the principal aws lambda get-policy lacks the source aws lambda add-permission for S3/SNS/EventBridge
7 AccessDeniedException inside the handler Permission (role) Execution role missing the action/resource The denied action is in the log line Add action+resource to the execution role policy
8 KMSAccessDeniedException, often at Init KMS Role can’t kms:Decrypt env vars/resource Error names the key ARN Grant kms:Decrypt; fix CMK key policy
9 429 TooManyRequestsException to callers Throttle Concurrency cap hit / reserved too low Throttles > 0; ConcurrentExecutions near cap Raise account/reserved concurrency; back-pressure
10 Function invoked but silently does nothing (reserved=0) Throttle (kill switch) Reserved concurrency set to 0 get-function-concurrency = 0 Remove/raise the reservation
11 Runtime exited ... signal: killed OOM Memory ceiling hit Max Memory Used ≈ Memory Size in REPORT Raise memory (also CPU); fix leak; stream data
12 No space left on device /tmp full Ephemeral storage (512 MB) exhausted Handler log ENOSPC --ephemeral-storage 4096; stream to S3
13 Async event “disappeared”, no downstream effect Async silent drop Retries exhausted, no destination AsyncEventsDropped > 0; Errors > 0 Attach on-failure destination/DLQ; idempotency
14 DeadLetterErrors > 0 Safety-net failure DLQ misconfigured / no permission Metric > 0; DLQ target/perm Fix DLQ ARN + role sqs:SendMessage/sns:Publish
15 Stream throughput flatlines; IteratorAge climbs for hours Poison-record shard block One record always fails; whole batch retried in order IteratorAge Maximum rising; same batch retried ReportBatchItemFailures + bisect + age/retry cap + destination
16 SQS messages processed multiple times Duplicate / partial-batch No ReportBatchItemFailures; or visibility < 6× timeout Duplicate side effects; maxReceiveCount climbing Partial-batch response; visibility ≥ 6× function timeout; idempotency
17 Downstream DB connection errors under load Scale-out stampede Thousands of concurrent envs each open a connection DB max-connections hit; ConcurrentExecutions high Reserved concurrency cap; RDS Proxy; connection reuse
18 Env vars empty; function behaves as if unconfigured KMS / config KMS decrypt failed at Init, or setting missing KMSAccessDeniedException; get-function-configuration Fix kms:Decrypt; set the variable
19 Background work never completes (Node) Lifecycle Work started after response; env frozen Side effect missing though handler “succeeded” await before returning; don’t fire-and-forget post-response
20 ResourceConflictException on deploy Control-plane race Two updates racing / update in progress The API error Serialize updates; retry with backoff

The three that cause the most damage, expanded:

A. The VPC cold-start / no-route timeout (the “it works on my machine, times out in prod” ghost). A function tests fine, then in production every call that touches the internet or a public AWS endpoint times out — Task timed out after N seconds with no error and no useful log. The cause is a VPC configuration with no NAT gateway (for internet) or no VPC endpoint (for AWS services): a Lambda ENI never gets a public IP, so the socket to a public address simply never connects and the invocation dies at the timeout. Confirm: the exact same code works when the function is not in a VPC, or when you target an AWS service that has a gateway endpoint (S3/DynamoDB) versus one that doesn’t. Check the route table associated with the function’s subnets for a 0.0.0.0/0 → nat-... route, and check for interface endpoints for the specific service. Fix: add a NAT gateway for internet egress, or — cheaper and more secure for AWS-only traffic — interface/gateway VPC endpoints for exactly the services you call; ensure enableDnsSupport/enableDnsHostnames are on. Historically this class also meant a slow ENI attach on cold start (~10 s), but Hyperplane pre-creates shared ENIs, so today the residual VPC init cost is sub-second — the timeout is now almost always the route, not the ENI.

B. The poison-record shard block (silent for hours). A Kinesis or DynamoDB Streams consumer stops making progress and nobody notices until IteratorAge is measured in hours. One record — a null field, an unexpected shape — throws on every retry, and because streams preserve order, Lambda retries the entire batch in order until it succeeds or the records age out, so that one record blocks its whole shard and every record behind it waits. Confirm: the IteratorAge metric (Maximum) climbs steadily; the logs show the same batch/sequence number retried repeatedly. Fix (the full set, not just one): enable ReportBatchItemFailures so good records checkpoint; set bisectBatchOnFunctionError = true to split the batch and isolate the offender; cap maximumRetryAttempts and maximumRecordAgeInSeconds so a bad record eventually expires instead of blocking forever; and attach an on-failure destination so the discarded record’s metadata is captured for inspection. Then make the handler defensive on the shapes you actually see. The parallelizationFactor can raise per-shard concurrency, but it does not cure a poison record — only the failure-isolation knobs do.

C. The async silent drop (data loss with a 202). An S3/SNS/EventBridge-triggered function throws intermittently under load; the producer got its 202 Accepted and moved on; Lambda retries twice and then discards the event because nothing was configured to catch it. Customers report missing side effects (no confirmation email, no thumbnail) while your dashboards look almost fine — a small Errors bump and, if you know to look, AsyncEventsDropped > 0. Confirm: AsyncEventsDropped and AsyncEventAge on the function; correlate the Errors bump with the missing side effects. Fix: attach an on-failure destination (SQS/SNS/EventBridge — richer than the legacy DLQ) so exhausted events are captured and redrivable; tune MaximumRetryAttempts and MaximumEventAgeInSeconds to your semantics; and make the handler idempotent so a redrive can’t double-apply. Watch DeadLetterErrors/DestinationDeliveryFailures too — a DLQ your role can’t write to is worse than none, because it hides the loss.

Best practices

Security notes

Cost & sizing

Lambda bills on GB-seconds (memory × duration) plus per-request plus extras (provisioned concurrency by the hour, ephemeral storage above 512 MB, and data transfer). The free tier is generous — 1M requests and 400,000 GB-seconds per month — but the failure classes above have direct cost consequences: a timeout bills for the full duration, an OOM-retry loop bills for every attempt, and provisioned concurrency bills whether used or not.

Cost driver How it’s billed Right-size by Rough figure
Compute GB-second (memory × billed ms) Power-tune memory; faster often = cheaper ~$0.0000166667 per GB-s (varies by region)
Requests Per invoke Batch where possible (SQS/Kinesis) ~$0.20 per 1M requests
Provisioned concurrency Per PC unit-hour + tiny per-invoke Size to the baseline, autoscale schedules for peaks Paid even when idle — don’t over-allocate
Ephemeral storage Above 512 MB, per GB-s Keep /tmp small; stream to S3 Only pay for what’s above the free 512 MB
Timeouts / retries Full duration, every attempt Client-side timeouts; cap retries A hung 15-min invoke bills 15 min
X-Ray / Logs Per trace / per GB ingested Sample traces; set log retention Logs with no retention grow forever

Sizing rules of thumb: for a sync API, prioritize latency — enough memory for CPU headroom plus provisioned concurrency for the baseline; the extra GB-second cost is usually dwarfed by the p99 win. For async/batch, prioritize throughput and cost — larger batches, right-sized memory, and no provisioned concurrency (nobody’s waiting). Set log retention on every function’s log group (default is never-expire) or CloudWatch Logs quietly becomes a bigger line item than the compute. In INR terms, a modest event-driven workload (a few million invocations, sub-second, 256–512 MB) typically runs in the low hundreds of rupees per month before free tier — the expensive mistakes are idle provisioned concurrency and unbounded log retention, not the invocations.

Interview & exam questions

Q1. What exactly is inside “Init Duration,” and why does it matter for a synchronous API? Init Duration is the cold-start work: downloading the package/image, bootstrapping the runtime, and running your module-level code (SDK clients, imports, pools) — plus a VPC ENI attach for VPC functions. It matters for sync APIs because it lands in the user’s p99 on every cold environment; async jobs don’t care. (DVA-C02)

Q2. An async-invoked function’s events are “disappearing.” Walk the diagnosis. Async invokes are Lambda-owned: on error it retries twice then drops the event unless a destination/DLQ is set. Confirm with AsyncEventsDropped > 0 and correlate the Errors bump with missing side effects. Fix: attach an on-failure destination, tune MaximumRetryAttempts/MaximumEventAgeInSeconds, and make the handler idempotent. (DVA-C02, SOA-C02)

Q3. A Kinesis consumer’s IteratorAge is climbing for hours with zero throughput. Cause and fix? A poison record that always throws; streams retry the whole batch in order, so it blocks the shard until age-out. Fix: ReportBatchItemFailures (checkpoint good records), bisectBatchOnFunctionError, cap maximumRetryAttempts/maximumRecordAgeInSeconds, and an on-failure destination. (DVA-C02, SAA-C03)

Q4. Distinguish Errors from Throttles. Errors counts invocations that failed in Init or handler (code/permission/timeout/OOM); it excludes throttles. Throttles counts invocations rejected by the concurrency gate (429). A throttle problem is fixed with concurrency, not code — reading the wrong metric wastes the incident. (SOA-C02)

Q5. You get Task timed out after 30 seconds but Duration looks fine on similar calls. What are you checking? Whether one downstream call stalls (X-Ray subsegment near full duration), whether a VPC function lacks a NAT/endpoint route (works outside the VPC), a missing await/callback, or DNS. Only after ruling those out do you raise the timeout. (DVA-C02)

Q6. How does throttling manifest differently across the three invocation models? Sync: caller gets 429 and must retry. Async: Lambda retries internally and eventually drops without a destination. Poll: the poller slows and backlog grows (IteratorAge for streams, depth for SQS). (DVA-C02)

Q7. What does setting reserved concurrency to 0 do, and when would you? It throttles all invocations — a kill switch to instantly stop a misbehaving or compromised function without deleting it. Non-zero reserved concurrency both guarantees and caps that function’s slice of the account pool. (SOA-C02)

Q8. Provisioned concurrency vs SnapStart — when each? Provisioned concurrency pre-warms N environments (kills cold starts on a baseline, billed hourly) — use for predictable latency SLAs. SnapStart snapshots the initialized env and restores it (Java/Python/.NET, no extra charge) — use to cut heavy-runtime init, minding snapshot uniqueness (randomness, connections). (DVA-C02)

Q9. Why can an SQS-triggered function process the same message multiple times, and how do you prevent duplicates? Visibility timeout too low (< 6× function timeout) makes a message reappear mid-processing; and without ReportBatchItemFailures, one failure reprocesses the whole batch. Fix both, and make handlers idempotent — SQS standard is at-least-once by design. (DVA-C02, SAA-C03)

Q10. Runtime.ImportModuleError vs an unhandled exception — which phase, and what does each imply? ImportModuleError is an Init failure (the runtime couldn’t load your module — missing dep, wrong arch, bad handler path) and fails every invoke identically. An unhandled exception is an Invoke failure in your handler logic. The phase tells you where to look. (DVA-C02)

Q11. A function scaled out and took down its RDS database. What happened and how do you prevent it? Each concurrent environment opened its own connection; hundreds of concurrent invokes exhausted the DB’s max_connections. Prevent with reserved concurrency to cap the fan-out, RDS Proxy to pool connections, and connection reuse across warm invokes. (SAA-C03, DVA-C02)

Q12. Which metrics would you alarm on for a production event-driven function, beyond Errors? Throttles (concurrency), DeadLetterErrors/AsyncEventsDropped (silent loss), IteratorAge (stuck shard), Duration p99 (timeout risk), and ProvisionedConcurrencySpilloverInvocations (cold spillover). Each maps to a distinct failure class. (SOA-C02)

Quick check

  1. Which lifecycle phase does Runtime.ImportModuleError occur in, and why does that mean it fails every invoke?
  2. Your async function shows Errors > 0 and customers report missing side effects, but nothing is in any queue. What single metric confirms the cause, and what’s the fix?
  3. What is the one ESM setting that stops a single bad SQS message from causing the other nine in its batch to reprocess?
  4. A VPC Lambda times out on an internet call with no error string beyond the timeout. What’s the root cause and the two possible fixes?
  5. Throttles > 0 but Errors is flat. Is this a code problem? What do you change?

Answers

  1. Init. The runtime can’t load your module (missing dependency, wrong architecture, wrong handler path), so it fails identically on every cold environment before your handler ever runs.
  2. AsyncEventsDropped > 0 confirms Lambda retried twice and dropped the event because no on-failure destination/DLQ was attached. Fix: attach an on-failure destination and make the handler idempotent for redrive.
  3. ReportBatchItemFailures (partial batch response) — return only the failed message IDs in batchItemFailures so the successful ones checkpoint and aren’t reprocessed.
  4. No route to the internet — a VPC function with no NAT gateway (Lambda ENIs get no public IP). Fix: add a NAT gateway for internet egress, or VPC endpoints for AWS-service targets. Confirm by running the same code outside the VPC.
  5. No — it’s a concurrency problem, not code. Throttles are rejections at the concurrency gate. Raise account or reserved concurrency (or reduce demand / add back-pressure); changing handler code does nothing.

Glossary

Term Definition
Execution environment The Firecracker micro-VM Lambda creates, reuses, freezes and destroys to run your function.
Init Duration The cold-start time to download the package, bootstrap the runtime, and run module-level code; shown in the REPORT line.
Cold start The latency of initializing a new execution environment before the handler runs.
Provisioned concurrency Pre-initialized environments kept warm to eliminate cold starts on a known baseline; billed hourly.
SnapStart A snapshot/restore of the initialized environment that slashes init time for Java, Python and .NET at no extra charge.
Invocation model Synchronous, asynchronous, or poll-based (event-source mapping) — each with distinct retry, ordering and failure behaviour.
Event-source mapping (ESM) The Lambda-managed poller that reads batches from SQS/Kinesis/DynamoDB Streams and invokes the function.
Partial batch response Returning only failed record IDs (ReportBatchItemFailures) so successful records in a batch are checkpointed.
Poison record A record that fails on every retry; on streams it blocks the shard until it ages out.
IteratorAge The age of the oldest unprocessed record on a stream shard; rising = a stuck or lagging consumer.
On-failure destination An SQS/SNS/EventBridge/Lambda target that captures async or stream records after retries are exhausted.
DLQ (dead-letter queue) The older mechanism for capturing failed async events; destinations are the richer successor.
Reserved concurrency A per-function guarantee and cap on concurrent executions; 0 acts as a kill switch.
Throttle (429) A TooManyRequestsException from the concurrency gate; counted in Throttles, not Errors.
Ephemeral storage (/tmp) Per-environment scratch disk, 512 MB by default, configurable to 10,240 MB.

Next steps

AWSLambdaServerlessCold StartTroubleshootingCloudWatchSQSX-Ray
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading