AWS – Lambda: Handlers, Cold Starts and Packaging

December 10, 20244 min readUpdated 8/24/2026

Lambda runs a function in response to an event and bills you per millisecond of execution. No servers, no capacity planning, nothing running when nothing is happening. This site's admin API is a FastAPI application on Lambda, and it costs under a dollar a month.

The handler and the execution context

import json
import boto3

# Module scope. Runs ONCE per container, not once per request.
s3 = boto3.client("s3")
CONFIG = load_config()


def lambda_handler(event, context):
    body = json.loads(event.get("body") or "{}")
    return {
        "statusCode": 200,
        "headers": {"content-type": "application/json"},
        "body": json.dumps({"ok": True}),
    }

The single most useful fact about Lambda is in that comment. AWS starts a container, imports your module, and then calls the handler — and it reuses that container for subsequent invocations, often for many minutes.

So anything expensive belongs at module scope: SDK clients, configuration, a database connection. Creating a boto3 client inside the handler pays that cost on every request for no reason.

The corollary is that module-scope state persists between invocations of different users. Caching a config value there is a good idea. Caching request-specific data there is a data leak waiting to happen, and it is the kind of bug that never reproduces locally.

Cold starts, measured

A cold start is the container creation plus your imports. It happens on the first request, when scaling out, and after a period of idleness.

Realistically: a small Python or Node function is roughly 100–300ms; a heavy dependency tree can be a second or more; a JVM function can be several. The dominant factor is almost always import time, not AWS's overhead — so the fix is usually to import less. Importing all of boto3 when you need one client, or a full data-science stack for one function, is where the time goes.

If cold starts genuinely matter, provisioned concurrency keeps a number of containers warm — and bills for them continuously, which gives up the main advantage of Lambda. Reach for it only when you have measured a real problem.

Memory is the CPU dial

This one is counter-intuitive and worth acting on. You configure memory from 128 MB to 10,240 MB — and CPU is allocated in proportion. You do not set CPU separately.

So a CPU-bound function at 128 MB is not just memory-constrained, it is getting a fraction of a core. Raising memory to 1,024 MB can make it run four times faster, and since you are billed for GB-milliseconds, four times faster at eight times the memory rate can cost roughly the same — or less, with a better user experience.

Do not guess. Run the same workload at several settings, plot duration against cost, and pick the knee. AWS Lambda Power Tuning automates exactly this.

Packaging, and the trap that kills a function at import

Three options: a zip you upload, a Lambda layer for shared dependencies, or a container image up to 10 GB.

Here is the trap, and it has cost this site real time. Compiled dependencies must be built for Lambda's platform, not for your laptop. Packages like pydantic-core, bcrypt, numpy and psycopg ship platform-specific binary wheels. Run pip install on macOS, zip the result, and you get a Lambda that fails at import with an unhelpful error — nothing in your code is wrong.

Force the target platform explicitly:

pip install -r requirements.txt -t ./package \
  --platform manylinux2014_x86_64 \
  --implementation cp --python-version 3.12 --only-binary=:all:

And verify before deploying, rather than after:

file package/pydantic_core/*.so     # must say ELF ... x86-64, not Mach-O

If you build on Apple Silicon and target arm64, the same rule applies with a different wheel — the point is that the platform must be stated, never inferred.

Timeouts and the two limits behind them

The default timeout is 3 seconds and the maximum is 15 minutes. Set it deliberately: too low and legitimate work fails, too high and a hung request bills for a quarter of an hour.

Note that anything fronted by API Gateway is capped at 29 seconds by the gateway regardless of your Lambda's timeout, so a 15-minute setting behind an API is a 29-second setting with a misleading configuration.

Concurrency is the other limit. An account has a regional concurrent-execution quota shared by every function, so one function scaling up can throttle the rest. Reserved concurrency both guarantees a floor and caps the ceiling — capping a function that talks to a small database is often the point, not a limitation.

Retries are not optional, and they differ by trigger

How a failed invocation is retried depends entirely on what invoked it, and assuming the wrong one produces either lost work or duplicated work.

  • Synchronous (API Gateway, an ALB) — no retry. The caller gets the error and decides
  • Asynchronous (S3 events, SNS) — retried twice by default, then sent to a dead-letter queue or on-failure destination if you configured one, and dropped if you did not
  • Stream and queue (SQS, Kinesis, DynamoDB Streams) — retried until the message expires or succeeds, which can block a shard indefinitely

So every asynchronous function needs a failure destination, and every function that can be retried has to be safe to run twice. Idempotency is a requirement here, not a refinement.

Logs and what to watch

aws logs tail /aws/lambda/lovemesomecoding-admin-api-prod --follow --since 15m

Every invocation writes a REPORT line with duration, billed duration, memory size and max memory used. That last field is how you right-size: a function configured for 1,024 MB and peaking at 90 MB is being paid for at eight times what it needs — unless it is CPU-bound, in which case see above.

Set a log retention period. Log groups default to never expiring, and they bill for storage forever.