CDK TypeScript Lambda: Choosing a Bundler and cdk synth Runner
A measured benchmark of 9 bundlers and 3 cdk synth runners for CDK TypeScript Lambdas, with a per-layer default and the rule that picks each one.
A CDK TypeScript Lambda project looks like one tooling decision and is really three: the runtime that runs your handler, the bundler that packs it, and the runner that executes cdk synth. These layers are loosely coupled, so each has a different winner. For a greenfield project the defaults are stock nodejs22.x, esbuild through NodejsFunction, and tsx for synth; the rule behind all three is that the closer a layer sits to production runtime, the more boring its tool should be. Nine bundlers and three synth runners, measured on eight realistic handlers, justify each default and mark where you override it. Numbers come from one Linux arm64 sandbox (Node v22.22.3, aws-cdk-lib 2.261.0, medians of two or three runs), so the ratios travel while the absolute milliseconds do not.
The decision table
This benchmark is the measured follow-up to an earlier toolchain map that sorted these Rust tools by layer and left the measurement open. Reading the project as three independent questions is what stops the tools from competing:
| Layer | Question | Greenfield default | Where nub fits |
|---|---|---|---|
| 1. Runtime | What runs the code in Lambda? | stock nodejs22.x | nowhere; nub is not a runtime |
| 2. Bundling | What packs each handler? | esbuild via NodejsFunction + mainFields | no bundler; at most dispatch (nubx esbuild) |
| 3. Synth runner | What runs cdk synth? | tsx | its door into CDK; real home is the dev loop |
The rule that orders these choices is distance from production. Runtime is frozen at stock Node because every one of the nine bundler outputs, including bun build --target node, was byte-verified to run there; a fixed runtime is the precondition for bundler freedom. Build-time can run aggressive Rust tooling because the risk stays inside the build and is smoke-testable before deploy, which is exactly why a bundler swap never touches the runtime. Dev-time is the free-innovation layer: those tools never reach production, so the DX gain is nearly free. Bun is the one tool that rejects this split. Its pitch is one tool everywhere, and taking its build speed means also taking its runtime; that is a deliberate trade, not an oversight, and everything that follows treats it as one.
Layer 1: keep the runtime boring
Every bundler output here ran on stock nodejs22.x, verified by a require() plus a real event invoke, with no custom runtime or layer. Even bun build --target node produced plain JavaScript with no Bun.* references, so it too runs on Node. That uniformity is what makes the other two layers safe to experiment in: nothing you choose at build time changes what AWS actually executes. Running Bun as a runtime through a custom runtime layer or an OCI image is possible but out of scope here, and the cold-start numbers later rarely justify it. Freezing the runtime is not conservatism; it is the precondition that lets you move fast everywhere else.
Layer 2: module resolution beats the bundler
The largest size lever is not which bundler you pick, but how it resolves the AWS SDK. Default esbuild with platform=node resolves AWS SDK v3 from dist-cjs, and CommonJS cannot be tree-shaken, so whole modules ship even when nothing calls them (for example bowser, about 36.7 KB). Forcing --main-fields=module,main resolves from dist-es, the ESM build, which tree-shakes: the total dropped from 3,136 KB to 2,357 KB (about 25 percent), bowser disappeared, and @smithy/core came out roughly 35 KB lighter. Same bundler, one flag.
CDK exposes the flag directly on NodejsFunction:
new NodejsFunction(this, 'OrdersHandler', {
runtime: Runtime.NODEJS_22_X,
entry: 'lambda/handlers/orders.ts',
bundling: {
format: OutputFormat.CJS, // CDK default; kept explicit
mainFields: ['module', 'main'], // resolve AWS SDK from dist-es -> smaller
minify: true,
},
});
The caveat is that mainFields is global, not SDK-only. Dual-package libraries can misresolve their entry point, so smoke-test the output rather than assume. To see what actually landed in a bundle, read esbuild’s --metafile (which reports bytesInOutput per package), not a sourcemap:
node_modules/.bin/esbuild lambda/handlers/orders.ts --bundle --platform=node \
--target=node22 --minify --main-fields=module,main \
--outfile=dist/orders.js --metafile=meta.json
With resolution fixed, here is the whole field on the same eight handlers, same standard (self-contained CJS per handler, node22, minified, sourcemaps off, all dependencies inlined, no shared chunks):
| Bundler | Build (median) | Total | Gzip | Verdict |
|---|---|---|---|---|
| bun build 1.3.14 | 253 ms | 3,148 KB | 906 KB | fastest build; needs a ~90 MB binary |
| esbuild 0.25 (default) | 702 ms | 3,136 KB | 918 KB | CDK default; shrinks with the flag |
esbuild + mainFields | ~700 ms | 2,357 KB | 709 KB | recommended; -25% for free |
| rspack 2.1.3 | 1,150 ms | 2,170 KB | 630 KB | best size/speed balance |
| tsup 8.5.1 | 1,151 ms | 2,385 KB | 711 KB | esbuild wrapper; ESM resolve by default |
| rolldown 1.1.5 | 1,853 ms | 2,993 KB | 859 KB | immature for node-CJS today |
| tsdown 0.22.4 | 2,648 ms | 2,993 KB | 859 KB | byte-identical to rolldown |
| webpack 5.108 | 11,567 ms | 2,570 KB | 707 KB | no reason over rspack |
| rollup 4.62 + terser | ~92,000 ms | 2,118 KB | 610 KB | smallest, slowest (n=1) |
| parcel 2.16.4 | failed | — | — | structurally unfit here |
The wiring lesson repeats one level down, at the minifier. Standalone minify passes over the same 1.2 MB unminified bundle landed between 653 and 674 KB, whichever minifier ran (oxc-minify 33 ms, esbuild 64 ms, terser about 2 s), while esbuild’s integrated bundle-plus-minify output of the same code came out at 479 KB — smaller than every after-the-fact pass. During bundling the minifier still sees cross-module scope, so it can shorten top-level names a later pass has to leave alone. As with mainFields, the size lever is not which tool you pick but where it sits in the pipeline: minifying at bundle time beats bolting a better minifier on afterwards.
The branches off the default map to named conditions:
Each recommendation carries a trade-off. esbuild plus mainFields is the cheapest path and the reason tsup came out smaller than plain esbuild (tsup defaults to ESM resolution), but the flag is global, so dual-package libraries need a smoke test. rspack is the best size and speed balance and is webpack-config compatible, which makes it the drop-in for teams already on webpack, but it adds a config layer a greenfield project does not need. rollup plus terser produced the smallest artifact yet took roughly 80 to 130 times esbuild’s build; that ~92 second figure is a single partial run, so read it as directional rather than precise, and reach for it only when CI time is cheap and the function count is huge. bun build was fastest at 253 ms but carries a roughly 90 MB binary dependency and a larger output, so it pays off only when you already run Bun. rolldown and tsdown share one engine and came out byte-identical; both are simply not yet tuned for the self-contained node-CJS target as of this benchmark, which is a maturity note, not a defect. parcel failed outright against five independent blockers (broken scope hoisting, zod wrongly excluded, forced separate chunks for dynamic imports, custom-target resolution, and a uuid exports-map error), which is a structural mismatch with per-handler Lambda bundling, not a tuning problem.
Layer 3: the cdk synth runner
All three synth runners produce byte-identical CloudFormation, which is verified, not assumed. Because the template output is fixed, the only axis that matters is speed under the aws-cdk-lib graph:
| Runner | Raw TS start | CDK app load | cdk synth |
|---|---|---|---|
| tsx | 254 ms | 777 ms | ~1.7 s |
| nub 0.4.8 | 112 ms | 1,128 ms | ~2.2 s |
| ts-node | 864 ms | 6,975 ms | ~9.6 s |
On a bare file nub starts fastest, about 2.3 times tsx. But once aws-cdk-lib’s thousands of files load, nub’s loader hooks add cost and tsx pulls ahead by roughly 25 percent. Two practical reads follow. First, default to tsx for cdk synth. Second, if you are still on ts-node, either tsx or nub is a 5 to 8 times win, so migrate. When bundling dominates the run (eight functions synthed with bundling), the runner gap shrinks: tsx around 4.6 s versus nub around 5.4 s.
Nub’s real home is the dev loop, not synth. There it starts TypeScript in 112 ms and folds .env loading, tsconfig paths, Node-version pinning, and a phantom-dependency warning into one tool. It has no build command, so the only realistic way to put it in the bundling path is dispatch: call nub exec esbuild (nubx) inside Code.fromAsset local bundling instead of npx esbuild. Dispatch overhead per call was direct binary about 0, nubx plus 78 ms, and npx plus 167 ms. So nubx roughly halves the dispatch tax over npx, but calling the binary directly is still fastest; nubx earns its slot through lockfile-aware resolution and an osv.dev malicious-package check, not raw speed.
Cold start is the bundle, not the bundler
Once you ship a bundle, the runner never reaches production, and cold-start init is set by the bundle’s parse-and-init cost. Proxying the Lambda init phase by require()-ing each bundle on stock Node 22 gives:
| Bundle source | Size | Init (median) |
|---|---|---|
| esbuild, small handler | 60 KB | 2.5 ms |
| rollup, s3-report | 389 KB | 26 ms |
| rspack, s3-report | 397 KB | 27 ms |
esbuild + mainFields, s3-report | 479 KB | 39 ms |
| tsup, s3-report | 485 KB | 39 ms |
| rolldown, s3-report | 577 KB | 48 ms |
| esbuild default, s3-report | 622 KB | 54 ms |
| bun, s3-report | 639 KB | 51 ms |
| unbundled (deps from node_modules) | — | 141 ms |
Three things follow. Init scales almost linearly with size here, roughly 60 to 85 microseconds per KB, so bundler choice moves cold start directly: rollup and rspack output opens about 27 ms faster per function than default esbuild. The biggest single win, though, is bundling at all: loading the same dependencies from node_modules inits at 141 ms against 26 to 54 ms bundled, so the “zip the folder and ship it” approach loses 3 to 5 times on cold start, and this is the lever most teams skip. Finally, the fastest bundler is not the fastest cold boot: bun bundles in 253 ms but its 639 KB output is among the slowest to init at about 51 ms. Build speed serves the builder; output size serves the user.
The picture inverts for a long-running container. On ECS, Fargate, or EC2 the runner does execute on every cold boot, and the tax is real:
| Startup | JS file | TS file |
|---|---|---|
| node | 26 ms | — |
| bun | 60 ms | 62 ms |
| nub | 130 ms | 112 ms |
| tsx | 227 ms | 254 ms |
| ts-node | — | 864 ms |
Node’s TS column is blank because that path went unmeasured; current Node 22 does run erasable-syntax TypeScript directly, with type stripping on by default since 22.18. Bun’s runtime claim is real in this context, starting TypeScript in 62 ms, but that is Bun’s runtime rather than stock Node, and the API-compatibility risk comes with it. The boring answer still wins: transpile or bundle at build time and start with node dist/, which zeroes the runner tax entirely. A runner belongs in the dev loop and in synth, never in a production container.
Common pitfalls
Four failure modes showed up during measurement, and each is a universal trap rather than a one-off.
A runner swap can change module semantics. Given the same .ts file, nub loaded it as ESM while tsx loaded it as CJS, so __dirname was defined under tsx and undefined under nub. The failure looked fast because synth still produced output; the handler was simply wrong. Any time you change the runner, re-verify behavior through asset count and template diff, not wall-clock alone.
CDK’s staging cache can collapse per-function assets. When eight functions share one source directory and differ only inside a tryBundle closure, CDK hashes the directory, sees one input, and stages a single asset, so all eight deploy identical code, silently. The fix is a content-based AssetHashType.CUSTOM so each function derives its hash from its own entry file (this runs during synth on Node, so node:crypto and node:fs are the correct imports):
lambda.Code.fromAsset(handlersDir, {
assetHashType: AssetHashType.CUSTOM,
assetHash: crypto
.createHash('sha256')
.update(`nubx-esbuild:${entryName}:`)
.update(fs.readFileSync(entryPath))
.digest('hex'),
bundling: {
image: DockerImage.fromRegistry('dummy-not-used'),
local: { tryBundle: (outputDir) => runEsbuild(entryPath, outputDir) },
},
});
Phantom dependencies run until they do not. An import of a package that is hoisted into node_modules but absent from package.json runs fine under npx or node, then breaks the day hoisting changes. nub surfaced it as WARN_PHANTOM_DEP; the plain runners stayed silent. That warning is a correctness gate you get for free.
Vendor ratios do not survive contact with your project. The nub repository cites nubx as about 19 times faster than npx; in this CDK dispatch scenario it measured about 2.1 times (nubx plus 78 ms versus npx plus 167 ms per call). The headline depends entirely on the baseline and cache state. Treat any single-number speed claim, including the absolute-millisecond columns here, as scoped to the machine that produced it.
The default holds for most CDK TypeScript Lambda projects: freeze the runtime at nodejs22.x, bundle with esbuild through NodejsFunction and mainFields: ['module', 'main'], and synth with tsx. Reach for an override only on a named condition: rspack when you are migrating off webpack or cold start is critical across hundreds of functions, rollup plus terser only when CI time is cheap and the artifact must be as small as possible, and Bun’s toolchain only when you have already accepted Bun as your runtime. The single setup that rejects this split is Bun’s one-tool-everywhere, and the trade is explicit: its build speed comes bundled with its runtime. Everything else stays layered, and each layer stays as boring as its distance from production demands. The cheapest first step is to add mainFields to your existing NodejsFunction and diff the bundle with --metafile before touching the bundler at all.
References
- nubjs/nub (GitHub) - The fast all-in-one Node.js toolkit; source of the nubx-vs-npx speed claim and the augment-not-replace stance.
- Nub official site - Positioning and docs; nub augments stock Node rather than replacing it.
- esbuild: Main fields - Why
mainis CommonJS andmoduleis ESM, and why ESM leads to better tree shaking. - CDK NodejsFunction BundlingOptions.mainFields - The CDK prop; the docs suggest
['module', 'main']to prefer ES module versions. Default is empty. - Modular packages in AWS SDK for JavaScript - Why the SDK ships both dist-cjs and dist-es, and why CJS resolution defeats tree shaking.
- Optimizing Node.js dependencies in AWS Lambda - Bundling and tree-shaking to shrink the Lambda artifact and cold start; recommends esbuild.
- Rspack: Migrate from webpack - Drop-in webpack compatibility with the same plugin names and config params; grounds the config-compatible claim.
- Rolldown - VoidZero’s Rust bundler; project home for its maturity and CJS-target status.
- Understanding the Lambda execution environment lifecycle - The Init phase downloads code and runs initialization outside the handler; grounds the cold-start and package-size link.
- aws-cdk aws-lambda-nodejs bundling performance (issue #10286) - The Parcel bundling performance report that preceded CDK’s switch to esbuild.
- Nub vs Vite+ (sibling post) - The toolchain map that sorted these Rust tools by layer; this benchmark is its measured step, scoped to CDK.
Related posts
AppSync subscriptions fire only on mutations. This explores bridging downstream BFF events into a NONE-data-source mutation with EventBridge and CDK.
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
How to slice AWS Lambda functions: default to single-purpose, treat the single-domain Lambdalith as an earned exception, and the platform forces that decide it.
DI containers, monolithic SDKs, god-handlers, top-level secret fetches, and heavy ORMs - what they cost on cold start, and the functional shape that replaces them.
Run Bun and Deno on AWS Lambda with custom runtimes: real performance benchmarks, cost analysis, and production deployment patterns.