~/satyajit

Bun AOT and --trim: what the compiler removes, and what a run showed

mdjsonmcp

2026-10-31 · 13 min · bun · javascript · compilers · binary-size

Why read this

Notabletop 60%

Builds four programs under every flag set on a canary Bun, measures size, startup and memory, and shows a run where trim removes an API the program needs.

  • Original analysis
  • Runs on a laptop CPU
  • Widely used

Developer tools & infraMITPractitioner tool

How this was scored
Is it new?
2 of 3: A real new idea, method or capability
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
1 of 3: Relevant for months
Does it affect many?
2 of 3: A widely used model, tool or lab release
Only here?
2 of 3: A teardown or measurement few others did

Score 66 of 100, ranked 235 of 666 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

A bun build --compile binary has always been the Bun runtime with your bundle stapled on. On Linux x64 that is 140 MB for a program that prints one line. Jarred Sumner's post about the new AOT mode says it also brings --trim, which deletes the unused parts of Bun from the output, and that on macOS a binary using Bun.serve's HTTP server goes from 89 MB to 25 MB (reported, I could not open the post itself from this sandbox, so the numbers in it are second-hand).

I did not want to repeat that number. I wanted to know what the flags do. So I downloaded the canary build, compiled four programs under every combination, ran every binary, and measured size, launch time and memory. Everything labelled measured below came from those runs on one machine. What I could not do is spelled out at the end.

oven-sh/bun@34cbc70 · snapshot 2026-10-31
tracked files
46,472
license
custom
branch
main
tests
37617 files
source
261.0 MB
commit date
2026-10-31
source by language
TypeScript97.5 MB(10668)Rust82.8 MB(4233)JavaScript50.4 MB(15941)C15.6 MB(934)C++10.0 MB(633)Python2.5 MB(209)CSS2.2 MB(32)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-10-31 at 34cbc70 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

What I ran, and on what

What AOT means here

The help text for the flag reads, verbatim: "Compile JavaScript and TypeScript to machine code at build time. Requires --compile". That is a stronger claim than the bytecode caching --bytecode already did. Bytecode caching skips parsing at startup, but the engine still interprets and then JITs hot functions as the program runs. AOT means the build produces native code for your functions, so there is no JIT warm-up for them.

The surrounding flags tell you how it works, and they are worth reading as a set:

All of that points one way. The compiler wants the whole program closed at build time. A program that is closed can be compiled harder and, as the next section shows, it can also be stripped.

A compile diagnostic from my own build shows the other side of the bargain. The compiler printed TS100058: this regular expression runs in the interpreter (reason 6) for regexes that Hono and zod build from template strings at run time. Those cannot be compiled ahead of time because their text does not exist yet.

Measurement 1: size

Four programs: console.log("hello"); a Bun.serve server that sends one request to itself and stops; fib(35); and a small app with Hono, zod, bun:sqlite, node:crypto and node:zlib behind a Bun.serve. I compiled each four ways and ran each output once to confirm it still did its job. All sixteen binaries gave the right answer.

The table behind it, in bytes (measured):

Programplain--trim--aot--aot --trim
hello140,356,26484,463,01668,189,22425,641,320
Bun.serve140,356,26484,463,01668,287,52834,642,152
fib(35)140,356,26484,463,01668,205,60825,682,472
Hono + zod + sqlite140,569,25684,676,00871,400,48846,299,368

Four things stand out.

Plain is a constant. The three small programs produce byte-identical file sizes in plain mode. The runtime is the binary, and your code is a rounding error: the Hono app adds 213 KB.

--trim without --aot is also a constant. It took 54.0 MB off every program (the tool prints "54.0 MB of unused Bun APIs removed") and left 84,463,016 bytes (84.5 MB) for all three small ones. A trim that is the same for hello and for an HTTP server is not looking at your program. It removes a fixed set. That is my reasoning from the sizes, not something the docs say, but a program-independent number is hard to read any other way.

--aot on its own halves the file. 140.4 MB to 68.2 MB, again nearly program-independent. I did not take the binary apart far enough to say what leaves, and I will not guess. The ELF sections of the final hello binary include .bun_aot, .bun_trim, .bun_features and .bun_builtins, which tells you the runtime now carries metadata about what was compiled and removed.

Together they follow the program. Hello falls to 25.6 MB, and the Bun.serve server to 34.6 MB. The tool printed "114.0 MB of unused Bun APIs removed" for hello and "105.4 MB" for the server. The difference between the two, 9.0 MB, is what the HTTP server costs to keep. The Hono app, which uses the server plus sqlite, crypto and zlib, lands at 46.3 MB.

Does this reproduce the reported 89 to 25 MB?

Not directly, and the comparison is not apples to apples. The post is about macOS, where a plain Bun binary is smaller than the 140 MB I get on Linux. My Bun.serve binary under --aot --trim is 34.6 MB, not 25 MB. My hello binary is 25.6 MB, close to the headline's end point, so the 25 MB figure is plausibly the floor of an AOT plus trim build. What I can say is that the shape (a large drop, and a floor around 25 MB) matches, and the specific Linux numbers differ from the reported macOS ones. Whether Bun.serve costs 0 MB on macOS or 9 MB, I cannot tell from here.

How --trim knows what is unused

The help text: "Remove the Bun and Node.js APIs your code never calls from the executable. Requires --compile". Without --aot I measured a fixed 54 MB. With it, the amount depends on the program. So the usage analysis is a product of the AOT compiler, which has seen every function. That fits: you cannot know which APIs are never called until you have a closed program with all its dependencies.

--explain=trim prints the analysis. On my Hono app it printed this, among much else:

aot: trim: blocked by eval: the program calls eval() or new Function() (Function) at node_modules/zod/v4/core/doc.js:35:29
aot: trim: blocked by dynamic-import: the program calls import() or require() with a dynamic path (Function) at node_modules/zod/v4/core/doc.js:35:29
aot: trim: the compiler can't tell which API this program uses at node_modules/hono/dist/utils/body.js:84:9, because the name is only known at run time
aot: trim: uses %ArrayIterator%.prototype (escapes, so any member may be used)

The last line is the interesting one. The analysis tracks built-in objects, including the engine's internal prototypes, and says when one "escapes", meaning it leaves the compiler's sight, so "any member may be used". That is a conservative escape analysis. A value that escapes keeps everything on it.

The first lines say what defeats it: eval, new Function, a dynamic import() or require(), and an API reached by a name computed at run time.

What happens when the analysis is wrong

I wrote a program that calls Bun.serve, but spelled in a way the compiler cannot read:

const k = process.argv[2]; const api: any = Bun;
const s = api[k]({ port: 0, fetch: () => new Response("dyn-ok") });

I compiled it with --aot --trim, then ran it with serve as the argument. It built without an error. It came out at 38.5 MB with "102.3 MB of unused Bun APIs removed". And at run time:

error: Bun.serve was removed by --trim because your code never uses it by name. A name known only at run time, as in object[key]() or eval(text), does not count. To keep it, add "serve" to compile.trim.allow in Bun.build().
 code: "ERR_FEATURE_REMOVED"
      at /$bunfs/root/dyn2.ts:2:15

(measured) That is the most useful result in this article. The build does not refuse the program. It produces a smaller binary that fails when the call is made, with a named error code and the fix in the message. The remedy, per the message, is an allow list in Bun.build() (reported by the tool, I did not test the option).

The size is also telling. That program is 38.5 MB, bigger than the 34.6 MB server, though it contains less working code. The unreadable name turned the analysis conservative about something, and kept other APIs. I did not isolate which.

The zod case matters in the other direction. Zod calls new Function in a helper, the analysis reported it as blocking, and the app still built to 46.3 MB and ran correctly. So "blocked by eval" is a statement about what the analysis could prove at that site, not a build failure. My app exercised only the paths it had, so I cannot say whether any zod feature I did not call would have hit a removed API.

Measurement 2: startup and memory

I launched each binary 40 times, after 5 warm-ups, and took the median wall time of the whole process, spawn included. Peak memory is the maximum resident set size of the child.

Programplain--trim--aot--aot --trim
hello, ms5.34.75.25.5
Bun.serve + 1 request, ms8.38.38.28.3
Hono + zod + sqlite, ms36.435.814.415.0
fib(35), ms69.871.351.653.4
hello, peak MB12.012.517.914.2
Bun.serve, peak MB18.718.621.520.5
app, peak MB34.935.034.032.1
fib(35), peak MB19.119.516.714.5

(measured; the p90 sat 1 to 10 ms above the median, and the spread on the shared box is wide enough that differences under about 1 ms are noise.)

Trim does not speed anything up. Trim-only and plain are within noise on all four. Removing 114 MB from the file changes what is on disk and in the download. It does not change launch time, because the pages that are never touched are never read.

AOT helps where there is code to load. The Hono app starts in 14.4 ms instead of 36.4 ms. Hello does not move, because a one-line program has nothing to compile or load. The gain is the loading and compiling of about 120 bundled modules, which is exactly what ahead-of-time work removes. For fib(35) the in-program timer gave 61 to 63 ms plain and 44 ms AOT (measured), so compiled code spent about 30% less time on the same loop. That is one micro-benchmark with one recursive function and it says nothing about general throughput.

Memory is a wash. AOT raised hello's peak from 12.0 to 17.9 MB and trim brought it back to 14.2 MB. For the app, AOT with trim is 32.1 MB against 34.9 MB. These are single measurements per cell of a peak value, so treat anything below 2 to 3 MB as unresolved.

A caution on the split in that figure: the totals are measured and the three-way division is arithmetic. I took the hello binary as the boot floor (5.3 ms), the Bun.serve binary as floor plus a server and a request (8.3 ms), and gave the rest of the app's time to loading and compiling its code. Under that division, AOT cuts the app's code-handling time from about 28 ms to about 6 ms. A profiler could split it differently.

Against Node's single executable

For scale, I built the same hello with Node 22.22.0's single executable application feature: node --experimental-sea-config, then postject into a copy of the node binary. The result is 123,473,088 bytes, of which the node binary itself is 123,405,064 (measured). The script and its blob add about 68 KB. Node prints an ExperimentalWarning to stderr on every launch, and the median launch of that hello binary was 27 ms against 4.9 ms for Bun's AOT plus trim hello (measured, same box).

The right reading is modest. A SEA binary ships all of Node with no way to remove parts, and Bun's trim is the first thing I have seen that tries to. Bun hello at 25.6 MB is one fifth of that size. I did not install Deno, so I have no deno compile number.

What I would do with this

What I did not do

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Bun AOT and --trim: what the compiler removes, and what a run showed", ai.thesatyajit.com, October 2026.

bibtex
@misc{ghana2026bunaot,
  author = {Satyajit Ghana},
  title  = {Bun AOT and --trim: what the compiler removes, and what a run showed},
  url    = {https://ai.thesatyajit.com/articles/bun-aot},
  year   = {2026}
}
share