Skip to content
Ayhan Sipahi Ayhan Sipahi

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:

LayerQuestionGreenfield defaultWhere nub fits
1. RuntimeWhat runs the code in Lambda?stock nodejs22.xnowhere; nub is not a runtime
2. BundlingWhat packs each handler?esbuild via NodejsFunction + mainFieldsno bundler; at most dispatch (nubx esbuild)
3. Synth runnerWhat runs cdk synth?tsxits 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):

BundlerBuild (median)TotalGzipVerdict
bun build 1.3.14253 ms3,148 KB906 KBfastest build; needs a ~90 MB binary
esbuild 0.25 (default)702 ms3,136 KB918 KBCDK default; shrinks with the flag
esbuild + mainFields~700 ms2,357 KB709 KBrecommended; -25% for free
rspack 2.1.31,150 ms2,170 KB630 KBbest size/speed balance
tsup 8.5.11,151 ms2,385 KB711 KBesbuild wrapper; ESM resolve by default
rolldown 1.1.51,853 ms2,993 KB859 KBimmature for node-CJS today
tsdown 0.22.42,648 ms2,993 KB859 KBbyte-identical to rolldown
webpack 5.10811,567 ms2,570 KB707 KBno reason over rspack
rollup 4.62 + terser~92,000 ms2,118 KB610 KBsmallest, slowest (n=1)
parcel 2.16.4failedstructurally 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:

Yes

No, on webpack

No, on Bun runtime

No

Yes

Extreme, CI time cheap

Pick a Lambda bundler

Greenfield CDK project?

esbuild via NodejsFunction + mainFields

rspack, config-compatible

bun build

Cold start critical at scale?

Keep esbuild default

rollup + terser, smallest

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:

RunnerRaw TS startCDK app loadcdk synth
tsx254 ms777 ms~1.7 s
nub 0.4.8112 ms1,128 ms~2.2 s
ts-node864 ms6,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 sourceSizeInit (median)
esbuild, small handler60 KB2.5 ms
rollup, s3-report389 KB26 ms
rspack, s3-report397 KB27 ms
esbuild + mainFields, s3-report479 KB39 ms
tsup, s3-report485 KB39 ms
rolldown, s3-report577 KB48 ms
esbuild default, s3-report622 KB54 ms
bun, s3-report639 KB51 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:

StartupJS fileTS file
node26 ms
bun60 ms62 ms
nub130 ms112 ms
tsx227 ms254 ms
ts-node864 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

Related posts