If you want to reduce Lambda cold starts, it can sound like fine-tuning for people with no real problems. Until the first request on Monday morning takes two seconds and someone in the daily asks whether “this Serverless thing” was such a good idea after all. Cold starts are rare, but they hit exactly the moments when somebody is watching.

Two things keep this topic on my mind right now. First, since August 2025 AWS bills the Init phase for all functions, including classic ZIP functions on a managed runtime. A slow start is no longer just a comfort issue. Second, I keep seeing the same causes in TypeScript projects: too much code at startup, unbundled dependencies, clients created inside the handler. In this article we tackle it step by step, with a small example project, measurements, and configuration for both the Serverless Framework and AWS CDK.

In short

The startup time of a Node.js Lambda depends mainly on how much code is loaded and executed at startup. Bundling with esbuild, lean imports and clients outside the handler help the most, and they cost nothing extra. The biggest pitfall: optimizing without measuring the Init Duration first.

What you will learn in this article

  • What happens during a Lambda cold start and which part of it is your code
  • How to measure the Init Duration with CloudWatch Logs Insights
  • How to bundle a TypeScript handler with esbuild
  • When more memory, ARM and provisioned concurrency pay off
  • Which shortcuts do more harm than good

What is a cold start in AWS Lambda?

Lambda runs your code in an execution environment, a small, isolated runtime environment. If no free, already running environment is available for a request, Lambda builds a new one. That is the cold start. Lambda loads your code, starts the runtime and any extensions, and then runs the code that sits outside your handler. Only after that does the handler itself run.

AWS groups the middle steps into the Init phase. For regular on-demand functions it may take at most 10 seconds. According to the AWS documentation, cold starts typically affect fewer than 1% of invocations and last anywhere from under 100 milliseconds to more than a second. You will see them more often in development and test environments, because less traffic keeps the environments warm there.

🎬 Cold start and warm start of a Lambda function compared – this animated diagram only runs on my site: watch it on sascha-ms.de

On a warm start, Lambda reuses an existing environment. Everything you created outside the handler is already in place: loaded modules, SDK clients, open connections. That is why the question “What happens at startup?” matters so much. You are not optimizing every request, only the one that arrives first.

Why it pays to reduce Lambda cold starts since 2025

Until July 2025, the Init phase was not part of the billed duration for on-demand ZIP functions on a managed runtime. Since August 1, 2025, it is. In its announcement, AWS walks through an example: a request with just over 250 ms of duration and just over 100 ms of Init Duration is now billed at 351 ms instead of 251 ms. Nothing changed for functions using container images, custom runtimes or provisioned concurrency, where Init was already part of the bill.

For most workloads the effect is small, because cold starts are rare. Still, take a look if you run many rarely invoked functions, such as webhooks, admin endpoints or nightly jobs. Almost every invocation there is a cold start, and one second of Init is then paid on every call. To estimate the order of magnitude, the Lambda cost calculator helps: run the numbers once with and once without the Init time.

Measure first: find the Init Duration in CloudWatch

Before you change anything, you need a number. At the end of every invocation, Lambda writes a REPORT line to the log. On a cold start, it also contains the field Init Duration. CloudWatch Logs Insights detects this field automatically as @initDuration. The following query shows you, per hour, how many invocations there were, how many of them were cold, and how long the Init phase took on average and at the 95th percentile: How to get structured logs, custom metrics and traces on top of these numbers is covered in my guide to Lambda Powertools TypeScript.

filter @type = "REPORT" | stats count(*) as invocations, count(@initDuration) as coldStarts, avg(@initDuration) as avgInitMs, pct(@initDuration, 95) as p95InitMs, max(@initDuration) as maxInitMs by bin(1h) 

In Logs Insights, select your function’s log group, for example /aws/lambda/bakery-orders-dev-getOrder, and a time range of at least one week. The query works with the classic text log format. If you have switched to JSON logs, the information is in the platform.report event. Write down the p95 value, it is your baseline for everything that follows.

One more thing to keep in mind: if a function times out or crashes, Lambda resets the environment and runs Init again on the next invocation. That time does not show up as a separate Init Duration but is included in the regular duration. Unusually long single invocations after errors can therefore be a hidden cold start, too.

Step by step: reduce Lambda cold starts with TypeScript

The example is a small API for a fictional bakery. One endpoint returns the status of an order from DynamoDB and, on request, a plain-text receipt. It is deliberately small so you can follow the effect of each measure.

Prerequisites

  • Node.js 24 installed locally (matching the nodejs24.x runtime)
  • an AWS account with permissions for Lambda, API Gateway, DynamoDB and CloudFormation
  • Serverless Framework v4 (npm i -g serverless) or alternatively AWS CDK v2
  • about 30 minutes

Step 1: Create the project

Create a folder and install the dependencies. Make sure you only install the SDK packages you actually need. The AWS SDK for JavaScript v3 is modular, and two packages are enough for DynamoDB.

mkdir bakery-orders && cd bakery-orders npm init -y npm pkg set type=module npm i @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm i -D typescript @types/node @types/aws-lambda esbuild 

The tsconfig.json is simple. TypeScript only checks the types here, esbuild does the building later.

// tsconfig.json { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", "types": ["node"] }, "include": ["src", "cdk"] } 

Step 2: Create clients outside the handler

The most important rule for the handler: everything that is the same for every request belongs at the top, in module scope. This code runs exactly once per execution environment during Init. Warm invocations simply keep using the DynamoDB client and its connections. First, a small module with the data type:

// src/orders.ts export interface Order { pk: string; customer: string; items: { name: string; quantity: number; priceCents: number }[]; status: 'received' | 'baking' | 'ready'; } export function totalCents(order: Order): number { return order.items.reduce((sum, item) => sum + item.quantity * item.priceCents, 0); } 

And here is the handler. Look at the imports: instead of a catch-all package, it loads only the DynamoDB client and the document client.

// src/get-order.ts import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; import type { Order } from './orders.js'; // Runs once per execution environment during the Init phase. // Warm invocations reuse the client and its open connections. const tableName = process.env.ORDERS_TABLE ?? 'bakery-orders'; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => { const orderId = event.pathParameters?.orderId; if (!orderId) { return json(400, { message: 'orderId is missing' }); } const { Item } = await db.send(new GetCommand({ TableName: tableName, Key: { pk: `ORDER#${orderId}` } })); if (!Item) { return json(404, { message: `Order ${orderId} not found` }); } const order = Item as Order; if (event.queryStringParameters?.format === 'receipt') { // Loaded on first use only, not during Init. const { renderReceipt } = await import('./receipt.js'); return { statusCode: 200, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: renderReceipt(order) }; } return json(200, { orderId, status: order.status }); }; function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 { return { statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }; } 

Step 3: Load rare code paths only when needed

The receipt is requested rarely. That is why the handler loads the receipt.ts module with await import() only when someone appends ?format=receipt. Its top-level code, in this case creating the number format, then runs on the first invocation that needs it instead of during Init.

// src/receipt.ts // Only needed for the rare "receipt" format, so the handler loads it lazily. import { totalCents, type Order } from './orders.js'; const euro = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }); export function renderReceipt(order: Order): string { const lines = order.items.map( (item) => `${item.quantity} x ${item.name.padEnd(20)} ${euro.format((item.quantity * item.priceCents) / 100)}`, ); return [`Receipt for ${order.customer}`, ...lines, `Total: ${euro.format(totalCents(order) / 100)}`].join('\n'); } 

To be fair, in this tiny example the difference is barely measurable. The pattern becomes really interesting with heavy libraries for PDF generation, image processing or large configuration files that only a fraction of invocations need. One more note: when bundling, the code still ends up in the same file. What you save is the execution at startup, not necessarily the reading.

Step 4: Bundle with esbuild in the Serverless Framework

Now comes the lever that helps the most in practice. Without bundling, Node.js has to find, read and parse every single file from node_modules at startup. When bundling, esbuild combines your code and its dependencies into one file and drops unused code. This is called tree shaking. Serverless Framework v4 ships with esbuild built in. It bundles TypeScript handlers without any extra configuration, but you can control the behavior in the build.esbuild block:

# serverless.yml service: bakery-orders frameworkVersion: '4' provider: name: aws runtime: nodejs24.x architecture: arm64 memorySize: 1024 region: eu-central-1 environment: ORDERS_TABLE: ${self:service}-${sls:stage} iam: role: statements: - Effect: Allow Action: dynamodb:GetItem Resource: !GetAtt OrdersTable.Arn build: esbuild: bundle: true minify: true sourcemap: false functions: getOrder: handler: src/get-order.handler events: - httpApi: method: GET path: /orders/{orderId} resources: Resources: OrdersTable: Type: AWS::DynamoDB::Table Properties: TableName: ${self:service}-${sls:stage} BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: pk AttributeType: S KeySchema: - AttributeName: pk KeyType: HASH 

Three settings are deliberate here. minify: true shrinks the code, because the framework leaves this off by default. sourcemap: false turns off the source maps that are enabled by default. They are invaluable for debugging, but the --enable-source-maps option the framework sets for them costs time whenever stack traces are generated. Weigh that up for your project. And architecture: arm64 runs the function on Graviton processors, which AWS bills at a lower rate than x86.

By the way, the framework does not bundle the AWS SDK. By default it uses the version that ships with the runtime. More on that in a moment.

Step 5: Check bundle size and import time

So you are not poking around in the dark, measure locally. The following script builds the handler in two variants, once with an external SDK and once with the SDK bundled, and lists the largest parts of each bundle:

// scripts/bundle.mjs // Bundles the handler like the Serverless Framework does and lists the largest inputs. import { build } from 'esbuild'; const variants = [ { name: 'sdk-external', external: ['@aws-sdk/*'] }, { name: 'sdk-bundled', external: [] }, ]; for (const variant of variants) { const result = await build({ entryPoints: ['src/get-order.ts'], bundle: true, minify: true, platform: 'node', target: 'node24', format: 'esm', outfile: `dist/${variant.name}/index.mjs`, external: variant.external, metafile: true, mainFields: ['module', 'main'], // Lets bundled CommonJS packages call require() inside an ES module. banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" }, }); const output = Object.values(result.metafile.outputs)[0]; console.log(`\n${variant.name}: ${(output.bytes / 1024).toFixed(1)} KB`); const biggest = Object.entries(output.inputs) .sort(([, a], [, b]) => b.bytesInOutput - a.bytesInOutput) .slice(0, 5); for (const [file, info] of biggest) { console.log(` ${(info.bytesInOutput / 1024).toFixed(1).padStart(7)} KB ${file}`); } } 

The second script starts a fresh Node.js process for each measurement and records how long importing the handler takes. That is not the same as a cold start in Lambda, but it comes quite close to the “your init code” part:

// scripts/measure-init.mjs // Starts a fresh Node.js process per run and measures how long importing a handler takes. import { execFileSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; const [file, runs = '15'] = process.argv.slice(2); if (!file) { console.error('Usage: node scripts/measure-init.mjs <path-to-handler> [runs]'); process.exit(1); } const url = pathToFileURL(resolve(file)).href; const probe = `const t = performance.now(); await import(${JSON.stringify(url)}); console.log(performance.now() - t);`; const samples = []; for (let i = 0; i < Number(runs); i++) { const out = execFileSync(process.execPath, ['--input-type=module', '-e', probe], { encoding: 'utf8' }); samples.push(Number(out.trim())); } samples.sort((a, b) => a - b); const median = samples[Math.floor(samples.length / 2)]; console.log(`${file}: median ${median.toFixed(1)} ms over ${runs} runs (min ${samples[0].toFixed(1)}, max ${samples.at(-1).toFixed(1)})`); 
node scripts/bundle.mjs npx tsc --outDir dist/tsc --noEmit false node scripts/measure-init.mjs dist/tsc/get-order.js 21 node scripts/measure-init.mjs dist/sdk-external/index.mjs 21 node scripts/measure-init.mjs dist/sdk-bundled/index.mjs 21 

My test run with Node.js 22 produced these results: the bundle with an external SDK is only 1.5 KB, yet it still takes about 110 ms to load, because the SDK then comes from many individual files. With the SDK bundled and minified, the bundle is 330 KB and the import takes 68 ms at the median. The plain tsc output without bundling came in at 119 ms.

Bar chart: handler import time drops from 119 ms unbundled to 68 ms with the SDK bundled

Local measurement with the example project: a bundled SDK loads much faster than many individual files.

The absolute numbers say little about your Lambda function. The direction, however, matches what AWS measured in its own post on Node.js dependencies: bundled code using SDK v3 started noticeably faster there than the unbundled variant. Fewer files mean less work for the module loader.

Step 6: Deploy and compare

serverless deploy --stage dev curl https://<api-id>.execute-api.eu-central-1.amazonaws.com/orders/4711 serverless logs -f getOrder --stage dev 

After the first call, the log shows a REPORT line with Init Duration. A second call right afterwards no longer has this field, it was warm. Expect a 404 response as long as the table is empty. For a real test, add an item with pk = ORDER#4711. Then run the Logs Insights query from the measurement section for a few days and compare the p95 value with your baseline.

If you see errors like Cannot find module after deploying, it is almost always a package that is marked as external but never ends up in the package. Check external and exclude in the build block.

The same result with AWS CDK

If you prefer AWS CDK, the NodejsFunction construct handles bundling with esbuild. Here you can also bundle the SDK on purpose. The bundleAwsSDK option makes sure only the parts of the SDK you actually use end up in the bundle. The banner is needed because some bundled packages still use require internally, which does not exist in ES modules otherwise.

// cdk/orders-stack.ts import { App, Stack, type StackProps } from 'aws-cdk-lib'; import { AttributeType, Billing, TableV2 } from 'aws-cdk-lib/aws-dynamodb'; import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'; import type { Construct } from 'constructs'; export class BakeryOrdersStack extends Stack Source:https://dev.to/sascha-ms/reduce-lambda-cold-starts-a-typescript-guide-55km

Related posts