How AWS Lambda Works Internally: Firecracker MicroVMs, Cold Start Optimization, and the Serverless Execution Model From Invoke to Response
A deep dive into Lambda's internal execution architecture: Firecracker microVM isolation, Worker Manager and Placement Service routing, cold start mechanics with SnapStart, synchronous vs asynchronous invoke paths, concurrency management, VPC networking via Hyperplane, and production tuning strategies.
Most engineers treat Lambda as a black box: upload a zip, attach a trigger, pay per invocation. That mental model works until you hit a latency SLA, exhaust burst limits, or spend an afternoon debugging a VPC cold start that adds 10 seconds to every new execution environment. Understanding what actually happens between invoke and the first line of your handler eliminates an entire class of production surprises.
This article walks through the full execution stack: the Firecracker microVM that provides isolation, the placement and scheduling layer that routes invocations, the cold start pipeline, both invoke paths, concurrency accounting, the networking model for VPC functions, and the storage options available inside an execution environment.
The Firecracker Foundation
Lambda’s isolation unit is a microVM managed by Firecracker, an open-source VMM written in Rust and developed at AWS. Firecracker replaced the older container-based model because containers share a kernel and the kernel attack surface is large. A microVM has its own kernel instance, so a compromised guest cannot reach the host or another tenant’s memory.
What makes Firecracker suitable for Lambda specifically is its minimal device model. A traditional VM exposes hundreds of emulated devices: PCI buses, USB controllers, legacy BIOS interfaces. Firecracker exposes only the devices Lambda actually needs: a virtio-net network interface, a virtio-block device for the root filesystem, a serial console, and a minimal ACPI table for orderly shutdown. Removing unused devices shrinks the attack surface and, more importantly, cuts boot time. A Firecracker microVM with a pre-loaded Linux kernel and a minimal init process boots in under 125 milliseconds on Lambda’s bare-metal fleet.
Memory ballooning is also part of the picture. The hypervisor inflates the balloon device to reclaim unused guest memory without stopping the VM. When a Lambda execution environment is idle between invocations, the host can reclaim guest memory pages that the runtime has not touched, then deflate the balloon when a new invocation arrives. This lets AWS pack more execution environments onto a single host without over-provisioning physical RAM.
The host machines are not shared EC2 instances. Lambda runs on a dedicated bare-metal fleet. Each physical server runs multiple Firecracker VMMs, and the Lambda control plane manages which functions run where.
Worker Manager and Placement Service
Two internal services coordinate where invocations land.
The Placement Service maintains a global view of the Lambda worker fleet: which hosts have available capacity, which execution environments are already warm for a given function version, and which AZ each host belongs to. When a new invocation arrives, the Placement Service selects a host that can satisfy the request with an existing warm environment if one is available, or with spare capacity to create a new one if not.
The Worker Manager runs per-function and tracks the lifecycle of execution environments for that function. It holds references to warm environments waiting for work, environments currently processing an invocation, and environments being initialized. When the Placement Service identifies a target host, the Worker Manager coordinates with the Sandbox Manager process on that host to either dispatch to an existing environment or spin up a new Firecracker VMM.
The dispatch path for a warm invocation looks like this: the invoke request arrives at the Lambda frontend, the Worker Manager finds an available warm environment, the request payload is sent to the runtime process inside the microVM over a local HTTP channel, and the runtime calls your handler. The Placement Service is not on the hot path for warm invocations. Latency from API call to handler entry on a warm invocation is typically in the low-single-digit milliseconds.
Cold Start Mechanics
A cold start happens when no warm execution environment exists for a function version. The sequence:
- The Placement Service selects a host with available capacity.
- The Sandbox Manager launches a new Firecracker VMM and boots the microVM kernel (sub-125ms).
- The Lambda agent inside the VM mounts the function package (zip or container image layer) from a distributed object store.
- The runtime process starts (Node.js, Python, JVM, or custom runtime).
- Your initialization code runs: module-level imports, database connection setup, SDK client instantiation.
- The runtime signals readiness to the Worker Manager.
- The first invocation payload is delivered.
The parts you control are steps 4 and 5. The JVM adds significant initialization time compared to Node.js or Python because class loading and JIT compilation happen during init. A Node.js function with a handful of require calls will complete steps 4-5 in 200-400ms. A Spring Boot application can take several seconds.
Here is a concrete example of initialization code that runs during step 5 and how its placement affects cold start duration:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
// This runs during init phase, not per-invocation.
// The SDK client and HTTP connection pool are created once
// and reused across all warm invocations on this environment.
const dynamo = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: process.env.AWS_REGION })
);
export const handler = async (event: { userId: string }) => {
// Only the business logic runs here on warm invocations.
const result = await dynamo.send(
new GetCommand({
TableName: process.env.TABLE_NAME!,
Key: { userId: event.userId },
})
);
return result.Item ?? null;
};
Anything outside the handler runs once per execution environment lifetime. Keep it lean. Avoid synchronous I/O, eager loading of configuration that is not always needed, and large in-memory caches built from remote data during init.
SnapStart and CRIU Checkpoint/Restore
For Java functions, AWS offers Lambda SnapStart. The mechanism: when you publish a function version with SnapStart enabled, Lambda runs the init phase once, then takes a snapshot of the microVM memory and disk state using CRIU (Checkpoint/Restore In Userspace). CRIU is a Linux feature that serializes a running process tree to disk, capturing open file descriptors, memory maps, and CPU register state.
When a cold start occurs for a SnapStart-enabled function, Lambda restores the microVM from the cached snapshot rather than booting from scratch and re-running your init code. Boot plus restore is faster than boot plus full JVM initialization, often cutting cold start from 3-8 seconds to under 1 second for Java functions.
The practical constraint: any resource acquired during init that embeds a timestamp, a random seed, or a connection credential with a short TTL will be stale after restore. AWS provides a RuntimeHook interface that lets you register callbacks for snapshot creation and restore events:
import com.amazonaws.services.lambda.runtime.api.client.runtimeapi.LambdaRuntimeClient;
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
public class DatabaseConnectionHook implements Resource {
private DatabaseConnection conn;
public DatabaseConnectionHook() {
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) {
// Close connections before snapshot is taken.
conn.close();
}
@Override
public void afterRestore(Context<? extends Resource> context) {
// Re-establish connections after restore.
conn = DatabaseConnection.create();
}
}
Pre-warming Pools
Lambda maintains internal pre-initialized microVM pools to reduce cold start frequency. These pools hold initialized runtimes without a specific function loaded. When a cold start is needed, Lambda can skip the VM boot step and go directly to mounting the function package and running init code. This is why you will sometimes see cold starts that are shorter than the theoretical minimum for a full boot sequence.
Provisioned Concurrency is the user-facing version of this: you explicitly reserve a number of pre-initialized execution environments that Lambda keeps warm at all times. No cold start, just the invocation dispatch overhead. The cost is that you pay for idle environments even when there is no traffic.
Synchronous vs Asynchronous Invoke Paths
Lambda supports two primary invoke modes that take different internal paths.
Synchronous invoke (InvocationType: RequestResponse) is the path used by API Gateway, Application Load Balancer, and direct SDK calls. The calling client holds a connection open until the function returns or times out. The Lambda frontend sends the request directly to the Worker Manager, which dispatches it to an execution environment. The response is streamed back to the frontend and returned to the caller. Errors and throttles surface immediately.
Asynchronous invoke (InvocationType: Event) is the path used by S3 event notifications, SNS, EventBridge rules, and direct SDK calls with the Event type. The Lambda frontend acknowledges the request immediately with a 202, then writes the event to an internal SQS-based event queue. A separate internal poller reads from that queue and submits the invocation to the Worker Manager. Lambda retries failed async invocations up to twice by default, with exponential backoff between attempts. You can configure a dead-letter queue or an on-failure event destination to capture events that exhaust retries.
The implications for capacity planning differ. Synchronous invocations throttle immediately when concurrency is exhausted: the caller gets a 429 TooManyRequestsException. Asynchronous invocations buffer in the internal queue, so traffic spikes do not result in immediate throttling errors. Instead, they result in increased processing latency as the queue depth grows and Lambda gradually scales up concurrency.
Concurrency Management
Lambda’s concurrency model is account-level concurrent executions, not requests per second or throughput.
Every in-flight invocation consumes one unit of concurrency. If your function is processing 100 requests simultaneously, that is 100 units consumed. The account default is 1000 concurrent executions per region, which is a soft limit you can raise.
Reserved concurrency sets a ceiling for a specific function, preventing it from consuming the entire account pool during a traffic spike and starving other functions. It also sets a floor: other functions cannot consume the reserved capacity even if the function is idle.
Provisioned concurrency pre-initializes environments and removes cold starts. It counts against reserved concurrency and costs per GB-hour in addition to per-invocation pricing.
Burst limits control how fast Lambda can scale a function that receives a sudden traffic surge. Lambda does not instantly scale from 0 to 1000. In most regions, the burst limit is 3000 concurrent executions in the first minute, with 500 additional executions added per minute after that. A function that receives 5000 simultaneous requests after being idle will throttle some of those requests for the first few minutes while Lambda scales out.
Per-function throttling returns a TooManyRequestsException with an X-Amzn-ErrorType: TooManyRequestsException header. Your caller needs to handle 429s with exponential backoff and jitter. The AWS SDK retry configuration does this by default for most service clients, but Lambda invoke is not always called through service clients.
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const client = new LambdaClient({
region: "us-east-1",
maxAttempts: 5, // SDK will retry throttles with backoff
});
const invoke = async (payload: Record<string, unknown>) => {
const response = await client.send(
new InvokeCommand({
FunctionName: process.env.FUNCTION_NAME!,
InvocationType: "RequestResponse",
Payload: Buffer.from(JSON.stringify(payload)),
})
);
if (response.FunctionError) {
const body = JSON.parse(Buffer.from(response.Payload!).toString());
throw new Error(`Function error: ${body.errorMessage}`);
}
return JSON.parse(Buffer.from(response.Payload!).toString());
};
Lambda Extensions API and Lifecycle Hooks
Lambda Extensions let you run additional processes alongside your function handler inside the same execution environment. AWS uses them for observability agents (Datadog, Dynatrace, New Relic), secret managers, and compliance proxies.
An extension registers via the Lambda Extensions API and participates in the execution environment lifecycle:
- INIT phase: Extensions are initialized before the runtime and handler init code runs.
- INVOKE phase: Extensions receive a notification when an invocation starts and when it completes.
- SHUTDOWN phase: Extensions receive a shutdown notification before the environment is torn down, with a configurable deadline to flush buffered data.
// A minimal internal extension written in TypeScript.
// Extensions run as separate processes and communicate
// with the Lambda runtime API over HTTP on 127.0.0.1:9001.
import http from "http";
const BASE_URL = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension`;
async function register(): Promise<string> {
const body = JSON.stringify({ events: ["INVOKE", "SHUTDOWN"] });
return new Promise((resolve, reject) => {
const req = http.request(
`${BASE_URL}/register`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Lambda-Extension-Name": "my-telemetry-agent",
},
},
(res) => {
const id = res.headers["lambda-extension-identifier"] as string;
resolve(id);
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
async function nextEvent(extensionId: string): Promise<{ eventType: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
`${BASE_URL}/event/next`,
{
method: "GET",
headers: { "Lambda-Extension-Identifier": extensionId },
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve(JSON.parse(data)));
}
);
req.on("error", reject);
req.end();
});
}
(async () => {
const id = await register();
while (true) {
const event = await nextEvent(id);
if (event.eventType === "SHUTDOWN") {
// Flush any buffered telemetry before the deadline.
break;
}
// Collect and buffer metrics per INVOKE event.
}
})();
Extensions add startup overhead because their INIT phase runs before your handler init. A heavyweight observability agent can add 500ms+ to cold starts.
Networking Model: VPC ENI Attachment via Hyperplane
By default, Lambda functions run in an AWS-managed VPC and cannot reach resources in your VPC. When you configure a function with VPC settings, Lambda attaches an ENI (Elastic Network Interface) to your subnet.
The original ENI attachment model created a new ENI per execution environment, which was slow (often 10+ seconds for the first cold start) and consumed ENI quota rapidly. AWS replaced it with Hyperplane, an internal network function virtualization layer.
With Hyperplane, Lambda creates a small number of shared ENIs per unique (VPC, subnet, security group) configuration tuple. Each Firecracker microVM’s virtual network interface connects to Hyperplane, which NATs the traffic through the shared ENIs into your VPC. Multiple execution environments share the same underlying ENI. ENI creation now happens at function deployment or first invocation, not at each cold start, eliminating the 10-second ENI attachment penalty.
The tradeoff is that security group rules apply to the shared ENI, not to individual execution environments. All concurrent executions of the same function share the same security group identity in your VPC.
For functions that need outbound internet access from within a VPC, the standard pattern remains: private subnets with a NAT Gateway or NAT instance. Lambda has no built-in outbound internet path from a VPC configuration.
Storage Model
Lambda provides three storage options:
Ephemeral /tmp is a local filesystem available at /tmp inside the execution environment. Size is configurable between 512MB and 10GB. Data persists across warm invocations on the same execution environment but is not shared between environments and disappears when the environment is shut down. Use it for caching large files that you do not want to re-download on every invocation: ML model weights, reference data, decompressed archives.
EFS mounts attach an Elastic File System to your function via the VPC networking path. Useful for sharing large files across invocations and functions without managing S3 downloads or layer size limits. Adds latency to cold starts because the NFS mount must be established during init.
Lambda Layers are zip archives that are extracted into the execution environment at /opt. They exist for sharing code and dependencies across functions without bundling them into each function package. Layers are not a substitute for proper dependency management; they are mainly useful for large binaries (native extensions, ffmpeg, Chromium) that you want to manage separately from function code.
Production Considerations
Memory-CPU proportionality. Lambda does not let you configure vCPU directly. CPU allocation is proportional to memory: at 1769MB you get one full vCPU, at 3538MB you get two. For CPU-bound workloads, increasing memory beyond what your code needs may reduce execution time enough to lower total cost. Measure with Lambda Power Tuning before assuming the default memory is optimal.
Timeout strategy. The maximum timeout is 15 minutes. Set it to slightly above your P99 execution time, not to the maximum. A runaway function holding concurrency at the maximum timeout blocks new invocations for 15 minutes. For async processing with SQS triggers, the function timeout must be less than the SQS visibility timeout or you risk duplicate processing.
Cold start mitigation without provisioned concurrency. Scheduled ping invocations (CloudWatch Events every 5 minutes) keep a single environment warm but do not help with concurrency spikes. For predictable traffic patterns, a scheduled Lambda that invokes the target function N times concurrently can pre-warm N environments before a known traffic event.
Cost model awareness. You are billed for GB-seconds: the memory configured multiplied by the duration rounded up to 1ms. A 128MB function running for 100ms costs the same as a 1024MB function running for 12.5ms. The free tier is 400,000 GB-seconds per month. Provisioned concurrency adds an idle cost regardless of invocations.
Extension overhead accounting. If you add a third-party observability extension, measure its contribution to cold start and per-invocation overhead before enabling it in production. Some agents add 300-800ms to cold starts and 5-20ms to each invocation. That overhead may be acceptable; it should be a conscious decision.
Tradeoffs Comparison
| AWS Lambda | Cloudflare Workers | Fly.io Machines | Google Cloud Run | Azure Container Apps | |
|---|---|---|---|---|---|
| Isolation model | Firecracker microVM per tenant | V8 isolate per request | Full VM (Firecracker) | Container per revision | Container per replica |
| Cold start latency | 100ms-3s (JVM higher) | Under 5ms (V8 isolate reuse) | 500ms-2s | 1-4s | 1-5s |
| Max execution time | 15 minutes | 30 seconds (Paid) | Unlimited | 60 minutes | Unlimited |
| Networking model | AWS-managed VPC or customer VPC via Hyperplane | Anycast edge, no VPC | Wireguard mesh across regions | Google VPC | Azure VNet |
| Pricing model | Per GB-second + per request | Per request + CPU time | Per VM-second (minimum billing) | Per vCPU-second + memory | Per vCPU-second + memory |
| State management | Ephemeral /tmp, EFS, external | Durable Objects, KV, R2 | Persistent volumes available | Cloud Storage, external | Azure Storage, external |
| Sweet spot | Event-driven, AWS-native integrations, burst workloads | Low-latency edge logic, global distribution | Long-running tasks, persistent connections | Containerized workloads, gradual traffic | Azure ecosystem, microservices |
Cloudflare Workers have the fastest cold starts because V8 isolates share a process and do not boot a kernel. The tradeoff is a constrained execution environment: no arbitrary native binaries, limited CPU per request, and the Workers runtime instead of Node.js. Lambda gives you a full Linux environment at the cost of higher cold start latency and more operational surface area.
Cloud Run sits between the two: you bring a container, get a full Linux environment, and pay per actual CPU and memory consumption during request processing. It scales to zero like Lambda but does not have the same depth of AWS service integrations.
Fly.io Machines are full VMs that you start and stop programmatically. They are not a serverless platform in the traditional sense. They suit workloads that need persistent TCP connections, long-running background tasks, or full control over the execution environment without Kubernetes overhead.
Understanding where Lambda fits on this spectrum, and where its isolation model imposes constraints that matter for your workload, is the foundation of choosing the right execution model rather than defaulting to whatever the team used last time.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.