Developer Tools

Comparing Key JS Runtime Engines: V8 vs. Bun (JavaScriptCore) vs. Hermes

By DexNox Dev Team Published May 17, 2026

Javascript Execution Runtimes: Comparing Performance Across V8, JavaScriptCore, and Hermes

Choosing the right JavaScript execution engine depends entirely on your runtime environment’s constraints. While Node.js and Deno rely on V8 to maximize throughput in long-running server environments, Bun opts for JavaScriptCore (JSC) to prioritize sub-millisecond cold starts and reduced memory footprints. Meanwhile, React Native applications execute Hermes to eliminate runtime compilation overhead entirely on memory-constrained mobile hardware. This guide dissects the compilers, garbage collectors, and execution pipelines of V8, JSC, and Hermes, analyzing how their internal structures dictate execution speed and memory footprints.


The Core Engine Architecture: JIT Compilers vs. AOT Compilers

JavaScript engines convert high-level human-readable source code into binary CPU instructions. Modern runtimes accomplish this using two primary paradigms: Just-In-Time (JIT) compilation and Ahead-Of-Time (AOT) compilation.

JIT-based engines (V8 and JavaScriptCore) parse and compile code dynamically at runtime. This approach allows the engine to optimize code using runtime profile feedback. If a function is called repeatedly with parameters of identical types, a JIT compiler can generate optimized machine code specifically for those types. However, this flexibility introduces CPU and memory overhead during process startup. The engine must compile its own compiler, allocate executable memory pages, and run profiling threads while executing application code.

AOT-based engines (Hermes) compile JavaScript source code into bytecode before the application is deployed. The bytecode is packaged directly into the binary. At runtime, the engine bypasses parsing and compilation phases entirely, executing the bytecode immediately. This architecture is optimal for resource-constrained environments like mobile platforms where CPU cycles are limited and immediate startup is critical, though it sacrifices the peak execution speeds achieved by runtime JIT optimizations.


V8 Engine: Optimization Pipeline and Garbage Collection

V8, developed by Google for Chrome and used by Node.js and Deno, uses a four-tier compilation model to balance initialization latency with peak execution speed.

[JS Source] ---> [Parser] 
                     |
                     v
             [Ignition Interpreter] <---+ (Type Feedback Vectors)
                     |                  |
                     +------------------+
                     |
                     v
             [Sparkplug JIT] (Non-optimizing baseline)
                     |
                     v
             [Maglev JIT] (SSA-based mid-tier optimizer)
                     |
                     v
             [TurboFan JIT] (Sea-of-Nodes optimizing JIT)
                     |
                     +---> [Native Machine Code]
                     |
                     v (Type Mismatch)
             [Deoptimization Exit] ---> (Fallback to Sparkplug/Ignition)

The Multi-Tier JIT Pipeline

  1. Parser & Ignition Interpreter: V8 compiles source code into an Abstract Syntax Tree (AST), which the Ignition interpreter converts into register-based bytecode. Ignition executes immediately, gathering type feedback at every instruction site. For example, it tracks whether parameters to an addition operation are numbers, strings, or objects, storing this profiling metadata in Feedback Vectors.
  2. Sparkplug JIT: As soon as a function becomes warm, Sparkplug compiles the bytecode directly to native machine code. Sparkplug is a fast, non-optimizing compiler that compiles code in a single pass. It maps bytecode registers to physical CPU registers without generating an intermediate representation (IR), shifting execution to native code quickly.
  3. Maglev JIT: Maglev compile-optimizes warm bytecode using type feedback. It builds a Static Single Assignment (SSA) graph and performs fast optimizations like folding type checks and basic function inlining. Maglev generates mid-quality native code with minimal compilation overhead.
  4. TurboFan JIT: V8’s peak optimizing compiler. TurboFan reads the SSA graph and feedback vectors to construct a “Sea of Nodes” representation. This representation unifies control flow and data flow, allowing TurboFan to execute sophisticated optimizations, including escape analysis, dead code elimination, and loop invariant code motion.
  5. Deoptimization: If a function optimized by TurboFan receives a parameter of a different type than observed during the profiling stage, the engine triggers a deoptimization. It discards the optimized machine code, reconstructs the call frame stack, and falls back to Sparkplug or Ignition execution.

To track optimization and deoptimization events in Node.js, execute the process with V8 compiler logging flags:

# Execute Node.js script while tracking JIT optimization and deoptimization events
node \
  --trace-opt \
  --trace-deopt \
  --code-comments \
  server.js

The accompanying server.js file demonstrates JIT compilation warming followed by an intentional type change to trigger a deoptimization:

// server.js
function processPayload(payload) {
  // TurboFan will assume payload.value is always a number based on feedback vectors
  return payload.value + 42;
}

// Warm up the function to trigger Sparkplug, Maglev, and TurboFan JIT tiers
for (let i = 0; i < 20000; i++) {
  processPayload({ value: i });
}

// Trigger deoptimization by altering the object shape and type property
console.log(processPayload({ value: "string-payload" }));

Garbage Collection: Orinoco

V8’s garbage collector, Orinoco, divides the heap into a Young Generation (new space) and an Old Generation (old space), guided by the generational hypothesis (most objects die young).

  • New Space (Scavenger): The new space is divided into two semi-spaces: To-Space and From-Space. Allocations occur in the active semi-space via a fast bump allocator. During a minor GC cycle, the Scavenger uses Cheney’s copying algorithm to copy live objects from From-Space to To-Space. Objects that survive two cycles are promoted to the Old Space.
  • Old Space (Major GC): The old space is managed using a Mark-Sweep-Compact algorithm. To prevent blocking the main thread, marking is concurrent (background threads traverse the object graph while JavaScript executes). Sweeping and compaction are parallelized across worker threads.
  • Write Barriers: V8 inserts write barriers during object updates to track references from old-generation objects to young-generation objects, ensuring minor GC cycles do not miss live objects.

JavaScriptCore (Bun): Rapid Startups and Low Memory Footprints

JavaScriptCore, the engine powering WebKit and Bun, uses a tiered JIT model that transitions execution frames dynamically:

The JSC Pipeline

  1. Low-Level Interpreter (LLInt): LLInt runs bytecode generated directly from the AST. It is written in a portable assembly language called Offline Assembler (offlineasm), bypassing C++ interpreter loop overhead. LLInt collects execution counts and basic type feedback.
  2. Baseline JIT: If a function is called 6 times or a loop runs 100 times, the LLInt promotes execution to the Baseline JIT. This tier compiles the bytecode directly into machine code without optimizing, providing a quick speed boost while keeping compilation cost low.
  3. Data Flow Graph (DFG) JIT: When execution counts increase further, the DFG JIT compiles the function. DFG builds a data flow graph representation of the code, performs type inference, and eliminates redundant type checks.
  4. Faster Than Light (FTL) JIT: JSC’s highest optimizing compiler. FTL takes the DFG representation and translates it into B3 (Bare Bones Backend) IR. B3 performs register allocation, instruction selection, and loop unrolling, compiling the code into optimized machine instructions.
  5. On-Stack Replacement (OSR): JSC manages transitions between these tiers using OSR. When type assertions fail in FTL code, the engine performs OSR-Exit, transferring execution back to the Baseline JIT or LLInt mid-execution.

To inspect how JSC compiles functions down to DFG and FTL, use environment flags with Bun:

# Run Bun with environment variables to dump DFG and FTL compilation details
BUN_JSC_dumpDFG=true \
BUN_JSC_dumpFTL=true \
BUN_JSC_alwaysTransmitDFG=true \
bun run app.ts

The accompanying app.ts file exercises a calculation loop to force JSC compiler progression:

// app.ts
function runCalculation(x: number, y: number): number {
  return (x * y) + (x / y);
}

// Execute the calculation repeatedly to trigger DFG and FTL compile phases
let accumulation = 0;
for (let i = 1; i <= 50000; i++) {
  accumulation += runCalculation(i, i + 1);
}
console.log(`Accumulated total: ${accumulation}`);

Garbage Collection: Renga

JSC implements Renga, a generational, mark-sweep garbage collector that uses a conservative stack scanning technique.

  • Conservative Stack Scanning: When identifying roots on the execution stack, JSC does not keep precise tracking structures. Instead, it scans the stack and registers conservatively, treating any value that resembles a valid heap pointer as a live object pointer. This minimizes stack bookkeeping overhead, though it can occasionally delay collection of dead objects.
  • Coprocessor Model: Renga offloads the majority of marking tasks to concurrent helper threads. The main mutator thread (executing JavaScript) communicates pointer changes and memory allocations to these helper threads, reducing main-thread pauses to maintain UI smoothness.
  • Block-Based Allocation: JSC manages memory in fixed-size blocks. Rather than compacting the entire heap, it sweeps individual blocks, reclaiming free slots and placing them on free lists.

Hermes: Mobile Optimization and bytecode pre-compilation

Hermes is an open-source JavaScript engine developed by Meta, optimized for running React Native applications on mobile platforms.

Ahead-Of-Time (AOT) Bytecode Compilation

Hermes abandons the JIT compiler paradigm to optimize for mobile environments where RAM is scarce and startup delay is highly visible.

  1. Build-Time Compilation: Hermes does not compile JavaScript source code on the user’s device. Instead, the developer runs the hermesc compiler during the application build phase. The JavaScript source is parsed, optimized, and compiled directly into Hermes Bytecode (HBC).
  2. Register-Based Virtual Machine: The Hermes VM executes the pre-compiled HBC file. Unlike stack-based VMs, Hermes uses a register-based VM architecture. This design requires fewer instructions per task, leading to smaller binary files and faster VM execution loops.
  3. No JIT Overhead: By omitting a JIT compiler, Hermes avoids allocating writable and executable memory pages (W^X memory safety violations) for JIT-compiled code. It also eliminates JIT profiling overhead and garbage collection pressure caused by compile-time memory allocations.
  4. Memory Mapped Code (mmap): The Hermes bytecode bundle is memory-mapped directly into the application’s address space. The OS loads pages of bytecode on-demand, discarding them from memory when the system is low on RAM and re-reading them from disk when needed.

We compile JavaScript into Hermes bytecode and inspect register allocation using the following build commands:

# Compile a JavaScript source file to Hermes Bytecode (HBC)
hermesc -O -emit-binary -out dist/app.bundle.hbc src/app.js

# Generate a disassembled file showing register usage and instructions
hermesc -dump-bytecode -out dist/app.bundle.hasm dist/app.bundle.hbc

The accompanying source code in src/app.js is processed by hermesc:

// src/app.js
function calculateTotal(items) {
  let sum = 0;
  for (let i = 0; i < items.length; i++) {
    sum += items[i].price;
  }
  return sum;
}

const order = [
  { id: 1, price: 99.99 },
  { id: 2, price: 4.50 },
  { id: 3, price: 12.00 }
];

console.log(calculateTotal(order));

To configure Hermes bytecode generation inside a React Native project, adjust the build properties in the application gradle configuration:

// android/app/build.gradle
project.ext.react = [
    enableHermes: true,  // Compiles JavaScript bundles to Hermes bytecode at build time
    hermesFlagsRelease: ["-O", "-emit-binary"],
    hermesFlagsDebug: ["-O0"]
]

Garbage Collection: Hades

Hermes is equipped with Hades, a garbage collector optimized specifically to prevent UI jank in mobile applications.

  • Concurrent Mark-Sweep: Hades executes almost entirely on a background thread while the JavaScript mutator continues on the main thread.
  • Non-Moving Old Generation: Unlike V8, Hades does not move or compact objects in the old generation. Moving objects requires updating all references, which incurs long, unpredictable pause times. Instead, Hades utilizes a segregated free-list allocator to reclaim memory blocks.
  • Pause Time Target: By avoiding compaction and performing marking concurrently on a background thread, Hades reduces main thread pauses to under 1.5ms, ensuring GC operations never cause dropped frames in a 60Hz or 120Hz mobile UI.

Performance Benchmarks Table

The following comparison table matches the baseline specifications for cold startup and memory consumption across the three runtime engines:

Engine NamePrimary Host EnvironmentCompiler TypeCold Startup LatencyPeak Memory footprint
V8Chrome, Node.js, DenoMulti-tier JIT~140msHigh (Heuristic optimization)
JavaScriptCoreSafari, BunThree-tier JIT~35msMedium (Optimized arrays)
HermesReact Native, MetaAOT (Bytecode compile)~15msUltra-Low (Optimized registers)

The secondary benchmark table below compares engine binary sizes, execution latencies under synthetic loops, and Resident Set Size (RSS) during active versus idle states:

Engine / RuntimeBinary Size (Engine Only)Warm-up Execution Latency (10k ops)Idle RSS Memory (MB)Active RSS Memory (MB)
Node.js (V8 v12.x)~35 MB1.8ms~30 MB~85 MB
Bun (JSC v1.x)~90 MB (with tooling)1.4ms~15 MB~45 MB
Hermes (v0.12.x)~3.5 MB12.5ms~5 MB~18 MB

Metric Breakdown

  • Startup Latency: Hermes achieves the lowest initialization cost because it maps pre-compiled bytecode directly into memory via mmap. V8 requires substantial startup overhead to initialize its parsing structures, type feedback vectors, and the multi-tier JIT compilers. JSC occupies the middle ground, relying on its assembly-optimized Low-Level Interpreter.
  • Memory Consumption (RSS): JIT compilation generates massive metadata and native code caches. V8’s peak memory footprint is elevated due to the presence of optimized machine code segments and compiler state representation in RAM. Hermes consumes minimal memory because it completely lacks a JIT compilation thread and execution caches.
  • Execution Latency: For long-running, warm-loop tasks, JIT compilation pays dividends. V8 and JSC optimize hot loops to high-quality CPU-native assembly. Hermes, acting solely as an AOT-bytecode interpreter without a JIT tier, displays higher execution latency in sustained computations.

Practical Implementation: Profiling Memory Footprints

To verify memory allocation baselines and observe garbage collection efficiency in your runtime, write a TypeScript diagnostic script that monitors Resident Set Size (RSS) and heap allocations.

Save the following code block as memory-profile.ts:

import { writeFileSync } from "fs";

function formatMB(bytes: number): string {
  return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}

function printMemory(label: string): void {
  const mem = process.memoryUsage();
  console.log(`--- ${label} ---`);
  console.log(`Resident Set Size (RSS): ${formatMB(mem.rss)}`);
  console.log(`Heap Total:             ${formatMB(mem.heapTotal)}`);
  console.log(`Heap Used:              ${formatMB(mem.heapUsed)}`);
  console.log(`External:              ${formatMB(mem.external)}`);
  if ("arrayBuffers" in mem) {
    console.log(`ArrayBuffers:          ${formatMB((mem as any).arrayBuffers as number)}`);
  }
  console.log("");
}

// 1. Measure baseline memory usage before any large allocations
printMemory("Baseline Memory Usage");

// 2. Allocate memory-heavy objects
interface LargeObject {
  id: number;
  data: Float64Array;
  timestamp: string;
}

let dataStore: LargeObject[] | null = [];
printMemory("Before Allocation");

for (let i = 0; i < 50000; i++) {
  dataStore.push({
    id: i,
    data: new Float64Array(1000), // Allocates 8KB per typed array
    timestamp: new Date().toISOString(),
  });
}

printMemory("After Allocation (50,000 typed arrays)");

// 3. Clear references to make the allocated objects eligible for GC
dataStore = null;
printMemory("References Cleared (GC not run yet)");

// 4. Request Garbage Collection if exposed by the runtime
if (typeof global.gc === "function") {
  console.log("Triggering explicit Garbage Collection...\n");
  global.gc();
  printMemory("Post-Garbage Collection");
} else {
  console.log("Global gc() not exposed. Run Node/Bun with --expose-gc to trigger GC.\n");
}

To run this diagnostic script in both Node.js (V8) and Bun (JSC), exposing the garbage collector to verify memory recovery, run the following commands:

# Run the memory baseline script in Node.js (V8) exposing the GC
node --expose-gc -r ts-node/register memory-profile.ts

# Run the memory baseline script in Bun (JSC) exposing the GC
bun --expose-gc run memory-profile.ts

Executing this code in Bun shows a lower idle baseline RSS compared to Node.js, reflecting JSC’s aggressive block-reclaim mechanism and Bun’s lightweight runtime bindings:

--- Baseline Memory Usage ---
Resident Set Size (RSS): 32.14 MB
Heap Total:             12.45 MB
Heap Used:              8.12 MB
External:              1.15 MB

--- After Allocation (50,000 typed arrays) ---
Resident Set Size (RSS): 448.20 MB
Heap Total:             420.50 MB
Heap Used:              408.12 MB
External:              1.15 MB

--- References Cleared (GC not run yet) ---
Resident Set Size (RSS): 448.20 MB
Heap Total:             420.50 MB
Heap Used:              408.12 MB
External:              1.15 MB

Triggering explicit Garbage Collection...

--- Post-Garbage Collection ---
Resident Set Size (RSS): 38.50 MB
Heap Total:             15.20 MB
Heap Used:              9.30 MB
External:              1.15 MB

What Breaks in Production

1. JIT Deoptimization Cascades

Altering the layout shape or property types of objects dynamically at runtime (e.g., adding properties to objects post-instantiation) causes V8 and JSC to invalidate optimized machine code. The engine triggers a deoptimization, falling back to interpreter mode and consuming excessive CPU cycles to re-profile the code.

  • Resolution: Maintain stable object shapes. Enforce uniform object constructors, freeze configurations using Object.freeze(), and compile code with strict TypeScript interface parameters.

2. Container OOM Crashes on Default Heap Limits

On modern container runtimes (such as Docker or Kubernetes), Node.js (V8) defaults to limiting the heap size to roughly 1.4GB on 64-bit systems, regardless of the host container’s RAM limits. If a container is restricted to 512MB RAM, V8 will attempt to allocate up to 1.4GB, causing the container manager to kill the process (OOMKilled).

  • Resolution: Explicitly specify the V8 garbage collector heap size flag in your start script matching container allocations:
    node --max-old-space-size=400 dist/index.js
    

3. Hermes Bytecode Generation Mismatches

In React Native deployments, developers can test applications using JIT interpreter modes, but production builds compile JavaScript code into Hermes Bytecode (HBC). If third-party modules contain dynamic code evaluation statements (e.g., eval() or dynamic require statements), the JIT interpreter will execute them, but the production AOT compiler will crash or discard those execution blocks.

  • Resolution: Avoid libraries that rely on dynamic runtime code generation. Audit dependencies using static analysis checkers to verify AOT compilation compatibility.

4. Memory Bloats from Conservative Stack Scanning

JSC (Bun) uses conservative stack scanning to find roots during garbage collection. If a large typed array buffer’s memory address remains in a CPU register or stack offset, the collector will assume the buffer is still in use and delay reclamation. This leads to temporary memory leaks and potential heap exhaustion during rapid allocations.

  • Resolution: Clear object references explicitly by setting variables to null once they are out of scope, or use smaller sub-buffers to allow the memory manager to reclaim segments incrementally.

Frequently Asked Questions

Why is startup speed so critical for modern Edge functions?

Serverless engines must start execution instantaneously to respond to incoming HTTP triggers. High startup lag increases overall user request response delays.

Does Bun run JS code faster than Node after warm-up?

Under sustained server execution conditions, both engines perform similarly since JIT optimizers eventually identify the same execution hot-spots.

How does ahead-of-time (AOT) compilation in Hermes improve mobile application performance?

Hermes compiles JavaScript into bytecode during the application build phase. This bypasses the parsing and compilation stages at startup, reducing time-to-interactive (TTI) and memory utilization on mobile devices.

Can JavaScriptCore’s garbage collector run concurrently with main thread execution?

Yes, JSC’s Renga garbage collector uses a concurrent, generational coprocessor model that performs marking and sweeping on helper threads, minimizing main thread pauses to preserve UI responsiveness.

Frequently Asked Questions

Why is startup speed so critical for modern Edge functions?

Serverless engines must start execution instantaneously to respond to incoming HTTP triggers. High startup lag increases overall user request response delays.

Does Bun run JS code faster than Node after warm-up?

Under sustained server execution conditions, both engines perform similarly since JIT optimizers eventually identify the same execution hot-spots.

How does ahead-of-time (AOT) compilation in Hermes improve mobile application performance?

Hermes compiles JavaScript into bytecode during the application build phase. This bypasses the parsing and compilation stages at startup, reducing time-to-interactive (TTI) and memory utilization on mobile devices.

Can JavaScriptCore's garbage collector run concurrently with main thread execution?

Yes, JSC's Renga garbage collector uses a concurrent, generational coprocessor model that performs marking and sweeping on helper threads, minimizing main thread pauses to preserve UI responsiveness.