Node.js Serverless on AWS Lambda · Lambda Fundamentals for Node.js
Bundling, ESM and Layers
Why a 60 MB deployment package slows every cold start, and how to bundle a Node.js function properly.
Scenario
Example app: a team zips their function folder with node_modules (including dev dependencies, the full AWS SDK and a date library with every locale) and uploads it. Each function shares a layer with "common utilities".
What you see in production
Cold starts take over a second for a function whose handler takes 30 ms. Deployments are slow, the package is 60 MB, and one release broke because the layer was updated independently of the function that relied on its old behaviour.
The naive code
zip -r function.zip . # everything: node_modules, tests, docs, dev dependencies
aws lambda update-function-code --function-name orders --zip-file fileb://function.zip
Reproduce it
Cold-start init time grows with the amount of code Node has to load and evaluate. This model compares the naive package with a bundled, tree-shaken one:
// kb: size in the package; zipEval / bundleEval: kb of code evaluated at init in each packaging style
const modules = [
{ name: "handler + business logic", kb: 40, zipEval: 40, bundleEval: 40 },
{ name: "@aws-sdk/client-dynamodb", kb: 2400, zipEval: 380, bundleEval: 220 },
{ name: "date library with every locale", kb: 1900, zipEval: 1900, bundleEval: 60 },
{ name: "lodash (whole library imported)", kb: 540, zipEval: 540, bundleEval: 12 },
{ name: "test frameworks and dev dependencies", kb: 52000, zipEval: 0, bundleEval: 0 },
{ name: "docs, tests, source maps", kb: 6100, zipEval: 0, bundleEval: 0 },
];
const msPerMbDownloaded = 12, msPerMbEvaluated = 90; // illustrative
const zipMb = modules.reduce((s, m) => s + m.kb, 0) / 1024;
const zipEvalMb = modules.reduce((s, m) => s + m.zipEval, 0) / 1024;
const bundleMb = modules.reduce((s, m) => s + m.bundleEval, 0) / 1024; // a bundle contains only what it evaluates
const ms = (download, evaluated) => Math.round(download * msPerMbDownloaded + evaluated * msPerMbEvaluated);
console.log(`zip everything: package ${zipMb.toFixed(1)} MB, init ~${ms(zipMb, zipEvalMb)} ms`);
console.log(`esbuild bundle: package ${bundleMb.toFixed(1)} MB, init ~${ms(bundleMb, bundleMb)} ms`);
Output
zip everything: package 61.5 MB, init ~989 ms esbuild bundle: package 0.3 MB, init ~33 ms
Root cause
Lambda must download and unpack the package and Node must resolve, read and evaluate every module imported at init. Shipping dev dependencies and whole libraries increases download size and, more importantly, the code evaluated at start-up. Layers shared between functions add another versioned artifact whose updates are not tied to the function's own tests.
The fix
Bundle each function with esbuild (directly, or through SAM, CDK NodejsFunction, or the Serverless Framework): one minified file with only the code paths actually imported, as ESM, with source maps for readable stack traces.
// build.mjs
import { build } from "esbuild";
await build({
entryPoints: ["src/handlers/orders.ts"],
bundle: true,
minify: true,
sourcemap: true,
platform: "node",
target: "node22",
format: "esm",
outfile: "dist/orders/index.mjs",
banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" },
});
Import modular SDK v3 clients (@aws-sdk/client-dynamodb), not whole libraries; prefer small, tree-shakable dependencies. Decide deliberately whether to bundle the AWS SDK: the Node.js runtimes include SDK v3, but bundling your own pins the version you tested. Use layers for genuinely shared, independently versioned content (for example the Powertools layer or native binaries), not as a substitute for bundling. Container images (up to 10 GB) suit very large dependencies such as ML models.
Trade-offs
- Bundling hides
node_modulesstructure; dynamicrequireand native modules may needexternalsettings. - Minified code needs source maps (enable
NODE_OPTIONS=--enable-source-maps) for readable stack traces, at a small runtime cost. - Container images give more room and familiar tooling but larger artifacts; Lambda caches them, yet first pulls can be slower.
Detect it in production
- Track deployment package size per function in CI and fail builds above a budget.
- Compare
Init Durationbefore and after dependency changes. - Use bundle analysers (esbuild's metafile with an analyser) to see what dominates the bundle.
Key points
- Bundle each function with esbuild; ship only imported code.
- Use modular SDK v3 clients and small dependencies.
- Layers are for shared, versioned content, not a substitute for bundling.
- Watch package size and init duration in CI.
Further reading: Deploying Node.js Lambda functions with .zip file archives · Managing Lambda dependencies with layers
Practice questions
Why does package size affect cold-start time?
Answer
On a cold start Lambda downloads and unpacks the package and Node loads and evaluates every module imported during init. More and larger modules mean more work before the first request can be served.
When are Lambda layers a good choice, and when are they not?
Answer
Good for genuinely shared, independently versioned content such as runtime extensions, Powertools or native binaries. Poor as a replacement for bundling application dependencies, because a layer updated separately can change behaviour without the function's tests running.
Should you rely on the AWS SDK included in the Node.js runtime?
Answer
It works and keeps packages small, but the version can change when AWS updates the runtime. Bundling your own SDK v3 clients pins the version your tests ran against; many teams bundle for production predictability.
Open in the app to mark lessons complete, take notes and track your progress.