← haandevENTR

Compiling JavaScript ahead of time: bytecode, JIT and Bun AoT

Bun can now compile TypeScript straight to machine code. To see what that means, start with how a JavaScript engine runs code, then look at what Android, Java and Hermes ran into when they tried the same thing. They all arrive at one point: an ahead-of-time compiler has to know at build time what a JIT learns at run time, and in JavaScript the only source of that knowledge is types.

1. The question: --bytecode versus --aot

For a while now Bun has been able to package a program into a single executable (bun build --compile), and to embed pre-built bytecode in it (--bytecode). The new option, bun compile, short for bun build --compile --aot, compiles the program to machine code.

At first glance the two do the same job. Both say: process the source in advance so there is less to do at run time. To see the difference, you have to follow what a JavaScript engine does with a function from the moment it first sees it to the moment it turns it into machine code. The two options cut that chain at different places.

2. How a JavaScript engine runs code

Take JavaScriptCore (JSC), the engine Bun uses. V8 and SpiderMonkey differ in detail, but the outline is the same.

  1. Parsing. The source text becomes a syntax tree. Engines do this lazily: a function body is fully parsed only when the function is first called.
  2. Bytecode. The tree is translated into the engine's own intermediate language. Bytecode isn't machine code; it's a sequence of instructions for an interpreter to read.
  3. The interpreter (LLInt). A function first runs in the interpreter, which steps through its bytecode. It's slow, but it starts at once. While it runs, the engine keeps a profile: what shapes of object arrive at each property access, which function each call reaches, whether each addition sees integers.
  4. The baseline JIT. A function that has run enough is translated into a plain machine-code version of its bytecode. It still handles the general case, but the cost of interpreting is gone.
  5. DFG and FTL. Functions that stay hot go to optimizing compilers that use the profile. FTL's back end is B3, a compiler Apple wrote for JSC in 2016 to replace LLVM there.

The key word in the last step is speculation. If the profile says p.x always sees objects of the same shape, the optimizing compiler turns the access into a single memory load with a small check in front. If one day an object of another shape arrives, the check fails, the optimized code is thrown away and the function falls back to the interpreter (deoptimization). This is why a JIT can be fast in a dynamic language: it doesn't need to know the types; it watches them.

The costs of this arrangement are paid at the start of a process: parsing, slow first runs in the interpreter, memory for the profile, and CPU for compilers running on background threads. The return comes over the rest of the process's life. For a server that stays up for weeks, that's a good trade. For a CLI tool that starts, does one job and exits after 300 milliseconds, the cost is paid and most of the return never arrives.

JAVASCRIPTCORE · FROM SOURCE TO MACHINE CODEprofile: object shapes, call targets, integer or doubleSourcetextParsesyntax treeBytecodeinstructionsLLIntinterpreterBaselineplain JITDFGoptimizing JITFTL · B3optimizing JITdeoptimization: a guess fails, back to the interpreterparsing: CPU at start-upslow first runsprofile memory, compiler threads in the backgroundall paid at the start of every process
JavaScriptCore's tiers. The interpreter collects a profile while it runs, and the optimizing tiers compile against it; when a guess turns out wrong, the code is thrown away and the function goes back to the interpreter.

3. Where the chain can be cut

Someone who wants to lower the start-up cost can cut this chain in three places.

Caching the bytecode. The first two steps, parsing and producing bytecode, happen at build time and the result is written to a file. V8's code cache and Bun's --bytecode are of this kind. The execution model doesn't change: the program still starts in the interpreter, still collects a profile, still warms up through the JIT. What is saved is the parsing time.

Snapshotting the heap. The program is started, run until its initial set-up is done, and the state of memory at that moment is written to a file. Later starts resume from that image. V8's startup snapshots, and the snapshot option of Node's single executable applications (since 20.6), are of this kind. It removes the set-up cost; it doesn't remove the JIT's warm-up.

Moving the whole chain to build time. Machine code is produced at build time and there is no JIT at run time. This removes not just the start-up cost but the profile memory and the compiler threads too. But it also removes the profile that the speculation in section 2 rests on: at build time there is no run to watch.

The first two cuts are safe, because they leave the JIT alone. The third requires knowing, from somewhere else, what the JIT would have learned while running. Everyone who has tried it ran into the same question.

WHERE EACH STAGE HAPPENSParseBytecodeInterpreterBaseline JITOptimizing JITRun as isbun build --compileCache the bytecode… --compile --bytecodecutAhead of timebun compilenot usedeval onlynot usedB3, at buildat build timeat run time, in every process
Bytecode caching moves only the first two stages to build time; the interpreter and the JIT still run in every process. Ahead-of-time compilation moves the optimizing compiler itself and keeps the interpreter only for code built from strings.

4. Those who tried before

Android: Dalvik, ART and the hybrid. Android apps ran for years on the Dalvik virtual machine, which gained a JIT in 2010. Android 5.0 (2014) replaced Dalvik with ART and began compiling apps entirely to machine code at install time. Apps started fast, but installs and system updates took minutes and the compiled code took real storage. Android 7.0 (2016) went hybrid: an app first runs with a JIT, the JIT records which methods are hot, and while the device is idle only those methods are compiled ahead of time. Ahead-of-time compilation worked once it was fed a profile.

Java: GraalVM Native Image. GraalVM can compile a Java application into a single executable; the JVM's warm-up disappears and start-up drops to milliseconds. The price is the closed-world assumption: the compiler has to see, at build time, all the code the program can reach. Reflection, dynamic class loading and proxies generated at run time either have to be declared in configuration files in advance or they don't work. At peak it usually trails a warm JVM, and Oracle's answer is again a profile: in Oracle GraalVM, an instrumented build is run on real work and the program is rebuilt with the profile it collected.

JavaScript: Hermes and Static Hermes. Meta released the Hermes engine for React Native in 2019. Its central decision was to give up the JIT: JavaScript is compiled to bytecode at build time and shipped inside the app, and on the phone only an interpreter runs. On phones the priority was start-up time and memory rather than peak speed, and iOS doesn't let third-party apps create executable memory at run time in any case. Static Hermes, announced in 2023, went further: it compiles JavaScript with type annotations to native code, by way of C and LLVM, and treats sound types as reliable information at build time. That work later became Hermes's main line of development, which, tellingly, has since gained a JIT as well.

The lesson of the three stories is the same. Compiling ahead of time reliably improves start-up and memory. Keeping peak performance requires the compiler to have, at build time, the knowledge the JIT gathers at run time. Android and GraalVM took it from the profile of an earlier run. Static Hermes chose to take it from the types in the source.

AHEAD OF TIME, THREE TIMESAndroidknows from: a profileof earlier runsDalvik JITART: all AOT at installhybrid: JIT + profile-guided AOTJavaknows from: a profileof a training runHotSpot JITGraalVM Native Imagelater: PGO, in Oracle GraalVMJavaScriptknows from: typeannotationsV8, JSC: tiered JITsHermes: bytecode, no JITStatic Hermes: types → nativeBun AoT20082012201620202024
Each platform moved from a JIT toward ahead-of-time compilation, and each had to find another source for what the JIT used to learn by watching. Android and Java found it in a profile; JavaScript is looking for it in types.

5. Bun's three options

Bun offers three ways that map onto three points of that range:

bun build --compile … --compile --bytecode bun compile
What the file holds Bundled JS Bundled JS + JSC bytecode Machine code
Where the chain is cut Nowhere After parsing and bytecode The whole chain
JIT at run time Yes Yes No
TypeScript types Removed Removed Checked, then used
Builds for Any platform, with --target Any platform, with --target Only the platform it runs on
Status Stable Stable Experimental

--bytecode is the first cut from section 3. By Bun's own measurement, tsc starts twice as fast with bytecode, and large applications gain two to four times. But once the program is up, everything is as it is under bun run.

bun compile is the third cut. Starting from the entry point it follows the imports, type checks the TypeScript, and works out across the whole program which objects reach each variable and which function each call reaches. Then it generates machine code for every reachable function, npm packages included. It does this with B3, the same back end JSC uses in FTL: Bun has, in effect, moved the JIT's top tier to build time. The output is a copy of the Bun runtime without a JIT. On Linux 6.7 and later the process even asks the kernel to forbid new executable memory (PR_SET_MDWE), so the JIT isn't merely unused; it can't be used.

The question from section 4 applies here too: where does bun compile get what the JIT would have learned from the profile?

6. Bun AoT's source of knowledge: TypeScript types

Bun takes Static Hermes's road: it takes the knowledge from types.

If a value is one the program creates itself, its type is treated as a fact and the value gets a fixed memory layout. In the example in Bun's pull request, a Vec with two number fields is a 16-byte header and two raw doubles, and reading a field whose type is proven is one load, like a struct field in Go or Rust. If the value comes from outside, an argument from a caller the compiler can't see or the result of JSON.parse, the type is used as a prediction: it is tested once, and on a miss the code takes a slow path. That resembles the JIT's speculation, except that the guess comes from an annotation rather than from watching.

Two consequences follow. First, bun run strips the types but bun compile checks them, and if there is a type error it writes no executable. The same : number is a comment in one mode and compiler input in the other. Second, untyped code can run slower under AoT than under the JIT: where there is no type, the compiler can neither watch nor prove, and it looks the property up by name at run time. Bun's documentation says so plainly, and bun compile --explain lists those accesses, most expensive first.

JIT · WATCHESp.xAAAAprofile: alwaysshape Acompiled: one load behind a shape guardBthe guard fails: deoptimize,back to the interpreter, watch againlearns from the run; needs no typesAHEAD OF TIME · PROVESp.xp was built by the program:the type is a factone loadp comes from outside (anargument, JSON.parse):the type is a predictionone test, then a load;slow path on a missp has no typelookup by namenothing to watch; uses only what it can prove
The JIT finds out what p is by watching the program run. The ahead-of-time compiler has no run to watch, so how fast p.x is depends on what the types let it prove.

7. Results for a short-lived process

Bun's first serious measurement was on the Claude Code CLI. The choice is no accident: Anthropic acquired Bun in December 2025, Claude Code ships as a Bun executable, and a coding agent is the textbook short-lived, frequently started process.

JIT executableahead-of-time executableTime to first prompt318 ms141 msPeak private memory185.4 MB44.7 MBCPU time1,542 ms512 msPeak RSS308.5 MB180.8 MBbars relative to the JIT executable · measured by the Bun team
The Claude Code CLI, built with the JIT and ahead of time. The biggest gain is private memory: no bytecode, no JIT-compiled code, no profile.

The second measurement is a longer job: TypeScript 6 type checking a project of 370,000 lines.

Wall time Memory CPU time
TypeScript 6 under bun run (JIT) 14.00 s 2,581 MB 29.2 s
TypeScript 6 under bun compile (AoT) 4.25 s 963 MB 5.3 s
TypeScript 7 (rewritten in Go) 2.68 s 1,256 MB 10.8 s

This second result stands out, because fourteen seconds is not a short time, and the JIT still loses. tsc is a large, highly polymorphic code base; warming it up takes the JIT seconds, and much of the 29 seconds of CPU goes to compiler threads. So whether a process counts as short-lived isn't measured in seconds alone; it also depends on the size of the program.

The TypeScript 7 row extends the story from section 4. Microsoft solved tsc's start-up and memory problem by rewriting the compiler in Go. Jarred Sumner presents AoT as the option of not having to rewrite in Rust. By these numbers AoT closes most of that gap, but not all of it.

8. Results for a long-lived process

The same pull request has peak-throughput measurements for server frameworks. In the first second AoT is one to two times ahead; the JIT hasn't warmed up yet. After warm-up:

Framework Warm difference
Koa JIT 22.9% faster
Fastify JIT 16.9% faster
Elysia JIT 13.5% faster
Express JIT 7.1% faster
Hono, Bun.serve AoT about 2.5% faster

This is the picture GraalVM showed. In a server that stays up, the warm-up is paid once, and the profile the JIT watches is richer than what the compiler can prove. Sumner concedes that ahead-of-time compilation generally gives lower peak performance; the team hopes to close the gap with a stricter flavour of TypeScript and locked-down built-ins.

The pattern in the table matches section 6. The frameworks where AoT keeps up, Hono and Bun.serve, are written in TypeScript and have few layers. The ones where it falls furthest behind, Koa and Express, are older, untyped JavaScript making dynamic calls through middleware chains. AoT is as fast as what it can prove from types.

What AoT still offers a warm server is memory. For one server that hardly matters; across hundreds of small containers, or where memory is what you're billed for, it does. And under autoscaling, every new copy is a cold process for its first second.

9. The price of a closed world

GraalVM's closed-world assumption exists in Bun too, in two forms.

Built-ins are locked. For the compiler to turn arr.push(x) directly into the operation, it has to assume Array.prototype.push won't change at run time. Bun enforces that assumption: existing members of built-in objects can't be replaced, and assigning to Array.prototype.push throws a TypeError. Object.prototype, RegExp.prototype, Promise.prototype and the generator prototypes can't be changed at all. Adding new members is allowed.

Code built from strings is interpreted. eval, new Function and node:vm aren't forbidden, but they aren't compiled to machine code either: they run in JSC's interpreter, in the same process, on the same objects. Validator and serializer libraries that generate their hot path with new Function therefore slow down noticeably under AoT. A call the compiler can't trace, such as globalThis[name](code), throws an EvalError. Loading code from disk with a dynamic import() is off by default.

The other limits: no cross-compilation, no breakpoints, and building a large application takes tens of seconds and gigabytes of memory, with no cache between builds. Linux x64 is tested; on Windows many tests still fail.

10. A comparison with V8 isolates

There is another, quite different answer to the start-up problem. Cloudflare Workers and Deno Deploy don't start a new process per request; inside one warm V8 process, each customer's code runs in its own isolate. Cloudflare put the cost of loading a Worker at about 5 milliseconds in 2020, short enough to hide behind the TLS handshake; in 2025 it conceded that "eliminating" cold starts had been a strong word, as its limits grew.

The costs from section 2 have to be paid by someone; what differs is who:

Who pays How it's recovered
A JIT per process (bun run, Node) Every process, at start-up Over that process's own life
A V8 isolate The platform's one long-lived process By sharing it across thousands of customers' code
AoT (bun compile) The developer, at build time Every copy starts without the cost

An isolate stretches the engine's life and fits short jobs inside it. AoT never starts the engine at all. The first is a platform solution: it needs a company hosting the isolates, and the code is limited to that platform's APIs. The second is a toolchain solution: the output is an ordinary executable that runs anywhere as a full process, at the price of build time and the limits in section 9.

Where isolates are already in use, AoT adds little, because the start-up problem is solved there. AoT belongs where every scale-up starts a container or a micro-VM, and in CLI tools that are shipped to users.

BUILD TIMERUN TIMEA JIT per process…A V8 isolateone platform process, warmed once; many customers' short jobs inside itAhead of timecompile, once…warm-up or compilationwork under a JITwork in compiled code
The cost of learning the program is paid somewhere. A JIT per process pays it in every process; an isolate pays it once inside a platform that lives for a long time; ahead-of-time compilation pays it once, before anything runs.

11. Which one, when

The choice turns on two things: how long the process lives, and how much of the code is typed.

If you aren't sure, Bun's advice is to build both and put them under the same load; bun compile --no-aot produces the same executable with the JIT.

12. Limits

  1. The numbers are the Bun team's. All the Bun measurements here come from the pull request and the documentation. I haven't seen an independent reproduction, and I haven't measured anything myself.
  2. The feature is experimental and moving. The first preview reported an executable nearly twice as large; the merged pull request says small programs now come out smaller. The numbers and the restrictions will keep changing.
  3. The peak-performance gap may be structural. Android and GraalVM closed it with profiles. Bun hopes to close it with types, but what a JIT sees at run time is more than what static analysis can prove. How much of the gap can close is an open question.

13. Conclusion

The difference between --bytecode and bun compile is where they cut the engine's chain. Bytecode moves only the parsing to build time; the JIT stays as it is. bun compile moves the whole chain and removes the JIT.

Removing the JIT means losing what it gathers at run time. Android and Java got that knowledge back from the profile of an earlier run. Bun, like Static Hermes, takes it from the types in the source. In return, built-ins are locked, code built from strings is interpreted, and untyped code gets slower.

For a long time, types in JavaScript were information for the developer: the compiler checked them and threw them away. In bun compile, types become the build-time counterpart of what the JIT learns at run time.

Sources

Bun

JavaScriptCore

Earlier ahead-of-time compilers

Start-up elsewhere

Note: every Bun measurement here is the Bun team's, taken from the pull request above. The feature is experimental, and the flags, numbers and restrictions may have changed since this was written.