Smaller executables
Remove the Bun and Node.js APIs your program never calls from an executable, with bun compile --trim
--trim and ahead-of-time compilation are experimental.bun compile --trim ./server.tsA standalone executable is a copy of bun with your program attached. So a ten-line server ships a package manager, a test runner, a type checker, a linter, several JIT compilers, SQLite and three image decoders. --trim removes what your program doesn't use. The compile line shows the size of the executable and how much --trim removed.
[108ms] bundle 1 modules
[129ms] compile server 33.4 MB (106.1 MB of unused Bun APIs removed)bun compile is short for bun build --compile --aot, which takes --trim too. So does bun build --compile without --aot, where it removes less. In Bun.build, it's compile: { trim: true }.
Here's the todo server from Ahead-of-time compilation, built five ways. --no-aot builds what bun build --compile builds: an executable that runs your program with the JIT compilers.
| Command | Linux x64 |
|---|---|
bun compile --no-aot | 140 MB |
bun compile | 69 MB |
bun compile --no-aot --trim | 86 MB |
bun compile --trim | 33 MB |
bun compile --trim --disallow-code-generation-from-strings | 33 MB |
All five answer every route the same way. Your program behaves the same with and without --trim, apart from the exceptions in What a trimmed executable does not do. Any other difference is a bug in Bun. The flag in the last row is different: it changes what a program can do. It saves nothing here, because nothing in this program calls eval or new Function. See Turn off code from strings.
Compressed with gzip, as in a container image, the executable of bun compile is 33 MB. With --trim it's 14 MB.
A trimmed executable starts as fast as an untrimmed one and uses as much memory.
Every size on this page is for Linux x64.
What --trim removes#
--trim only removes a feature when the compiler can prove your program never uses it. Features fall into three groups.
| Group | What's in it | --trim removes it |
|---|---|---|
| Tools | The bun command line, bun check, bun lint, bun fmt, the ahead-of-time compiler. Unless you pass --no-aot, the JIT compilers | Always |
| APIs | bun:sqlite, Bun.Image, HTMLRewriter, bun:ffi, Bun.sql, Bun.redis, WebAssembly, Intl, the bundler, the debugger and many more | When your code never uses it |
| Code from strings | eval, new Function, and the JavaScript parser and interpreter that run their code | When your program never calls eval() or new Function() |
Tools#
bun install, bun test, bun build and the rest of the command line are only in an executable because the executable is a copy of bun. Nothing in JavaScript calls them, and a compiled executable never starts the JIT compilers. So bun compile leaves both out of every executable, with or without --trim: 140 MB becomes 69 MB. That doesn't depend on your program. With --no-aot the executable is a whole copy of bun, and --trim removes the command line but keeps the JIT compilers: 86 MB.
Two things do use the tools: BUN_BE_BUN=1, and Bun.build with check or compile.aot. With BUN_BE_BUN=1 the executable tells you to run bun instead, and exits with code 70. A program that calls Bun.build keeps the type checker, the compiler and the JIT compilers, unless you pass --trim. install.auto in bunfig.toml has no effect, because the package manager is part of the command line.
APIs#
bun compile reads the whole program, including dependencies. It knows which globals, which members of Bun and process, which built-in modules and which methods of built-in objects your code uses. Bun removes every feature that nothing uses. You don't list anything: a program that imports bun:sqlite gets SQLite, and a program that doesn't ships without it.
Methods count because some of them are large. One call to toLocaleString() needs the number formatting of Intl, which is 6 MB. The locale counts too: toLocaleString("de") keeps the data for one language. A locale that isn't a constant, or that's omitted, keeps the data for all of them. See Locales.
The bundler and the transpiler are features too. The executable includes the bundler if your program uses Bun.build or imports an HTML file, and the transpiler if it uses Bun.Transpiler.
With --no-aot, Bun doesn't analyze your program, so it only removes the tools.
This step takes the todo server from 69 MB to 33 MB. It relies on the compiler seeing every API your program will ever use (a closed-world assumption, like GraalVM Native Image). What blocks --trim lists what gets in the way.
Code from strings#
The JavaScript parser and the interpreter run the code that eval and new Function create. They stay in the executable when the compiler finds a call to either one, or anything else that can run code that isn't in the executable. Otherwise Bun removes them. They take 2 to 4 MB.
If a dependency calls new Function and your program can do without it, turn off code from strings.
A ShadowRealm has its own copy of every built-in module and runs it from source code. If your code names ShadowRealm, the executable also holds the source code of Bun's built-in modules (3 MB), with or without --trim, and --trim keeps the transpiler (2 MB). In a trimmed executable, the realm can import the built-in modules whose features your program uses.
See what the compiler found#
--explain=trim lists the APIs your program uses, and why --trim kept or removed each feature. It works with bun compile, and with bun check --aot, which writes no executable. It doesn't need --trim: without it, the report shows what --trim would do. In Bun.build, compile: { aotExplain: "trim" } adds the lines to result.logs.
Bun.serve({
port: 3000,
routes: { "/": () => new Response("hello") },
});
console.log("listening");bun check --aot --explain=trim ./server.tsaot: trim: the compiler can see every API this program uses
aot: trim: uses Bun serve
aot: trim: uses console log
aot: trim: locales: none
aot: trim: kept Bun.serve (serve): used (Bun.serve) at server.ts:1:1
aot: trim: removed bun:sqlite and node:sqlite (sqlite): unusedThe report is a few hundred lines long. The examples on this page only show the lines that matter.
The first line matters most. Here the compiler can see every API, so Bun removes every feature that isn't used. Otherwise the report starts with one or more blocked by lines, or with a line for each place where the name of an API is only known at run time.
Each uses line is an API. The list is long because it includes what Bun's own built-in modules use. An API that your program only tests for, as in typeof WebSocket, isn't a use and has no line.
locales: has the locales that your code passes to Intl: none, a list, or all with the reason, the built-in that takes the locale, and the location. See Locales.
A kept line is a feature that --trim keeps, with the reason after the colon. The word in parentheses is the feature's name in compile.trim.allow. A small feature without a name there has no parentheses. A removed line is a feature that --trim removes. It ends with unused, or with the place in your code that could still reach the feature.
| After the colon | --trim keeps the feature because |
|---|---|
used (Bun.serve) at server.ts:1:1 | your code uses that API there |
needed by Bun.serve | another feature that stays needs it. A longer chain reads needed by A <- B |
needed by --inspect | an option in --compile-exec-argv needs it. needed by --compile-inspector is the same for that flag |
code loaded at run time could use it (eval) | --trim is blocked, for the reason in parentheses |
listed in compile.trim.allow (sqlite) | it's in allow |
runs at startup | every executable runs it at startup |
The report goes to stderr. To fail a CI job when a new dependency blocks --trim, search for blocked by.
if bun check --aot --explain=trim ./server.ts 2>&1 | grep "trim: blocked by"; then exit 1; fiThe job's log then has the lines that grep found.
What blocks --trim#
This program turns a string into a function at run time, like some libraries do, and can do without.
function compile(body: string): Function | undefined {
try {
return new Function("value", body);
} catch {
return undefined;
}
}
const double = compile(process.argv[2] ?? "return value * 2");
console.log(double ? "generated code" : "plain code");bun check --aot --explain=trim ./probe.tsaot: trim: blocked by eval: the program calls eval() or new Function() (Function) at probe.ts:3:16A string passed to new Function could use anything, so the compiler can't see every API this program uses. The line has the reason, the built-in that causes it, and the location in your code. If the call is in one of Bun's own modules, as with node:vm, the location is the statement in your code that leads there.
A string that's a constant blocks nothing. Bun compiles new Function("a", "b", "return a + b"), Function("return 1") and (0, eval)("1 + 1") with the rest of your program, so a library that only probes with new Function("") costs nothing. A direct eval("...") still blocks, because its code can see the variables around it.
One such call is enough, in your own code or in a dependency. How much --trim keeps depends on the reason.
| The line starts with | Your program | --trim keeps |
|---|---|---|
blocked by eval: the program calls eval() or new Function() | calls eval, new Function or the constructor of async or generator functions | The parser, the interpreter, process.getBuiltinModule and JavaScript's built-ins, but not Intl, Temporal or WebAssembly. Unused Bun and Node.js APIs still go |
blocked by runtime-code: the program can run code that isn't in the executable | uses one of the built-ins in the next table | What eval keeps, and native addons |
blocked by dynamic-import: the program calls import() or require() with a dynamic path | imports a module from a path that isn't a constant, starts a Worker, or runs code from a string, which can import | The loaders for data files. It decides everything else as usual, so a built-in module that only a dynamic path reaches is removed |
blocked by external-import: the program imports a module that isn't in the executable | imports a package that you marked --external, or a module by its URL (https:, data:) | Every API |
blocked by build-option: a build option lets the executable load code at run time | is built with --compile-inspector, or with an option in --compile-exec-argv that loads code, such as --preload | Every API |
blocked by limit-reached: the analysis hit a compiler limit | is larger than one of the compiler's limits. --explain shows which | Every API |
blocked by no-aot: the program wasn't built with --aot | is built with --no-aot, or with bun build --compile | Every API, and the JIT compilers |
blocked by analysis-disabled: a developer option turned the analysis off | is built with one of the flags for working on the compiler | Every API |
blocked by no-report: the compiler didn't report which APIs are used | has found a bug in Bun. Report it | Every API |
The report has one line for each reason, so you can fix all of them before you run it again.
These built-ins block --trim too. Only a call that your program can reach counts. Importing the module doesn't: import { isMainThread } from "node:worker_threads" blocks nothing.
| Built-in | The line starts with | Why |
|---|---|---|
ShadowRealm.prototype.evaluate, module._compile | blocked by eval | They run a string |
node:vm, ShadowRealm.prototype.importValue | blocked by runtime-code | They run strings and load modules |
bun:ffi, Bun.FFI, process.dlopen, a .node file | blocked by runtime-code | Native code can call anything |
Bun.build, Bun.plugin, Bun.Transpiler | blocked by runtime-code | They load or create code at run time |
new Worker() | blocked by dynamic-import | It finds its file by name. Only an entry point of the build can run |
require used as a value, process.mainModule.require, process.getBuiltinModule(name), Module._load | blocked by dynamic-import | Whoever gets them can ask for any module by name |
With --aot-allow-code-from-disk, the path may name a file with code, so --trim also keeps what eval keeps, the transpiler and the interpreter. Unused Bun and Node.js APIs still go. A file that import() or require() loads from disk finds the APIs that your program uses. If it calls one that --trim removed, the call throws an error that names the feature: list it in allow to keep it. A debugger only starts in an executable that's built with --compile-inspector: without it, inspector.open() from node:inspector starts nothing, so it blocks nothing.
These block nothing, because the compiler sees the name: globalThis.fetch, typeof globalThis.fetch, process.env.HOME and process.getBuiltinModule("node:fs"). value.constructor and settings[name] block nothing either. settings[name] can have a cost of its own: see Look up by key with a Map.
An import() or a require() of a package that wasn't installed at build time blocks nothing either. The build warns about it, and the call throws at run time, so it loads nothing.
const { serve } = Bun and const b = Bun; b.serve(options) cost nothing: the compiler tracks them.
A computed key on process, as in process[name] or name in process, has a price. The compiler can't tell which member your program will ask for, so the executable keeps every feature of that object. So does passing the object itself to other code: inspect(process), for (const name in process), return process. The report says so.
aot: trim: uses process (escapes, so any member may be used)A read such as process[name] also costs what settings[name] costs. And if the key turns out to be "dlopen", the native addon that your program loads can ask for a feature that --trim removed.
Bun is different. A member of Bun counts as used where your code names it on Bun: Bun.file(path), const { file } = Bun, import { file } from "bun". A constant key names the member too: Bun["file"], or Bun[key] after const key = "file". Bun[name], { ...Bun }, for (const name in Bun), console.log(Bun) and passing Bun to code that the compiler can't follow name no member, so they keep nothing. Neither does a key that your code puts together with a call, such as Bun[["fi", "le"].join("")]. Every name is still there, and typeof Bun[name] is what it is under bun run. Calling a member that nothing names throws the error in Using a removed feature. To keep one, name it somewhere in your code or add it to compile.trim.allow.
Intl, Temporal and WebAssembly#
These three are large: Intl and its data are 11 MB on Linux. So they follow the same rule as Bun. --trim keeps a part of them when your code or a dependency names it:
new Intl.Segmenter(),globalThis.Intl.Collator,Temporal.Now,WebAssembly.instantiate(bytes)value.toLocaleString(),a.localeCompare(b),date.toLocaleDateString(), whatever the type of the value- a string literal that has the name in it, such as
"return new Intl.NumberFormat()." + method, because it may end up innew Functionor be used as a key - a method's name in a list of constants, such as
["localeCompare", "toLocaleString"], because the list may be a list of keys. The name of a class by itself in such a list, as in["Segmenter", "Collator"], keeps nothing: as a key it needsIntl[name] Intl[name], which keeps all ofIntl
A new Function(text) whose text comes from somewhere else, and a call like value[name](), name nothing. They used to keep all three in every program that has zod in it.
The compiler can't tell what a call like value[name]() reaches, and the report says so. It has the place of each such call, what the call could reach that --trim removes, and how to keep it.
console.log((1234.5 as any)[process.argv[2]!]("de-DE"));bun check --aot --explain=trim ./format.ts 2>&1 | grep -E "can't tell|could reach"aot: trim: the compiler can't tell which API this program uses at format.ts:1:14, because the name is only known at run time
aot: trim: removed Intl.NumberFormat, Intl.DurationFormat and toLocaleString on numbers and BigInts (intl): no code names it, but a name from run time could reach it (Number.prototype.toLocaleString) at format.ts:1:14. To keep it, add "intl" to compile.trim.allow
aot: trim: removed units in Intl.NumberFormat, Intl.DurationFormat and toLocaleString (intl): no code names it, but a name from run time could reach it (Number.prototype.toLocaleString) at format.ts:1:14. To keep it, add "intl" to compile.trim.allowThe names are still there: typeof Intl.Collator is "function". Calling a part that was removed throws the error in Using a removed feature. If your program runs templates or scripts that it reads at run time and they format dates or numbers, add "intl" to compile.trim.allow.
Make it smaller#
--trim stops where it would have to guess. You know things about your program that the compiler can't prove, so you can go further. The steps below start with the one that usually saves the most.
Turn off code from strings#
When --trim is blocked, the cause is often a dependency that calls new Function, or has a code path that could. zod 4, ajv, TypeBox and Elysia do.
If your program doesn't need code from strings, say so at build time. --disallow-code-generation-from-strings means what it means in Node.js: eval(), new Function() and the constructors of async and generator functions throw an EvalError. node:vm isn't affected. With the flag, those calls can't run anything, so they no longer block --trim. Bun then removes the parser, the interpreter and whatever else your code doesn't use.
bun check --aot --disallow-code-generation-from-strings --explain=trim ./probe.tsaot: trim: the compiler can see every API this program usesbun compile --disallow-code-generation-from-strings ./probe.ts && ./probeplain codeThe flag doesn't need --trim. In Bun.build, the option is compile: { disallowCodeGenerationFromStrings: true }.
Program, with --trim | Without the flag | With the flag |
|---|---|---|
probe.ts | 40 MB | 29 MB |
| A script that validates with zod 4 | 37 MB | 34 MB |
| A Hono server | 40 MB | 40 MB |
| The todo server | 33 MB | 33 MB |
The flag changes what your program does, with or without --trim. It's also a flag of bun itself, so you can find out what breaks before you build.
bun --disallow-code-generation-from-strings ./probe.tsplain code| Library | With the flag |
|---|---|
| zod 4, Hono | Work. zod tries new Function in a try block and falls back |
| Elysia | Works with new Elysia({ aot: false }). Otherwise it throws at startup |
| Express | Works |
| Fastify, Koa | Throw an EvalError at startup: a dependency generates code when it loads |
The flag only takes care of the eval row in What blocks --trim. Every other row still applies, and =strict doesn't change that. Express and Elysia are still blocked with the flag, and it saves them little: Express stays at 51 MB, and Elysia goes from 47 MB to 45 MB. For a program like that, fix the other blocked by lines first.
Look up by key with a Map#
A program that blocks nothing can still include more than it needs. The sign is a long run of uses property lines with methods that your code never mentions.
const settings: Record<string, unknown> = JSON.parse(process.argv[2]!);
const value = settings[process.argv[3]!];
console.log(typeof value === "function" ? value() : value);bun check --aot --explain=trim ./lookup.tsaot: trim: the compiler can't tell which API this program uses at lookup.ts:2:15, because the name is only known at run time
aot: trim: uses property constructor
aot: trim: uses property match
aot: trim: kept regular expressions: used (String.prototype.match) at lookup.ts:2:15A key finds more than the object's own properties. If it's "match", settings[key] is the method that a string inherits, and this program calls what it finds. The compiler can't tell what the key will be. It can't tell what JSON.parse returns either: an object, an array, a string or a number. So the list has every method of every built-in class, and Bun keeps them all, except for Intl, Temporal and WebAssembly, which have to be named. Some of the rest are large: match needs regular expressions, and constructor finds Function, which needs the interpreter. lookup.ts is 34 MB. With a Map, which has no inherited keys, it's 27 MB.
These lookups cost little or nothing:
- The key is a number, as in
list[i]. - Nothing calls what the lookup finds.
console.log(settings[key])prints[Function: toLocaleString], and for that Bun keeps the name and not the code behind it. - The compiler can see where the object was created, as with an object literal or an instance of one of your classes. Then the key only finds what that object inherits. For
{ theme: "dark" }[key], that's the methods ofObject.prototype.
Hono and zod have lookups of the expensive kind, which is why they're still 40 MB and 34 MB with the flag in the table above. You can't rewrite a dependency, but you can list the locales that your program needs.
Locales#
The locale data of Intl is the largest thing that --trim removes. A program that formats numbers in German doesn't need the data for other languages.
console.log((1234.5).toLocaleString("de"));bun check --aot --explain=trim ./price.ts 2>&1 | grep -E "locales|\(intl\)"aot: trim: locales: de
aot: trim: kept Intl.NumberFormat, Intl.DurationFormat and toLocaleString on numbers and BigInts (intl): used (Number.prototype.toLocaleString) at price.ts:1:14Bun keeps the data for the locales that your code passes as constants, as in toLocaleString("de"), new Intl.DateTimeFormat(["en-US", "en"]) or const locale = "de" at the top of a module. One locale that isn't a constant, or that's omitted so that the system's locale applies, keeps every language. So does anything that can run code that isn't in the executable, such as eval. The locales: line says which one it is, and where.
aot: trim: locales: all, because the locale isn't a constant (Intl.NumberFormat) at price.ts:4:31
aot: trim: locales: all, because a call passes no locale, so the system's applies (String.prototype.localeCompare) at sort.ts:2:27In all of these cases you can list the locales yourself. Bun then keeps exactly these locales.
await Bun.build({
entrypoints: ["./price.ts"],
compile: { outfile: "./price", aot: true, trim: { locales: ["en", "de"] } },
});On the command line, it's --trim-locales en,de, which also turns on --trim.
If the program asks for a language whose data was removed, the process exits with code 70. A try block can't catch that.
error: the locale data this program needs was removed by --trim. Add the locale to compile.trim.locales to keep it.On macOS, Bun uses the system's ICU library, so locales has no effect there.
Remove function source code#
bun compile embeds the source code of every function, so that fn.toString() can return it. --no-compile-function-source removes it.
bun compile --trim --no-compile-function-source ./server.tsfn.toString() then returns function name() { [native code] }. An eval() or new Function() call that the compiler didn't find at build time throws an EvalError. See Options that load code.
The flag doesn't need --trim. In Bun.build, the option is compile: { functionSource: false }.
Read the build's log#
The CLI prints how many megabytes it removed. With log: true in trim, Bun.build also adds the features to result.logs. For the todo server with trim: { log: true }:
compile.trim: removed 105.0 MB of the runtime, 98.3 MB of it from the file: cli, process-kill, process-report, dialogs, ipc, native-code, jit, bundler, test, inspector, source-maps, image, archive, sqlite, sql, redis, s3, html-rewriter, webassembly, shell, webview, fetch, sockets, dns, http2, http3, websocket, udp, node-vm, node-crypto, web-crypto, password, brotli, intl, temporal, bun-jsc, plugin, fs-router, terminal, cron, secrets, markdown, yaml, toml, json5, jsonc, jsonl, xml, glob, semver, color, csrf, hash, crypto-hasher, url-patternSome names include several others. cli is the whole command line, so lint, fmt, check, install and the rest aren't listed again. native-code includes ffi and native-addons.
The error messages of a removed native library are gone from the file too. strings server | grep -c "malformed database schema", a message of SQLite, prints 0.
The first line has two numbers: what Bun removed, and how much of that made the file shorter. Bun cuts whole pages out of the file, and the format of the executable allows a limited number of cuts. What it can't cut it overwrites in place, so the file keeps its length there. What Bun overwrites compresses to almost nothing.
When the cuts run out, the log has a line for what stayed:
compile.trim: 1.4 MB stayed in the file as filler (BudgetSpent)If nothing can be cut at all, the file doesn't get smaller. Bun prints this warning with or without log: true:
compile.trim: warning: 98.3 MB could not leave the file (HeaderRoomExhausted) and was overwritten in placeWhen the build fails#
Bun.build rejects when there's a mistake in trim.
| Message | Cause |
|---|---|
compile.trim: unknown name "sqlite3". Did you mean "sqlite"? | A name in allow or remove that Bun doesn't know |
compile.trim: "sqlite" is listed in both allow and remove | The same name in both lists |
compile.trim: this build of Bun has no data for the locale "xx" | A locale in locales that Bun has no data for |
compile.trim must be true, false or an object: { allow, remove, locales, log } | Anything else, such as an array or a key that isn't one of the four |
Using a removed feature#
Bun removes code, not names. Every global, class, prototype, property, method name and constant of a removed feature is still there, so typeof, in and instanceof answer as before. What's gone is what they do: a call, a new, a getter or a setter of a removed feature throws.
Your own code rarely gets there, because --trim keeps what it uses. Code in a string can. It can use all of JavaScript and process.getBuiltinModule, plus every Bun and Node.js API that your own code uses. Any other API throws an error that says how to keep it.
console.log(typeof HTMLRewriter);
try {
console.log(new Function("return Bun.color('red', 'hex')")());
} catch (error) {
console.log((error as NodeJS.ErrnoException).code);
console.log((error as Error).message);
}Built with bun compile --trim, this program prints:
function
ERR_FEATURE_REMOVED
Bun.color 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 "color" to compile.trim.allow in Bun.build().To keep an API, use it by name somewhere in your code or add it to compile.trim.allow. A small feature has no name there, and its error only mentions the first way.
An API can need more than its own feature: Bun.S3Client.list() also needs the HTTP client, TLS and Brotli. Using the API by name keeps all of them, and so does allow: ["s3"].
Your own code gets there in one way: through a name that it only has at run time. The members of Bun, and methods such as toLocaleString and localeCompare that need Intl, are kept when something names them. In (1234.5)[name]("de-DE"), nothing does if name comes from process.argv, a config file or Object.getOwnPropertyNames(Number.prototype). Without --trim the call returns "1.234,5". With --trim it throws toLocaleString was removed by --trim because your code never uses it by name. --explain=trim lists the place of each such call. It has no line for Bun[name]. There are two ways out. Name the method in code that runs, for example by checking the name first with ["toLocaleString", "toFixed"].includes(name) or name === "toLocaleString". Or add "intl" to compile.trim.allow.
Bun.escapeHTML 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, use it by name somewhere in your code.| Code in a string, or a native addon | With the feature removed |
|---|---|
typeof HTMLRewriter, "HTMLRewriter" in globalThis, Object.keys(Bun) | The same as before |
new HTMLRewriter(), Bun.color("red", "hex") | Throws an Error whose code is "ERR_FEATURE_REMOVED" |
import("bun:sqlite"), require("bun:sqlite"), process.getBuiltinModule("node:sqlite") | Throws an Error whose code is "ERR_MODULE_NOT_FOUND" |
A check such as if (Bun.Image) or typeof HTMLRewriter === "function" can't tell that the feature is gone: the code takes the same path as before, and throws where it uses the feature. A try block around the call can.
bun test doesn't find these errors, because it runs your source files, where nothing is removed. Start the executable and test it from outside, like a deployed server.
If your own code names the API, the error is a bug in Bun. Report it. Until it's fixed, add the feature to the list: trim: { allow: ["color"] }.
Keep a feature#
allow in compile.trim keeps a feature that your code never uses, and the features it needs. There's no CLI flag for it, so you need a build script.
await Bun.build({
entrypoints: ["./color.ts"],
compile: { outfile: "./color", aot: true, trim: { allow: ["color"] } },
});Built this way, color.ts prints:
function
#ff0000Use allow when something the compiler can't see uses the API: a string passed to eval or new Function, a call like value[name]() whose name is only known at run time, or a native addon.
trim | Bun removes |
|---|---|
true | The APIs your code never calls. The same as --trim |
{ allow: ["sqlite"] } | What true removes, except sqlite and the features that sqlite needs |
{ remove: ["sqlite"] } | What true removes, and sqlite, even if your code uses it |
{ locales: ["en", "de"] } | What true removes, and the data for every other locale |
{ log: true } | What true removes. result.logs lists it |
Every name is in Feature names and in the type Bun.Build.TrimFeature, so your editor completes them. --explain=trim has the name in parentheses on each kept and removed line.
Remove a feature#
remove takes a feature out of the executable even if your code or a dependency uses it. Use it when an executable must not be able to do something, whatever code ends up running in it.
await Bun.build({
entrypoints: ["./server.ts"],
compile: { outfile: "./server", aot: true, trim: { remove: ["native-code", "sqlite", "image", "brotli"] } },
});Only the code behind the feature is removed. import { dlopen } from "bun:ffi" still works, typeof dlopen is still "function", and a program that never calls it runs as before. A call throws.
error: bun:ffi and its C compiler isn't in this executable: it was built with compile.trim.remove in Bun.build().
code: "ERR_FEATURE_REMOVED"The names are the ones that allow takes. A name can't be in both lists. In the report of --explain=trim, the feature's line ends with listed in compile.trim.remove.
Bun doesn't offer what it can't do. Without brotli, fetch sends Accept-Encoding: gzip, deflate, zstd. If a server answers with Content-Encoding: br anyway, the fetch rejects. The rest of node:zlib works.
Feature names#
The names that allow and remove take. Each size is roughly what the feature adds to a trimmed executable. Features share code, and the file only gets shorter where whole pages in a row are removed, so a list usually adds less than the sum of its names.
| Name | What it covers | Linux x64 | macOS arm64 |
|---|---|---|---|
cli | The whole bun command line: the package manager, the test runner, bun build, bun lint, bun fmt, bun check and the ahead-of-time compiler. Includes "lint", "fmt", "check", "install", "aot", "repl", "create", "upgrade" and "run". An executable only uses it with BUN_BE_BUN=1, with --help in BUN_OPTIONS, or when it calls Bun.build() with check or compile.aot. | 47.4 MB | 44.8 MB |
lint | bun lint. Included in "cli". | 12.2 MB | 11.9 MB |
fmt | bun fmt. Included in "cli". It shares about 8 MB of code with "lint", so keeping either one keeps that code. | 0.2 MB | < 0.1 MB |
check | bun check, --check, and type checking and declaration files in bun build and Bun.build(). Included in "cli". It shares the type checker with "lint" and "aot", so keeping any of the three keeps the type checker. | 0.3 MB | 0.3 MB |
install | The package manager: bun install, add, remove, update, link, unlink, pm and its subcommands, patch, outdated, publish, audit, why, prune, dedupe, info and bunx. Included in "cli". -i, --install and install.auto in bunfig.toml need it. | 1.8 MB | 1.6 MB |
aot | The ahead-of-time compiler: bun compile, bun build --compile --aot, bun run --aot and compile.aot in Bun.build(). Included in "cli". An executable built with aot doesn't need it to run. | 9.6 MB | 9.3 MB |
repl | bun repl. Included in "cli". | < 0.1 MB | < 0.1 MB |
create | bun create and bun init. Included in "cli". | < 0.1 MB | < 0.1 MB |
upgrade | bun upgrade. Included in "cli". | < 0.1 MB | < 0.1 MB |
run | The rest of the command line: bun run with package.json scripts and --filter, bun exec, --help and shell completions. Included in "cli". The executable doesn't need it to run its own program. | 0.6 MB | 0.3 MB |
spawn | Everything that starts a process: Bun.spawn(), Bun.spawnSync(), node:child_process, node:cluster, Bun.$, Bun.cron, Bun.WebView, Bun.Terminal, Bun.openInEditor(), process.execve(), --watch, --hot and automatic crash reporting. Includes "shell", "cron", "webview" and "terminal". Also needs it: bun commands that start processes, such as bun run and bunx. | 1.8 MB | 1.3 MB |
process-kill | process.kill(). Without it, the executable can't send a signal to any process, including itself. | < 0.1 MB | < 0.1 MB |
process-report | process.report, the Node.js diagnostic report. It lists environment variables, loaded libraries and network interfaces. | < 0.1 MB | < 0.1 MB |
dialogs | alert(), confirm() and prompt(), which read from the terminal. | < 0.1 MB | < 0.1 MB |
ipc | IPC with the parent process that started the executable with ipc or fork(): process.send(), process.disconnect(), process.channel, process.connected, and the "message" and "disconnect" events. Without it, the executable runs as if it was started without an IPC channel. | < 0.1 MB | < 0.1 MB |
native-code | Everything that loads native code: bun:ffi, process.dlopen(), .node addons, and Database.loadExtension() in bun:sqlite. Includes "ffi" and "native-addons". Without it, loadExtension() throws an SQLiteError. On macOS, "sqlite" loads native code too: the system's SQLite library. | 0.7 MB | 0.5 MB |
jit | The JIT compilers for JavaScript, regular expressions and WebAssembly. An executable built with aot never starts them, so trim removes them. Without aot, trim keeps them. | 14.1 MB | 14.2 MB |
interpreter | Everything that runs code that isn't in the executable: the JavaScript parser, the interpreter and the transpiler. eval(), new Function(), import() and require() of files that weren't compiled into the executable, Bun.Transpiler, Bun.plugin(), and built-in modules that your code doesn't import all need it. trim only removes it from an executable built with aot. | 3.7 MB | 2.7 MB |
bundler | bun build, Bun.build(), HTML imports and the development server in Bun.serve(), with the CSS parser and the React Compiler. Bun.Transpiler doesn't need it, and neither does importing TypeScript or JSX files at run time. | 6.0 MB | 2.5 MB |
test | bun test, bun:test and node:test: the test runner, expect() and its matchers, mocks and snapshots. | 2.1 MB | 1.8 MB |
inspector | The debugger: --inspect, BUN_INSPECT and node:inspector. An executable built with aot also needs compile.inspector before a debugger can attach. | 0.6 MB | 0.7 MB |
profiler | The CPU and heap profilers, heap snapshots and code coverage: --cpu-prof, --heap-prof, Bun.generateHeapSnapshot(), v8.writeHeapSnapshot() and the profiling functions in bun:jsc. | 0.3 MB | 0.4 MB |
source-maps | Source maps: the ones that the sourcemap option embeds in the executable, which stack traces use, and SourceMap and findSourceMap() in node:module. | < 0.1 MB | < 0.1 MB |
image | Bun.Image and .image() on a Blob. | 1.3 MB | 0.9 MB |
archive | Bun.Archive, and everything else that reads or writes a tar archive. Also needs it: unpacking packages in bun install, and bun pm pack; unpacking a template in bun create; installing a missing package during bun build or bun compile. | < 0.1 MB | < 0.1 MB |
sqlite | bun:sqlite, node:sqlite and imports with type: "sqlite". On macOS, Bun loads the system's SQLite, so this is only the bindings and the loader. Also needs it: Bun.SQL with a sqlite: URL. | 1.9 MB | < 0.1 MB |
sql | Bun.sql and Bun.SQL for PostgreSQL and MySQL. | 0.3 MB | 0.3 MB |
redis | Bun.redis, Bun.RedisClient and --redis-preconnect. | 0.3 MB | 0.3 MB |
s3 | Bun.s3, Bun.S3Client, and s3: URLs in Bun.file(), Bun.write() and fetch(). Without it, an s3: URL throws an Error whose code is "ERR_FEATURE_REMOVED". Also needs it: writing a Bun.Image to S3; receiving an S3 file from another process. | 0.1 MB | < 0.1 MB |
html-rewriter | HTMLRewriter. | < 0.1 MB | < 0.1 MB |
ffi | bun:ffi and its C compiler. Included in "native-code". | 0.7 MB | 0.5 MB |
native-addons | Native addons: .node files, process.dlopen(), N-API, and the V8 and libuv symbols that addons link against. Included in "native-code". | < 0.1 MB | < 0.1 MB |
webassembly | WebAssembly, node:wasi and imports of .wasm files. | 1.0 MB | 1.1 MB |
shell | Bun.$, the shell. Included in "spawn". | 0.4 MB | 0.4 MB |
webview | Bun.WebView. Included in "spawn". | < 0.1 MB | < 0.1 MB |
fetch | Bun's HTTP client: fetch(), Bun.fetch() and Bun.FetchSession. Request, Response and Headers don't need it. Neither does http.request() in node:http, which needs "sockets". | 0.3 MB | < 0.1 MB |
serve | Bun.serve(), and the node:http and node:https servers, which are built on it. Without it, their listen() emits an "error" event. fetch(), http.request(), node:net and node:http2 don't need it. | 0.4 MB | 0.3 MB |
sockets | Bun.listen() and Bun.connect(), and everything that opens or accepts a connection through node:net: net.connect(), net.createServer().listen(), node:tls, node:http2, http.request() and https.request(). fetch(), WebSocket, Bun.serve(), Bun.SQL and Bun.redis don't need it. | < 0.1 MB | < 0.1 MB |
dns | Bun.dns and node:dns. Without it, fetch(), Bun.connect() and node:net still resolve host names, but node:net doesn't emit lookup and node:dgram only takes IP addresses. | 0.3 MB | 0.3 MB |
http2 | node:http2, and HTTP/2 and HTTP/3 in Bun.serve(). | 0.3 MB | < 0.1 MB |
http3 | HTTP/3 in Bun.serve(), and node:quic. Also needs it: fetch() over HTTP/3. | 0.5 MB | < 0.1 MB |
websocket | The WebSocket client. WebSockets in Bun.serve() don't need it. | 0.2 MB | < 0.1 MB |
udp | Bun.udpSocket() and node:dgram. | < 0.1 MB | < 0.1 MB |
worker | Worker and node:worker_threads. | < 0.1 MB | < 0.1 MB |
node-vm | node:vm. | 0.1 MB | < 0.1 MB |
node-crypto | node:crypto. | 0.4 MB | < 0.1 MB |
web-crypto | crypto.subtle, SubtleCrypto and CryptoKey. crypto.getRandomValues() and crypto.randomUUID() don't need it. Also needs it: receiving a CryptoKey from another process. | 0.2 MB | < 0.1 MB |
password | Bun.password. | < 0.1 MB | < 0.1 MB |
brotli | Brotli in node:zlib, CompressionStream, DecompressionStream and fetch. Without it, fetch doesn't offer br in Accept-Encoding. | 0.4 MB | 0.3 MB |
intl | All of Intl, plus localeCompare(), toLocaleString(), toLocaleDateString() and toLocaleTimeString(), with their locale data. Includes "intl-segmenter". On macOS, Bun uses the system's ICU, so this is only the bindings. | 10.5 MB | < 0.1 MB |
intl-segmenter | Intl.Segmenter and its dictionaries. Included in "intl". On macOS, the dictionaries come with the system. | 3.1 MB | < 0.1 MB |
temporal | Temporal and Date.prototype.toTemporalInstant(). | 0.5 MB | 0.5 MB |
bun-jsc | bun:jsc and node:v8. | < 0.1 MB | < 0.1 MB |
plugin | Bun.plugin(). | < 0.1 MB | < 0.1 MB |
fs-router | Bun.FileSystemRouter. | < 0.1 MB | < 0.1 MB |
terminal | Bun.Terminal and the terminal option of Bun.spawn(). Included in "spawn". | < 0.1 MB | < 0.1 MB |
cron | Bun.cron(). Included in "spawn". | < 0.1 MB | < 0.1 MB |
secrets | Bun.secrets. | < 0.1 MB | < 0.1 MB |
markdown | Bun.markdown. Importing a .md file doesn't need it. | < 0.1 MB | < 0.1 MB |
yaml | Bun.YAML. Importing a .yaml file doesn't need it. | < 0.1 MB | < 0.1 MB |
toml | Bun.TOML. Importing a .toml file doesn't need it. | < 0.1 MB | < 0.1 MB |
json5 | Bun.JSON5. Importing a .json5 file doesn't need it. | < 0.1 MB | < 0.1 MB |
jsonc | Bun.JSONC. Importing a .jsonc file doesn't need it. | < 0.1 MB | < 0.1 MB |
jsonl | Bun.JSONL. | < 0.1 MB | < 0.1 MB |
xml | Bun.XML. Importing an .xml file doesn't need it. | < 0.1 MB | < 0.1 MB |
glob | Bun.Glob. | < 0.1 MB | < 0.1 MB |
semver | Bun.semver. | < 0.1 MB | < 0.1 MB |
color | Bun.color(). | < 0.1 MB | < 0.1 MB |
cookie | Bun.Cookie, Bun.CookieMap and request.cookies in Bun.serve(). | < 0.1 MB | < 0.1 MB |
csrf | Bun.CSRF. | < 0.1 MB | < 0.1 MB |
hash | Bun.hash(), Bun.sha() and the hash classes from Bun.MD4 to Bun.SHA512_256. Bun.CryptoHasher has its own name, "crypto-hasher". | < 0.1 MB | < 0.1 MB |
crypto-hasher | Bun.CryptoHasher. | < 0.1 MB | < 0.1 MB |
url-pattern | URLPattern. | < 0.1 MB | < 0.1 MB |
What a trimmed executable does not do#
Without --trim | With --trim | |
|---|---|---|
BUN_BE_BUN=1 ./server install, with --no-aot | Acts as the bun CLI | Tells you to run bun instead, and exits with code 70 |
BUN_BE_BUN=1 ./server --version | Prints the version | Prints the version |
A bunfig.toml in the directory the executable runs in | Bun reads it at startup | Bun doesn't read it, unless you also pass --compile-autoload-bunfig |
Bun.build with check or compile.aot, called by the executable | Works | Throws a TypeError |
A trimmed executable still reads .env, prints stack traces with the lines and columns of your sources, and writes profiles with --cpu-prof and --heap-prof in BUN_OPTIONS. Addresses in crash reports and profiles mean the same as in the untrimmed executable. On macOS, Bun signs the executable again. To debug a program with the inspector, build it with --compile-inspector, which keeps the debugger.
Which options still work#
Most options in BUN_OPTIONS and --compile-exec-argv work as before. A few need a feature that --trim removed.
| Options | Can't be used with |
|---|---|
--help, -h, --aot, --aot-log-type-mismatches, --expose-internals, --watch, --hot, -i, --install | Any trimmed executable |
--preload, -r, --require, --import, --eval, -e, --print, -p | An executable that --trim removed an API from |
--fetch-preconnect, --redis-preconnect, --sql-preconnect | An executable whose program doesn't use that client |
BUN_OPTIONS=--sql-preconnect ./serverThe executable prints why and exits with code 1, before any of your code runs. The message has the long name of the option, whichever way you wrote it.
error: --sql-preconnect can't be used with this executable because --trim removed the feature it needs--preload and the other options that load code are in the second row because that code could use any API. A build flag comes first: all of them, and --inspect, need --compile-inspector. See Options that load code.
An option in --compile-exec-argv is part of the build, so bun compile --trim keeps what the option needs. With --compile-exec-argv=--inspect the executable has the debugger and keeps every API. --compile-inspector counts the same way.
-i and --install install missing packages while the program runs. That takes the package manager, which is one of the tools. trim: { allow: ["install"] } keeps it.
What comes after the name of the executable on the command line is never an option of Bun. It goes to your program.
Which builds of Bun can trim#
--trim works on every platform that Bun ships for, with a bun that was linked for it. A bun that you built yourself may not be.
error: this build of Bun doesn't support --trim (NoTrimTable)If a feature is missing#
Your program can recover from every error under Using a removed feature. If code reaches a removed feature in a way that Bun didn't foresee, the process exits with code 70, and a try block can't catch that.
error: --trim removed HTMLRewriter from this executable, but the program needs it. This is a bug in Bun. Please report it with this number: 118Report it with the number. Until it's fixed, add the feature to compile.trim.allow.