Photo by Mohammad Rahmani on Unsplash
To reduce AWS SDK v3 Lambda bundle size, bundle the handler with esbuild using bundle, minify, platform: "node", format: "esm" and mainFields: ["module", "main"], import only the clients and commands you call, and deploy the single output file instead of node_modules. In our test, a DynamoDB handler went from 7.06 MiB of dependencies to a 321.2 KiB bundle.
Most advice on the aws sdk v3 lambda bundle size is a list of settings with no numbers. This guide is for Node.js and TypeScript developers deploying Lambda functions who want to know which settings actually matter. You’ll get the handler and the esbuild script we used, the sizes we measured for each variant, a runtime error you’ll hit with ESM output and its fix, and a CloudWatch Logs Insights query to track cold starts in your own account.
All numbers below were measured on 28 September 2026 with @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb 3.1141.0, esbuild 0.28.2 and Node.js 24.15.0. Every bundle was run end to end against a local fake DynamoDB endpoint to confirm it works. They weren’t measured inside Lambda, so treat the load-time figures as a relative guide.
Should you use the SDK in the Lambda runtime or bundle your own?
Every supported Lambda Node.js runtime (nodejs22.x, nodejs24.x and nodejs26.x as of September 2026) includes a specific minor version of AWS SDK for JavaScript v3, which depends on the runtime version and Region. You can mark @aws-sdk/* as external and ship almost nothing:
| Runtime-included SDK | Bundled SDK | |
|---|---|---|
| Your deployment package | Your code only (0.5 KiB in our test) | Your code plus the SDK code you use (321.2 KiB) |
| SDK version | Chosen by AWS, can change with runtime updates | Pinned by your lockfile |
| Newest APIs and fixes | Only when the runtime catches up | As soon as you upgrade |
| Same code in tests and production | No | Yes |
The Lambda Node.js handler docs strongly recommend including the SDK clients you need in the deployment package, for compatibility with future runtime updates, and relying on the runtime copy only when you can’t add packages. We follow that here. If you still ship aws-sdk v2, start with migrating a Node.js app from AWS SDK v2 to v3; no supported Node.js runtime includes v2.
Prerequisites
- Node.js 18 or later locally, and a function on
nodejs22.xor later with the handler set toindex.handler. esbuild,typescriptand@types/nodeas dev dependencies, and the SDK clients your handler imports as dependencies.- Handlers already written against SDK v3 commands. For v2 files, the free AWS SDK JavaScript v2 to v3 converter produces a first draft to review.
How to cut your AWS SDK v3 Lambda bundle size, step by step
- Import clients, not the whole SDKInstall one
@aws-sdk/client-*package per service and import the client class and the commands you send. - Bundle with esbuildSet
bundle: true,platform: "node", atargetthat matches your runtime andformat: "esm". - Prefer the ES module buildsSet
mainFields: ["module", "main"]. Forplatform: "node"esbuild’s default ismain,module, which picks the CommonJS builds that tree-shake poorly. - Minify
minify: trueshrinks identifiers and whitespace. - Add a require shim to ESM outputA one-line banner that defines
requirewithcreateRequire, explained below. - Check what went inWrite the metafile and print
analyzeMetafileto see the largest inputs. - Measure Init DurationCompare the
REPORTlines before and after you deploy.
Example: the handler and the esbuild build script
The test handler reads one item with the DynamoDB document client, the same pattern as in querying DynamoDB with AWS SDK v3:
// src/handler.ts: reads one order from DynamoDB by id.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
// Created once per execution environment, during Init, and reused by every invocation.
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME ?? "orders";
interface OrderEvent {
orderId: string;
}
export const handler = async (event: OrderEvent): Promise<{ statusCode: number; body: string }> => {
const { Item } = await ddb.send(new GetCommand({ TableName: TABLE, Key: { pk: event.orderId } }));
return Item
? { statusCode: 200, body: JSON.stringify(Item) }
: { statusCode: 404, body: JSON.stringify({ message: "not found" }) };
};
// build.mjs: bundle a Lambda handler with esbuild and report what went into it.
// Usage: node build.mjs [--external-sdk]
import { build, analyzeMetafile } from "esbuild";
import { writeFileSync } from "node:fs";
const externalSdk = process.argv.includes("--external-sdk");
const result = await build({
entryPoints: ["src/handler.ts"],
outfile: "dist/index.mjs",
bundle: true,
minify: true,
sourcemap: "linked",
platform: "node",
target: "node22",
format: "esm",
// Prefer each package's ES module build, which esbuild can tree-shake.
mainFields: ["module", "main"],
// Leave the SDK out and use the copy in the Lambda runtime, if you choose to.
external: externalSdk ? ["@aws-sdk/*"] : [],
// Some bundled CommonJS code calls require("node:https"); give ESM output a real require.
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
metafile: true,
logLevel: "info",
});
writeFileSync("dist/meta.json", JSON.stringify(result.metafile));
console.log(await analyzeMetafile(result.metafile, { verbose: false }));
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev esbuild typescript @types/node
# Bundle, then zip only the output (no node_modules)
node build.mjs
cd dist && zip -q ../function.zip index.mjs && cd ..
# Deploy: runtime nodejs22.x or later, handler "index.handler"
aws lambda update-function-code --function-name orders-get --zip-file fileb://function.zip
The esbuild API documentation describes mainFields, external wildcards and the metafile in detail.
What did each setting change? Our measurements
Sizes of the single output file, and of that file zipped with deflate, for the same handler unless noted:
| Build | Output | Zipped |
|---|---|---|
No bundling: handler plus node_modules for the two SDK packages |
7.06 MiB in about 3,200 files | 2.61 MiB |
esbuild, minified, default mainFields |
513.4 KiB | 151.0 KiB |
esbuild, minified, mainFields: ["module", "main"] |
321.2 KiB | 99.5 KiB |
| Same, not minified | 817.8 KiB | 157.2 KiB |
@aws-sdk/* external (runtime SDK) |
0.5 KiB | 0.4 KiB |
Aggregated DynamoDBDocument class instead of DynamoDBDocumentClient plus GetCommand |
368.6 KiB | 111.6 KiB |
DynamoDBClient plus GetItemCommand, no document client |
309.9 KiB | 96.0 KiB |
Aggregated DynamoDB class with getItem |
350.2 KiB | 108.3 KiB |
S3Client plus HeadObjectCommand |
394.4 KiB | 120.8 KiB |
| DynamoDB document client and S3 in one handler | 445.4 KiB | 134.3 KiB |
For comparison: SDK v2, aws-sdk/clients/dynamodb only (2.1693.0) |
373.5 KiB | 97.7 KiB |
For comparison: SDK v2, whole aws-sdk import |
9,538.8 KiB | 1,195.6 KiB |
Three results stood out. The mainFields setting mattered most, cutting 37% on its own. With the ES module builds, the command style finally pays: the aggregated classes added 40 to 47 KiB, while with the default mainFields the aggregated and command styles came out within a few dozen bytes of each other. And a v2 single-client import was only about 16% larger than our best v3 DynamoDB bundle, so the big gap is against the whole v2 SDK, not against a careful v2 import.
The metafile shows where the rest goes. In our 321.2 KiB bundle, @smithy/core contributed 108.7 KiB, @aws-sdk/nested-clients (the STS and SSO clients the credential providers use) 53.9 KiB, @aws-sdk/core 48.8 KiB and @aws-sdk/client-dynamodb itself only 26.1 KiB. That shared core is why a second client costs less than the first: adding S3 to the DynamoDB handler added 124.2 KiB, not the 394.4 KiB an S3-only bundle weighs.
Why does an ESM bundle fail with “Dynamic require is not supported”?
Parts of the SDK dependency tree are CommonJS and call require() for Node.js built-ins. esbuild keeps those calls, but an ES module has no require. Without the banner, importing our bundle from an ES module threw during initialization:
Error: Dynamic require of "node:https" is not supported
The banner defines require at the top of the file with Node.js module.createRequire, which fixes it. A quick node -e "import('./dist/index.mjs')" check didn’t show the problem, because node -e provides a global require; only loading it from a real .mjs file did. Invoke every bundle once from an ES module before you ship it. Alternatively, emit format: "cjs" to an index.js file and skip the banner.
How do you measure cold starts in Lambda?
Every invocation writes a REPORT line to the function’s log group, the same one you read to investigate Lambda errors with CloudWatch. On the first request an execution environment serves, it includes Init Duration: the time the runtime took to load the function and run code outside the handler. That’s the number bundle size affects. With the default text log format, this Logs Insights query summarizes it per day:
filter @type = "REPORT"
| parse @message /Init Duration: (?<initMs>[\d.]+) ms/
| stats count(*) as invocations, count(initMs) as coldStarts, avg(initMs) as avgInitMs, pct(initMs, 95) as p95InitMs, max(initMs) as maxInitMs by bin(1d)
Run it for a week before and after the change, from the console or with the helper in running CloudWatch Logs Insights queries with AWS SDK v3. With the JSON log format, the report arrives as a structured platform event rather than a text line, so adjust the filter and field names.
As a local proxy, we timed a cold import() of each variant in a fresh Node.js 24.15.0 process on a Windows 11 laptop, 25 runs each. Medians: 34.8 ms for the module,main bundle, 40.7 ms for the default-mainFields bundle and 52.3 ms for the unbundled handler loading from node_modules (an empty module took 1.9 ms). Lambda’s numbers will differ; the ordering is what to expect, and Init Duration in your own logs is the number to trust.
What else lowers Init Duration?
- Create clients outside the handler. The Lambda docs recommend it: the client is built once during Init and reused by later invocations in the same environment. Creating clients inside the handler repeats that work on every request.
- Don’t disable keep-alive. It’s on by default in all supported Node.js runtimes, which saves a new TCP and TLS connection per SDK call. Set sensible timeouts too, as in AWS SDK v3 retries and timeouts in Lambda.
- Defer rarely used clients. A dynamic
import()inside the branch that needs a client delays running its module code until that branch. Without code splitting, esbuild still puts that code in the same file, so check the metafile. - Revisit memory. Lambda allocates CPU in proportion to memory, which can change Init time too. Finding Lambda functions with too much memory shows the other side of that trade-off, and finding functions not yet on arm64 covers the architecture switch; a pure JavaScript bundle like this one runs unchanged on both.
Common mistakes and how to fix them
- Bundling but still zipping
node_modules. The bundle already contains the SDK. Zip onlydist/index.mjs. - Marking
@aws-sdk/*external without meaning to. Some build tools do this by default for newer runtimes; check your config. Your code then runs against the runtime’s SDK version, not the one you tested. - Handler not found after switching to ESM. The file must be
index.mjs, orindex.jswith"type": "module"in apackage.jsonshipped alongside it. - A target newer than the runtime. Keep esbuild’s
targetat or below the runtime’s Node.js version. Upgrade old functions first; find Lambda functions on deprecated runtimes lists them. - Unit tests that import the bundle. Test the source instead and mock AWS SDK v3 clients in Jest or Vitest; mocks patch the SDK in
node_modules, not the copy inside a bundle.
Limits of bundle size tuning
- Our numbers are for one small handler and one SDK version. Your bundle will differ; measure it with the metafile.
- A smaller bundle shortens loading, not your own initialization work such as fetching secrets or opening database connections.
- Cold starts only affect requests that land on a new execution environment. For latency-critical paths, provisioned concurrency removes them at a price. The script to find unused Lambda provisioned concurrency shows when that price stops paying off.
- Bundling doesn’t reduce IAM or runtime risks. Old function versions still count toward your code storage, so delete old and unused Lambda function versions after you redeploy.
To check how your functions are configured today without writing a script, ChatWithCloud answers questions such as “Which Lambda functions run nodejs22.x and how much memory do they have?” from your terminal; how ChatWithCloud runs read-only AWS SDK queries explains what it sends and where the code runs.
Frequently asked questions
Does the Lambda Node.js runtime include AWS SDK v3?
Yes. All supported Node.js runtimes include a specific minor version of SDK v3, which depends on the runtime and Region. The Lambda docs still recommend bundling the clients you use so runtime updates can’t change them under you.
Should I mark @aws-sdk as external in esbuild?
Only if you accept whichever SDK version the runtime has. It gives the smallest package, 0.5 KiB in our test, but your tested and deployed SDK versions can drift apart.
Is AWS SDK v3 smaller than v2 in Lambda?
Much smaller than importing all of v2 (9.3 MiB bundled in our test), but a v2 single-client import was 373.5 KiB against 321.2 KiB for our v3 DynamoDB bundle. The bigger reasons to move are that v2 reached end of support and isn’t in any supported Node.js runtime.
Where do I see Lambda cold start time?
In the Init Duration field of the REPORT line in the function’s CloudWatch log group. It appears only for the first request an execution environment serves.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud