How fast do Bun, LLRT and Node.js cold-start on AWS Lambda?
110 real Lambdas, benchmarked every hour.
🌐 Live results · 🚀 Deploy · ⏱️ Benchmark · 🕐 Scheduled runs · 🛠️ Develop
One SST stack. Every function runs the same handler (src/report.ts) and gets a
public Function URL, on x86_64 and arm64 at 128, 256, 512, 1024 and 1769 MB:
11 variants × 2 architectures × 5 sizes = 110 functions, named like BunCompileArm64M128.
| Variant | What ships | |
|---|---|---|
| 🥟 | BunRuntime |
bun executable + bundled index.js, started by a shell bootstrap |
| 🥟 | BunCompile |
bun build --compile --minify --bytecode executable as bootstrap |
| 🥟 | BunCompileCanary |
the same, built by Bun canary: the baseline for --trim and --aot |
| 🥟 | BunCompileTrim |
BunCompileCanary + --trim, which drops the parts of Bun the code doesn't use |
| 🥟 | BunCompileAot |
--compile --aot, behind a bootstrap that preloads the AOT shim |
| 🥟 | BunCompileAotTrim |
--aot --trim |
| 🪶 | Llrt |
LLRT's no-sdk Lambda build + bundled index.mjs |
| 🪶 | LlrtBytecode |
the bundle as llrt compile bytecode, run by LLRT's CLI build |
| 🟢 | Node22 · Node24 · Node26 |
SST's esbuild bundle on nodejs22.x, nodejs24.x, nodejs26.x (preview) |
Bun and LLRT run on provided.al2023. Bun serves invocations through a small runtime loop
(src/runtime.ts), LLRT through its own (src/llrt.ts), and Node
through Lambda's managed runtime.
bun install
cp .env.example .env # add AWS_PROFILE or access keys
bun run deploy --stage dev # builds both architectures in Docker, deploys all 110 functions
bun run remove --stage dev # tears it down again (production is retained)- 🔁
bun run just-deploydeploys the bundles already indist/without building them again. - 🎯
bun run build --arch arm64 BunCompileAotbuilds only the variants you name, intodist/<arch>/<Variant>. - 🐳 Builds run in Amazon Linux 2023 containers, Lambda's own OS, with Bun 1.4.4 and the latest
canary (
infra/build-image.sh). The image is cached until that script changes;docker rmi $(docker images -q lambda-coldstarts-build)picks up a newer canary. - 🦾 The architecture your host doesn't have builds under QEMU. On Docker Engine (Linux, WSL), register
it once per boot:
docker run --privileged --rm tonistiigi/binfmt --install arm64 ⚠️ A plainbunx sst deploybuilds on the host, for its architecture only, and replaces the other architecture's functions.
| Variable | Default | What it does |
|---|---|---|
LAMBDA_ARCH |
the host's | x86_64, arm64 or x86_64,arm64 |
LAMBDA_MEMORY |
128,256,512,1024,1769 |
Memory sizes in MB; one function per variant and size |
VARIANTS |
all | e.g. BunCompile,Node24 |
BUN_BIN |
bun |
Bun for BunRuntime and BunCompile |
BUN_AOT_BIN |
$BUN_BIN |
Bun for BunCompileCanary, --trim and --aot |
CC |
cc |
C compiler for the AOT shim |
BUN_BUNDLES |
unset | Deploy Bun/LLRT from $BUN_BUNDLES/<arch>/<Variant>, no build |
Both landed in oven-sh/bun#45631, after 1.4.4, so for now only canary builds have them.
- 🙈 Released Bun silently accepts unknown flags, so the build checks
bun build --helpand skips a variant withSkipping <Variant>: …instead of deploying a mislabelled plain executable. - 🐧
--aotcan't cross-compile: it needs Linux on the Lambda architecture. On a Mac, usebun run build(Docker) or the Deploy and benchmark workflow. - 📏 Compare against
BunCompileCanary, notBunCompile: canary executables are ~30% larger on their own. - 🔍
bun run explain [--arch arm64] [report]lists the handler code--aotcompiled to slow paths, worst first (reports:json,guards,guards-json,trim,missed-optimizations,allocations).
📦 Installing the canary next to your normal Bun
The canary's files are replaced on every push to main, so there's no version to pin. This
checks the zip against the digest GitHub reports (use bun-linux-aarch64 on arm64,
bun-darwin-aarch64 on a Mac):
mkdir -p ~/.bun-aot && cd ~/.bun-aot
curl -fsSLO https://github.com/oven-sh/bun/releases/download/canary/bun-linux-x64.zip
curl -fsSL https://api.github.com/repos/oven-sh/bun/releases/tags/canary |
jq -r '.assets[] | select(.name == "bun-linux-x64.zip") | .digest | ltrimstr("sha256:") + " bun-linux-x64.zip"' |
sha256sum -c
unzip -o bun-linux-x64.zip
~/.bun-aot/bun-linux-x64/bun --revision # 1.4.4-canary.1+<commit>
BUN_AOT_BIN=~/.bun-aot/bun-linux-x64/bun bunx sst deploy --stage devA 404 or a failed checksum means a push is replacing the files; try again in a few minutes.
infra/aot-shim.c is LD_PRELOADed by the AOT bootstrap and works around
two things Lambda breaks. Both fixes belong upstream.
Why it's needed
mincore→EPERM. AOT executables check that page 0 is unmapped by callingmincore()and expectingENOMEM. Lambda's seccomp filter returnsEPERM, so the loader exits during init. The shim rewritesEPERMtoENOMEMbelowmmap_min_addr.- A 256 MB
mmapat 128 MB. The AOT runtime maps a 256 MB table withoutMAP_NORESERVEand callsabort()when that fails, which it does in a 128 MB microVM. The shim addsMAP_NORESERVEto private, writable, anonymous mappings of 128 MB or more.
It only needs glibc 2.2.5 symbols, so it loads on Lambda's glibc 2.34.
bun run bench # 3 rounds × 10 cold, then 20 warm per function
bun run bench --rounds 5 --cold 20 --only BunCompile,Node24Each round changes an environment variable on every function, which retires its warm environments,
then fires concurrent cold invocations. The numbers come from Lambda's own REPORT line:
- ❄️ Init:
Init Duration, from runtime start until it first asks for an event - 🏁 In-process startup:
performance.now()when the handler module is evaluated - 🔁 Invoke / round trip: Lambda's
Duration, and what the client saw
Results land in bench-results/ as Markdown and JSON.
The stack also benchmarks itself every hour, the way maxday/lambda-perf does, and keeps every result.
flowchart LR
S[⏰ EventBridge Scheduler<br>every hour] --> R[PerfRun<br>Step Functions]
R -- every function, in parallel --> M[PerfMeasure<br>PERF_ROUNDS × cold + PERF_WARM warm]
M --> D[(PerfResults<br>DynamoDB)]
R -- at the end --> Z[PerfSummarize] --> D
D --> A[PerfApi]
A -- /api/* --> C[☁️ PerfRouter<br>CloudFront]
W[PerfSite<br>React + Tailwind] --> C
| Piece | Source | Job |
|---|---|---|
PerfMeasure |
src/perf/measure.ts |
One round: retire warm environments, one cold invoke, then warm invokes |
PerfSummarize |
src/perf/summarize.ts |
Per-function p50/p90/avg/min/max, stored with the run |
PerfApi |
src/perf/api.ts |
GET /runs/history: the latest 90 runs, the one request the site makes |
PerfSite |
web/ |
The charts at coldstarts.github.nimmervoll.work |
Other stages live at <stage>.coldstarts.github.nimmervoll.work and tell search engines to stay
away.
bun run perf --stage dev [--detach] # start a run now (and wait for it)
bun scripts/perf-summarize.ts --stage production [--all] # summarize runs that have no summary yet| Variable | Default | |
|---|---|---|
PERF_ROUNDS |
10 |
Cold starts per function and run |
PERF_WARM |
5 |
Warm invocations after each cold start |
PERF_ADMIN_SECRET |
unset | Turns on the admin routes (URL-safe characters only) |
POSTHOG_PROJECT_TOKEN |
unset | Turns on the site's PostHog analytics in production, sent through /ingest to PostHog's EU cloud |
🔐 Admin routes
With PERF_ADMIN_SECRET set, PerfApi takes POST /api/admin/$PERF_ADMIN_SECRET/<action>.
Without it, these paths 404. The unlinked /admin page (web/admin.html) has a
button for each. PerfApi only answers through PerfRouter, whose Origin Access Control needs the
body's hash on a POST: curl one with
-H 'x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'.
| Action | Does |
|---|---|
run |
Starts a run |
pause |
Stops the hourly runs; a deploy that updates the schedule turns them back on |
resume |
Starts the hourly runs again |
purge |
Empties PerfResults, ~15 s at a time; call it again until it answers "done": true |
- ✅ Pull requests run CI: the checks plus native x86_64 and arm64 builds.
- 🚢 Pushes to
mainrun Deploy: the same, thensst deploy --stage production. - 📊 Deploy and benchmark (manual) builds on native runners, deploys every function and benchmarks them.
- 🔑 AWS access goes through GitHub OIDC.
bun run bootstrap:awssets up the provider, the role and thebenchandproductionenvironments, and is safe to re-run (scripts/bootstrap-aws.ts).
bun run build:local # build Bun + LLRT for this host and smoke-test every bootstrap against a fake Runtime API
bun run typecheck # the same checks CI runs ↓
bun lint --max-warnings 0
bun fmt --check
bun testbun lint needs a Bun canary, and the types need bunx sst install once.
- 🖥️ Site against a deployed API:
cd web && PERF_API_URL="$(jq -r '.perf.api' ../.sst/outputs.json)" bun run dev - 🖼️ Social preview: after editing
web/og.html, runcd web && bun run og(headless Chrome; setCHROMEto use another binary).
- 📦 Compiled Bun executables are ~100 MB against a few KB for Node. Lambda fetches them on every cold start, which shows up in the round trip even when init is fast.
- 🪶 LLRT is pinned in
infra/llrt.tsand uses the no-sdk builds.src/llrt-env.tsfills in theprocessandnavigatorAPIs it lacks. - 🧩
LlrtBytecodeships LLRT's CLI build (14 MB vs 5.5 MB), because only the CLI build reads bytecode the CLI compiled. Comparing it withLlrttherefore compares the executables as well. Like--aot, it only builds on Linux on the Lambda architecture. - 📌
nodejs26.xneeds the AWS provider pinned to 7.52.0 insst.config.ts. - 🚫 Every function sets
dev: false, sincesst devwould route it to your machine.