Siddhant Deval
Siddhant Deval
backend18 min read

Lambda Internals: Execution Model, Context Reuse & Cold-Start Root Causes

A Lambda function is not a stateless process that starts fresh on every call — it is a MicroVM that freezes between invocations and thaws on reuse. This article covers the three-phase lifecycle, execution context reuse, global scope caching, cold-start root causes, Provisioned Concurrency, and the memory-vCPU relationship that makes the counterintuitive 'more memory = cheaper' case real.

Series·Part 1 of 13

AWS Serverless Engineering: Lambda to Production

Lambda Internals: Execution Model, Context Reuse & Cold-Start Root Causes

Every mid-level engineer knows AWS Lambda runs their code when an event arrives. Every senior engineer knows Lambda runs inside a MicroVM with a three-phase lifecycle — Init, Invoke, Shutdown — and that the decisions you make about initialization placement, bundle size, and memory allocation determine whether your P99 latency is 12ms or 600ms at identical throughput. Every AWS primitive is a tradeoff surface, not a feature toggle. Cold starts are not random tax — they are a deterministic consequence of runtime initialization decisions you make before the first line of handler code executes. This article closes the gap between knowing that Lambda "spins up sometimes" and knowing exactly what happens in each phase, what you pay for, and which levers you control.


1. The Three-Phase Execution Lifecycle

Lambda does not run your handler directly. Every invocation travels through a three-phase lifecycle. Understanding which phase each line of your code belongs to is the most impactful mental model shift for serverless performance optimization.

1.1 Init Phase

The Init phase is the cold-start tax. It runs exactly once per MicroVM lifetime — not once per invocation. Lambda completes three sequential steps inside Init:

  1. Runtime bootstrap — The Lambda service allocates a MicroVM, downloads and unzips your deployment package (or pulls your container image layers), starts the language runtime (Node.js process, JVM, Python interpreter), and loads the runtime shim that translates Lambda's Invoke API into a handler call.
  2. Static initializer execution — Every statement in your module's top-level scope executes: import statements, constructor calls, const declarations that call functions. This is where SDK clients, database connections, and parsed configuration objects should live.
  3. Handler registration — The runtime signals readiness to the Lambda service by sending a GET request to the Lambda Runtime API endpoint. The service acknowledges and the MicroVM enters a warm standby state.
TYPESCRIPT
// The Init phase executes everything at module scope:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'

// ✅ All of these run ONCE in Init, never again for warm invocations
const region = process.env.AWS_REGION!
const dynamoClient = new DynamoDBClient({ region })
export const docClient = DynamoDBDocumentClient.from(dynamoClient)

// Secret fetch in global scope — runs once, cached in the MicroVM lifetime
const sm = new SecretsManagerClient({ region })
const secretResponse = await sm.send(
  new GetSecretValueCommand({ SecretId: process.env.DB_SECRET_ARN! })
)
export const dbPassword = JSON.parse(secretResponse.SecretString!).password

// Handler registration — runs after Init completes
export const handler = async (event: AWSLambda.APIGatewayProxyEvent) => {
  // Warm invocations start here — Init phase is already complete
  // docClient and dbPassword are already initialized and available
}
Crucial Requirement

Top-level await in ES modules is supported in Node.js 18+ Lambda runtimes. For CommonJS modules, use an IIFE or a module-level promise chain. Calling async initialization inside the handler on every invocation is the single most common Lambda performance anti-pattern.

1.2 Invoke Phase

Once Init completes, the Lambda service delivers the event payload to the registered handler. The Invoke phase runs on every invocation — warm or cold. Lambda passes two arguments:

  • event: The trigger payload, shaped by the event source (API Gateway, SQS, Kinesis, EventBridge, etc.)
  • context: A runtime metadata object containing functionName, functionVersion, memoryLimitInMB, awsRequestId, and critically — getRemainingTimeInMillis() for timeout-aware processing
TYPESCRIPT
export const handler = async (
  event: AWSLambda.SQSEvent,
  context: AWSLambda.Context
): Promise<AWSLambda.SQSBatchResponse> => {
  // context.awsRequestId — unique per invocation, use as correlation ID
  // context.getRemainingTimeInMillis() — milliseconds until timeout fires
  const remainingMs = context.getRemainingTimeInMillis()

  const failures: AWSLambda.SQSBatchItemFailure[] = []

  for (const record of event.Records) {
    if (context.getRemainingTimeInMillis() < 500) {
      // Less than 500ms remaining — report remaining records as failures
      // SQS will redeliver them; better than a forced timeout
      failures.push({ itemIdentifier: record.messageId })
      continue
    }
    try {
      await processRecord(record)
    } catch (err) {
      failures.push({ itemIdentifier: record.messageId })
    }
  }

  return { batchItemFailures: failures }
}
Pro Tip & Optimization

Use context.getRemainingTimeInMillis() as a progress gate inside batch processing loops. A handler that times out mid-batch returns no information about which records succeeded — partial batch response (SQSBatchResponse) reports only failures, enabling SQS to redeliver exactly those records.

1.3 Shutdown Phase

When Lambda decides to terminate a MicroVM — due to low demand, a deployment update, or the container reaching its maximum lifetime — it sends a SIGTERM signal before forcibly terminating. Your handler has a 300ms grace window to complete cleanup work.

TYPESCRIPT
// Register a SIGTERM handler for graceful cleanup
process.on('SIGTERM', async () => {
  console.log('SIGTERM received — flushing pending telemetry')
  // Close database connection pools
  // Flush buffered metrics / log spans
  // Complete any in-flight writes
  await flushTelemetry()
  process.exit(0)
})
Architectural Note

Lambda Extensions receive a separate Shutdown lifecycle event with a configurable grace period (up to 2 seconds for external extensions). If you use a telemetry extension (e.g., AWS Distro for OpenTelemetry), it registers its own Shutdown hook independently of your SIGTERM handler.


2. Execution Context Reuse — The Freeze/Thaw Cycle

After Init completes and the handler finishes executing, Lambda does not destroy the MicroVM. Instead, it freezes the container in place — pausing all processes, preserving heap memory, open file descriptors, and network connections exactly as they were at handler exit. When the next invocation arrives for the same function, Lambda thaws the frozen container and delivers the event to the handler, bypassing Init entirely.

Cold invocation:    [Init: 180ms] → [Invoke: 45ms] → [Freeze]
Warm invocation 1:  [Thaw] → [Invoke: 12ms] → [Freeze]
Warm invocation 2:  [Thaw] → [Invoke: 11ms] → [Freeze]
Warm invocation 3:  [Thaw] → [Invoke: 13ms] → [Freeze]

2.1 What Persists Across Invocations

The freeze/thaw cycle creates a category of state that neither exists in the event payload nor resets between invocations. Understanding this category prevents both bugs and missed optimizations:

Persistent across invocations Resets per invocation
Module-level variables (const, let at top scope) event parameter
SDK client connection pools context parameter
In-memory caches (Maps, Sets, arrays) Handler local variables
Open file handles try/catch state
/tmp filesystem contents Stack frame
Environment variables
TYPESCRIPT
// ✅ Exploit context reuse: connection pool initialized once
import { Pool } from 'pg'

// Pool is created on Init, reused across all warm invocations
// Without this pattern: new Pool() on every invocation = 40-80ms overhead each time
const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  password: dbPassword, // from global scope secret fetch
  max: 1,              // ⚠️ Keep max=1 for Lambda — RDS Proxy handles pooling above
  idleTimeoutMillis: 10_000
})

export const handler = async (event: AWSLambda.APIGatewayProxyEvent) => {
  const client = await pool.connect()
  try {
    const result = await client.query('SELECT id, name FROM users WHERE id = $1', [
      event.pathParameters!.userId
    ])
    return { statusCode: 200, body: JSON.stringify(result.rows[0]) }
  } finally {
    client.release() // Return to pool — NOT pool.end()
  }
}
Performance / Safety Warning

Never call pool.end() inside the handler. This destroys the connection pool and forces re-initialization on the next warm invocation, defeating context reuse entirely. pool.end() belongs only in the SIGTERM handler.

2.2 The In-Memory Cache Pattern

Context reuse enables a lightweight caching layer with zero infrastructure cost — a module-level Map that lives for the MicroVM lifetime:

TYPESCRIPT
interface CacheEntry<T> {
  value: T
  expiresAt: number
}

// Module-level cache — survives freeze/thaw cycles
const cache = new Map<string, CacheEntry<unknown>>()

async function getCached<T>(
  key: string,
  ttlMs: number,
  fetch: () => Promise<T>
): Promise<T> {
  const entry = cache.get(key) as CacheEntry<T> | undefined
  if (entry && Date.now() < entry.expiresAt) {
    return entry.value // Cache hit — no external call
  }
  const value = await fetch()
  cache.set(key, { value, expiresAt: Date.now() + ttlMs })
  return value
}

export const handler = async (event: AWSLambda.APIGatewayProxyEvent) => {
  // Fetches config from Parameter Store at most once per 5 minutes per container
  // At 1,000 concurrent invocations: reduces SSM API calls by 99.9%
  const config = await getCached(
    'feature-flags',
    5 * 60 * 1000, // 5 minute TTL
    () => ssm.send(new GetParameterCommand({ Name: '/app/feature-flags', WithDecryption: true }))
      .then(r => JSON.parse(r.Parameter!.Value!))
  )
  // use config...
}
Mental Model Check

The module-level cache is a per-container cache, not a distributed cache. Ten concurrent invocations across ten MicroVMs each have their own independent cache instance. This is acceptable for configuration and small reference data — not for user session state or distributed locks.


3. Ephemeral Storage: /tmp

Every Lambda MicroVM has an ephemeral local filesystem at /tmp. Unlike in-memory variables, /tmp persists across freeze/thaw cycles and can be used to cache files — downloaded assets, compiled templates, ML model weights — that would be expensive to reconstruct on every warm invocation.

Attribute Value
Default size 512 MB
Maximum size (configurable) 10,240 MB (10 GB)
Persistence Survives freeze/thaw within the same MicroVM lifetime
Sharing Not shared between MicroVM instances
Encryption At-rest encryption available (AWS KMS)
TYPESCRIPT
import { createWriteStream, existsSync, readFileSync } from 'fs'
import { pipeline } from 'stream/promises'
import { Readable } from 'stream'

const MODEL_PATH = '/tmp/model-weights.bin'

async function ensureModelLoaded(): Promise<Buffer> {
  if (existsSync(MODEL_PATH)) {
    // ✅ Warm invocation: model already downloaded in a previous invocation
    return readFileSync(MODEL_PATH)
  }
  // Cold path: download from S3, cache to /tmp
  const response = await s3.send(
    new GetObjectCommand({ Bucket: 'my-models', Key: 'model-weights.bin' })
  )
  await pipeline(response.Body as Readable, createWriteStream(MODEL_PATH))
  return readFileSync(MODEL_PATH)
}
Pro Tip & Optimization

For workloads requiring persistent shared storage across invocations and MicroVMs, mount Amazon EFS. EFS adds ~1–3ms latency per operation but enables shared state (ML model caches, shared file processing queues) across all concurrent Lambda instances.


4. Cold Start Root Causes & Mitigation

A cold start occurs when no warm MicroVM is available for a new invocation. Lambda must complete the full Init phase before executing the handler. Understanding what drives Init phase duration gives you precise levers to reduce it.

4.1 Init Phase Duration Breakdown

Total cold start duration = Runtime bootstrap + Dependency loading + Static initializer execution
Root cause Typical contribution Mitigation
Runtime bootstrap 50–150ms (JVM: 300–800ms) SnapStart (JVM), arm64 architecture
Dependency loading 30–200ms Tree-shaking, bundling, lazy requires
Static initializer execution 20–400ms Reduce global-scope work, defer non-critical init
Network calls in global scope 50–500ms Use Lambda Extensions for secret caching

4.2 Bundle Size Impact

The single most controllable cold-start variable for Node.js runtimes is deployment package size. Lambda must download and unzip your package during Init. A naïve dependency tree that pulls the entire aws-sdk v2 monolith adds 4MB+ of unneeded code:

TYPESCRIPT
// ❌ Entire AWS SDK v2 — 4.4MB unzipped, loads hundreds of service clients on require()
const AWS = require('aws-sdk')
const dynamo = new AWS.DynamoDB.DocumentClient()

// ✅ AWS SDK v3 modular — import only what you use
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'
// @aws-sdk/client-dynamodb alone: ~180KB vs 4.4MB for the full v2 SDK

Use esbuild to bundle and tree-shake your deployment artifact:

BASH
# esbuild bundles + tree-shakes + minifies to a single file
npx esbuild src/handler.ts \
  --bundle \
  --minify \
  --platform=node \
  --target=node20 \
  --external:@aws-sdk/* \   # AWS SDK v3 is available in the Lambda runtime — don't bundle it
  --outfile=dist/handler.js

# Before bundling: 45MB node_modules
# After bundling (excluding AWS SDK): 240KB — 187× reduction
Crucial Requirement

Mark @aws-sdk/* as external when bundling for Lambda. The Lambda Node.js 18+ runtime includes the complete AWS SDK v3 — bundling it again adds 2–5MB to your package for zero benefit.

4.3 Provisioned Concurrency

Provisioned Concurrency keeps a configurable number of MicroVM instances perpetually initialized — they remain in the Init-complete state even when no invocations are pending.

TYPESCRIPT
// CDK: Provisioned Concurrency configuration
import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda'
import { Alias } from 'aws-cdk-lib/aws-lambda'

const fn = new Function(this, 'OrderProcessor', {
  runtime: Runtime.NODEJS_20_X,
  handler: 'handler.handler',
  code: Code.fromAsset('dist'),
  memorySize: 1024,
})

const alias = new Alias(this, 'Live', {
  aliasName: 'live',
  version: fn.currentVersion,
  provisionedConcurrentExecutions: 10 // 10 warm instances always available
})
Performance / Safety Warning

Provisioned Concurrency is a reservation, not a guarantee for all traffic. If an API receives a sudden burst of 50 concurrent requests against a pool of 10 provisioned instances, the 11th through 50th requests all cold-start from On-Demand capacity. Provisioned Concurrency eliminates cold starts for steady-state baseline traffic — size it to your predictable P95 concurrency, not your burst peak.

Lambda lifecycle: Cold path (Init→Invoke→Freeze) with Init duration breakdown vs Warm path (Thaw→Invoke→Freeze) bypassing Init entirely; global scope execution highlighted on Init only
Lambda lifecycle: Cold path (Init→Invoke→Freeze) with Init duration breakdown vs Warm path (Thaw→Invoke→Freeze) bypassing Init entirely; global scope executi…

5. Memory Allocation & the vCPU Relationship

Lambda allocates memory in 1MB increments from 128MB to 10,240MB. What most engineers miss: memory is the only vCPU lever available in Lambda. There is no separate CPU configuration.

Memory vCPU allocation Network bandwidth
128 MB 0.08 vCPU Low
512 MB 0.29 vCPU Low
1,024 MB 0.58 vCPU Moderate
1,769 MB 1.0 vCPU High
3,538 MB 2.0 vCPU High
10,240 MB 6.0 vCPU Very High

5.1 The "More Memory = Cheaper" Case

Lambda billing is invocations × GB-seconds. GB-seconds = (memoryMB / 1024) × durationSeconds. For CPU-bound functions, doubling memory can halve execution time — making the total GB-seconds lower at the higher memory tier:

At 512MB:   512/1024 × 0.8s = 0.4 GB-seconds per invocation
At 1024MB:  1024/1024 × 0.4s = 0.4 GB-seconds per invocation  ← same cost, 2× faster!
At 1024MB:  1024/1024 × 0.35s = 0.35 GB-seconds               ← sometimes cheaper

Use AWS Lambda Power Tuning — an open-source Step Functions state machine that runs your function at multiple memory settings and produces cost-vs-performance curves:

BASH
# Deploy Power Tuning via SAR
aws serverlessrepo create-cloud-formation-change-set \
  --application-id arn:aws:serverlessrepo:us-east-1:451282441545:applications/aws-lambda-power-tuning \
  --stack-name power-tuning \
  --capabilities CAPABILITY_IAM \
  --parameter-overrides '[{"Name":"lambdaResource","Value":"*"}]'
Pro Tip & Optimization

Always benchmark with Power Tuning before hardcoding memory settings. The optimal memory setting is function-specific and depends on CPU-bound vs I/O-bound workload ratio. I/O-bound functions (waiting on DynamoDB, SQS) rarely benefit from memory above 512MB. CPU-bound functions (JSON transformation, image processing, crypto) often hit their cost minimum at 1,769MB (1 full vCPU).

5.2 SnapStart (Python & Managed Runtimes)

AWS Lambda SnapStart takes a snapshot of the initialized MicroVM state after Init completes and caches it in a tiered cache (memory → S3). Subsequent cold starts restore from the snapshot rather than running Init from scratch — reducing cold-start latency from 800–3000ms down to sub-second responses.

PYTHON
# Python: SnapStart with snapshot-restore runtime hooks (Python 3.12+)
# Use pre-installed snapshot_restore library to manage snapshot/restore lifecycle
import boto3
from snapshot_restore import register_before_snapshot, register_after_restore

# Initialized at snapshot time (MicroVM Init phase)
dynamodb = boto3.client('dynamodb')

@register_before_snapshot
def before_checkpoint():
    """
    Runs before the MicroVM snapshot is taken.
    Close any connections or sockets that should NOT be serialized into the snapshot.
    """
    global dynamodb
    # Close connection pool before checkpointing
    dynamodb.close()

@register_after_restore
def after_restore():
    """
    Runs after the MicroVM is restored from snapshot (must complete within 2 seconds).
    Re-establish connections and refresh any unique state (entropy, tokens).
    """
    global dynamodb
    dynamodb = boto3.client('dynamodb')

def handler(event, context):
    response = dynamodb.get_item(
        TableName="Orders",
        Key={"orderId": {"S": event["pathParameters"]["id"]}}
    )
    return {
        "statusCode": 200,
        "body": str(response.get("Item", {}))
    }
Performance / Safety Warning

SnapStart snapshots the post-Init state. SDK initialization added after the snapshot is taken — inside the handler body, or in after_restore() — executes on every cold start and does NOT benefit from SnapStart. Move all heavy initialization to module level, before the snapshot checkpoint.

Comparison matrix: On-Demand vs Provisioned Concurrency vs SnapStart across P50/P99 latency, cost per invocation, configuration effort, and ideal use-case profile
Comparison matrix: On-Demand vs Provisioned Concurrency vs SnapStart across P50/P99 latency, cost per invocation, configuration effort, and ideal use-case pr…

6. Practical Optimization Checklist

A senior serverless engineer applies this checklist to every new Lambda function before the first production deployment:

Decision Default (mid-level) Correct (senior)
SDK client placement Inside handler (every invocation) Global scope (once per MicroVM)
Secret/config fetch Inside handler Global scope with TTL cache
Database connection New connection per invocation Connection pool in global scope
Bundle size require('aws-sdk') @aws-sdk/client-* + esbuild
Memory setting 128MB (default) Benchmarked with Power Tuning
Cold-start strategy None Provisioned Concurrency for steady-state baseline
Python / JVM runtime On-Demand (cold) SnapStart
Cleanup None SIGTERM handler for connection draining

Summary

Concept Rule
Global scope execution Runs once per MicroVM Init, not per invocation — SDK clients and secrets belong here
Context reuse MicroVM freezes after handler exits; thaws for next invocation — /tmp and in-memory caches persist
Cold start root causes Runtime bootstrap + bundle size + static initializer duration — all three are controllable
SnapStart Snapshots post-Init MicroVM; initialization inside handler defeats the cache
Memory and vCPU Memory is the only vCPU lever; CPU-bound functions often cost less at higher memory
Provisioned Concurrency Reserves N warm instances; burst beyond N still cold-starts

What's Next

In Part 2: Lambda at Scale — Concurrency Architecture, ESM & Advanced Runtimes, we move from single-function optimization to system-level concurrency design: how the regional concurrency pool works, why one noisy function can throttle your entire account, how Kinesis ESM scales fundamentally differently from SQS ESM, and the BisectBatchOnFunctionError configuration that prevents a single malformed record from blocking a shard for days.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#AWS Lambda#Cold Starts#Serverless#MicroVM#Execution Context#Provisioned Concurrency#AWS
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.