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

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/bun-aot
> date: 2026-10-31
> tags: bun, javascript, compilers, binary-size

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.

<RepoCard repo="oven-sh/bun" />

## What I ran, and on what

- **Bun 1.4.4, stable.** `bun build --help` has no `--aot` and no `--trim`. Both are absent from the release.
- **Bun 1.4.4 canary**, revision `da867c60b`, from the `canary` tag of the GitHub release. This is the build the flags exist in, and the one I used for everything. If you try this against a stable release you will hit "unknown option".
- Linux x64, 4 shared vCPUs, each launch timed with `nice -n 19`. Sizes are bytes of the output file, divided by 10^6 for megabytes.
- I could not find the flags in the public docs. The docs site's `llms.txt` lists a "Single-file executable" page and a "Bytecode Caching" page and neither is reachable as markdown from here, so the descriptions I quote come from the canary's own `bun build --help`. That text is the closest thing to a primary source I had, and I treat it as such.

## 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:

- `--aot-log-type-mismatches`: "the executable logs every value that doesn't match its declared type". Compiling dynamic JavaScript to machine code needs assumptions. This flag exists to check them, and the natural reading (**reasoned**, not confirmed in code) is that your declared TypeScript types are one source of those assumptions.
- `--explain=<val>`: "list the code that compiled to a slow path and how to fix it, worst first". A compiler that has fast and slow paths needs to tell you which one you got.
- `--no-compile-function-source`: with it, `fn.toString()` returns `[native code]`, "and an eval() or new Function() call the compiler didn't find at build time throws". So there is no engine waiting inside to compile text you only produce at run time, unless the compiler saw it.
- `--aot-allow-code-from-disk`: lets `import()` and `require()` load code that was not in the executable, at the price of a larger and slower binary.
- `--compile-in-place`: keeps modules on disk, and "the executable only runs on the machine that built it".

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.

<SizeLadder />

The table behind it, in bytes (**measured**):

| Program | plain | `--trim` | `--aot` | `--aot --trim` |
|---|---|---|---|---|
| hello | 140,356,264 | 84,463,016 | 68,189,224 | 25,641,320 |
| Bun.serve | 140,356,264 | 84,463,016 | 68,287,528 | 34,642,152 |
| fib(35) | 140,356,264 | 84,463,016 | 68,205,608 | 25,682,472 |
| Hono + zod + sqlite | 140,569,256 | 84,676,008 | 71,400,488 | 46,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:

```ts
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).

<TrimDecision />

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.

| Program | plain | `--trim` | `--aot` | `--aot --trim` |
|---|---|---|---|---|
| hello, ms | 5.3 | 4.7 | 5.2 | 5.5 |
| Bun.serve + 1 request, ms | 8.3 | 8.3 | 8.2 | 8.3 |
| Hono + zod + sqlite, ms | 36.4 | 35.8 | 14.4 | 15.0 |
| fib(35), ms | 69.8 | 71.3 | 51.6 | 53.4 |
| hello, peak MB | 12.0 | 12.5 | 17.9 | 14.2 |
| Bun.serve, peak MB | 18.7 | 18.6 | 21.5 | 20.5 |
| app, peak MB | 34.9 | 35.0 | 34.0 | 32.1 |
| fib(35), peak MB | 19.1 | 19.5 | 16.7 | 14.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.

<StartupSplit />

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

- **Use `--aot --trim` for a CLI or a server you ship as one file** and run it before you ship it. The size drop is real: 140.4 MB to 25.6 MB for hello, 140.4 to 34.6 for an HTTP server, 140.6 to 46.3 for my small app.
- **Start with `--explain=trim`** and read what it says is blocking. Every `eval`, `new Function` and computed API name is a place where the analysis gave up or guessed.
- **Test the paths, not just the build.** My dynamic-name program compiled cleanly and failed on its first call. If your code reaches an API through `obj[key]`, a plugin system or a user-supplied string, add it to the allow list or avoid trim.
- **Do not expect trim to make startup faster.** The speedup is AOT's, and it only shows on programs that have a lot of code to load.
- **Treat it as canary software.** The flags are not in 1.4.4 stable, I found no documentation for them, and the behaviour I measured may change before they ship.

## What I did not do

- I did not read the compiler source. How it infers types, what it emits and what "reason 6" means for a regex are not established here.
- I did not run on macOS or Windows, so the reported 89 to 25 MB is neither confirmed nor refuted.
- I did not test the `compile.trim.allow` fix, `--trim-locales`, `--obfuscate`, or `--aot-huge-heap`.
- I did not benchmark throughput. One recursive function is not a benchmark.
- I could not open the original post, the docs page or the pull requests, so every claim about their wording is limited to what the canary's own help text and error messages print.
