Runtime Internals — How Runtimes Work Under the Hood
Status: Active | Last Updated: 2026-08-29 Category: Runtime — Internals Prerequisites: Bun runtime or Node.js experience Tags: event-loop, v8, javascriptcore, jit, worker-threads, wasm Estimated Time: 5–7 hours (Self-paced, includes lab time)
Summary
What happens between bun file.ts and your code actually running? This article explains the engine, the event loop, JIT compilation, and how to make your code run faster. Useful for debugging performance issues, understanding async ordering, and knowing when Node's worker threads or Bun's WASM support help.
What You'll Learn (Core Competencies)
- Trace the full pipeline from JavaScript source to machine code for both V8 and JavaScriptCore
- Predict event loop ordering: which queue drains when, and in what order synchronous, microtask, and macrotask callbacks fire
- Explain tier-up JIT compilation, deoptimisation triggers, and hidden classes
- Implement a Worker thread pool for CPU-bound workloads and explain when Workers are appropriate
- Profile a Node.js or Bun process and interpret
--trace-opt,--trace-deopt, and flame graph output - Assess when WebAssembly is the right tool for a given performance or portability requirement
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
Foundational theory, system mechanics, and high-level design.
Engine Architecture
- V8 (Node, Deno): Two-tier compiler (Sparkplug for fast startup, Maglev/TurboFan for optimisation), uses Ignition interpreter
- JavaScriptCore (Bun): Three-tier JIT (LLInt → Baseline → DFG → FTL), starts faster and uses less memory
Both engines compile JavaScript to machine code at runtime. The difference is the strategies for when and how aggressively to optimise.
The Pipeline: Source to Machine Code
Source code (.js / .ts)
↓
Parser → AST (Abstract Syntax Tree)
↓
Bytecode (Ignition in V8, LLInt in JSC)
↓
Execution — interpreter runs bytecode
↓
Profiling — interpreter tracks hot functions, types, branches
↓
Tier-up — JIT compiler generates optimised machine code
↓
Deoptimise (if assumptions break)
The Interpreter
Every JavaScript engine starts with an interpreter that runs bytecode. The interpreter is fast to start, fast to interpret simple code, and doesn't assume anything about types. V8's interpreter is called Ignition; JSC's is LLInt (Low-Level Interpreter).
// The interpreter handles this directly
function add(a, b) {
return a + b;
}
add(1, 2); // run as bytecode
Why Profiling Matters
The interpreter doesn't just run code — it watches it. It tracks:
- Which functions run most often
- What types flow through the pipeline
- Which branches get taken
- Which loops iterate many times
This profile data is what tells the JIT compiler where to focus optimisation effort.
V8 vs JavaScriptCore — Different Strategies
V8 (Node, Deno, Chrome):
AST → Ignition bytecode → (hot code) → Sparkplug baseline JIT → (still hot) → Maglev → TurboFan
JavaScriptCore (Bun, Safari, WebKit):
AST → LLInt bytecode → (hot code) → Baseline JIT → (still hot) → DFG → (still hot) → FTL
Both engines try to spend less time on cold code and more on hot code. JSC has one more tier than V8, so it can squeeze out more performance for code that runs a long time. V8's Maglev tier was added more recently to close the gap.
The Event Loop
Every async runtime has an event loop. The loop runs continuously, processing tasks and microtasks:
while (true) {
runTimers() // setTimeout, setInterval callbacks
runPendingCallbacks() // I/O callbacks
runIdle() // internal housekeeping
runPoll() // I/O polling
runCheck() // setImmediate (Node) callbacks
runClose() // close events
runMicrotasks() // Promise.then, queueMicrotask (always runs after current task)
}
Key insight: microtasks (Promise resolution) run after the current task completes, before the next task. This means:
console.log("1");
Promise.resolve().then(() => console.log("3"));
setTimeout(() => console.log("4"), 0);
console.log("2");
// Output: 1, 2, 3, 4
// Microtasks (3) run before next macrotask (4)
The Call Stack and the Heap
The runtime has two main memory areas:
- Heap: long-lived objects, allocated on demand, freed by GC
- Call stack: current function calls; each call pushes a frame
function c() {
throw new Error("stack trace shows a -> b -> c");
}
function b() {
c();
}
function a() {
b();
}
a();
// Stack:
// [a] [b] [c] ← top of stack
If the stack grows unbounded (e.g., infinite recursion), you get "Maximum call stack size exceeded."
Tasks vs Microtasks
Two queues, different priorities:
- Task queue (macrotask):
setTimeout,setInterval, I/O callbacks, user interaction events - Microtask queue:
Promise.then,queueMicrotask,MutationObservercallbacks
After every task, the runtime drains the entire microtask queue before moving on. This is why Promise.then always runs before setTimeout in the same tick.
Promise.resolve().then(() => {
console.log("microtask 1");
Promise.resolve().then(() => console.log("nested microtask"));
});
setTimeout(() => console.log("macrotask"), 0);
console.log("sync");
// Output: sync, microtask 1, nested microtask, macrotask
Why Microtasks Run So Often
Microtasks drain after every task and after every await. This is important for await:
async function f() {
console.log("1");
await something();
console.log("2"); // runs as a microtask after the await
}
The "2" runs as soon as the microtask is dequeued, which can be much sooner than the next macrotask.
I/O and libuv
Node uses libuv to handle I/O across platforms. libuv has:
- A thread pool for file system, DNS, and other "slow" operations
- An event loop for the main thread
- Cross-platform async I/O primitives (epoll, kqueue, IOCP)
// These are offloaded to the libuv thread pool
import { readFile } from "fs/promises";
const data = await readFile("big.txt");
// These use the OS event loop directly
import { createServer } from "http";
const server = createServer((req, res) => {
// runs in event loop, no thread pool involvement
});
Default thread pool size is 4. Set with UV_THREADPOOL_SIZE=8 node app.js.
2. Deep Dive & Implementation
Technical implementation, fully commented code blocks, and step-by-step logic.
JIT Compilation
Tier-Up: When Code Gets Faster
The interpreter profiles code as it runs. When a function is "hot" (called many times, or in a tight loop), the runtime sends it to a JIT compiler:
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
// First 100 calls: interpreted (bytecode)
// Next: Sparkplug compiles to machine code
// After 10k+ calls: Maglev optimises with type feedback
// After 1M+ calls: TurboFan applies deep optimisation
sum(hugeArray);
Each tier trusts the types it has seen. If a function always receives integers, the JIT generates integer-only machine code — much faster than generic code.
When JIT Optimises and Deoptimises
The JIT makes optimistic assumptions. When those assumptions break, it deoptimises:
function double(x) {
return x * 2;
}
double(5); // JIT compiles with int multiplication
double(10); // still int — runs compiled code
double("hello"); // type assumption broke — DEOPT
double(7); // now runs interpreted or recompiled
Tips to stay in the optimised tier:
- Use consistent types in arrays (
number[]not mixednumber|string[]) - Avoid changing object shapes (add properties in constructor, not later)
- Don't pass arguments of different types to the same function
Hidden Classes and Inline Caches
V8 uses hidden classes (also called shapes or maps) to optimise object property access. When all objects of a class have the same shape, V8 generates fast property access code:
class Point {
constructor(x, y) {
this.x = x; // shape: { x: any }
this.y = y; // shape: { x: any, y: any }
}
}
const p1 = new Point(1, 2);
const p2 = new Point(3, 4); // same shape — fast inline cache hit
const p3 = { y: 5, x: 6 }; // different shape — slower
// Bad: changing shape mid-life
function bad(p) {
p.z = 10; // adds property — different shape for next access
}
Optimization tips:
- Always initialise properties in the constructor
- Always use the same order
- Avoid
delete(changes shape) - Use
Mapfor objects with truly dynamic properties
Inline Caching
The JIT caches the result of property lookups:
function getX(obj) {
return obj.x;
}
getX({ x: 1 }); // cache miss
getX({ x: 2 }); // cache miss
getX({ x: 3 }); // cache miss
getX({ x: 4 }); // after 3 hits, V8 uses the inline cache
getX({ x: 5 }); // cache hit — fast
Once the cache has a hit, the same property access on the same shape runs at native speed.
Worker Threads and Concurrency
Node is single-threaded for JavaScript. The main thread runs your code, the libuv thread pool handles I/O, and the event loop dispatches callbacks. For CPU-bound work, you need worker threads (or child processes).
When You Need Workers
- Image / video processing
- Cryptography (hashing, encryption of large data)
- Compression
- CPU-intensive parsing
- Running a Python/Node subprocess that blocks
For I/O-bound work, the event loop + libuv already keeps your code responsive. Don't reach for workers prematurely.
Basic Worker Thread
main.js:
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./worker.js", import.meta.url), {
workerData: { input: 1_000_000 }
});
worker.on("message", (result) => {
console.log("result:", result);
});
worker.on("error", (err) => {
console.error("worker error:", err);
});
worker.on("exit", (code) => {
console.log("worker exited with code", code);
});
worker.js:
import { parentPort, workerData } from "node:worker_threads";
// CPU-bound work
function fibonacci(n) {
if (n < 2) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(workerData.input);
parentPort.postMessage(result);
Shared Memory with SharedArrayBuffer
Workers can share memory directly (faster than message passing for large data):
// main.js
import { Worker } from "node:worker_threads";
const buffer = new SharedArrayBuffer(4 * 1024 * 1024); // 4MB shared
const view = new Int32Array(buffer);
const worker = new Worker(new URL("./counter.js", import.meta.url), {
workerData: { buffer }
});
worker.on("message", () => {
console.log("counter value:", Atomics.load(view, 0));
});
// counter.js
import { workerData } from "node:worker_threads";
const view = new Int32Array(workerData.buffer);
let count = 0;
for (let i = 0; i < 1_000_000; i++) {
// Atomic increment — safe across threads
Atomics.add(view, 0, 1);
}
parentPort.postMessage("done");
Atomics ensures thread-safe operations on shared memory.
Worker Pool
For many small tasks, use a pool to avoid spawning overhead:
import { Worker } from "node:worker_threads";
import os from "node:os";
class WorkerPool {
constructor(workerPath, size = os.cpus().length - 1) {
this.workers = [];
this.queue = [];
for (let i = 0; i < size; i++) {
this.workers.push(this.createWorker(workerPath));
}
}
createWorker(path) {
const worker = new Worker(path);
worker.busy = false;
worker.on("message", () => {
worker.busy = false;
this.processQueue();
});
return worker;
}
run(data) {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this.processQueue();
});
}
processQueue() {
if (this.queue.length === 0) return;
const worker = this.workers.find((w) => !w.busy);
if (!worker) return;
const { data, resolve, reject } = this.queue.shift();
worker.busy = true;
worker.once("message", resolve);
worker.once("error", reject);
worker.postMessage(data);
}
}
Bun's Approach
Bun optimises the single-threaded case aggressively. For parallel work, Bun offers:
- Bun Workers (similar to Node)
- Bun.spawn (subprocess, very fast)
- Native worker pool built into the runtime
Bun's startup time is much faster than Node, so for short-lived parallel tasks, the difference can be significant.
WebAssembly
WebAssembly (WASM) is a portable, low-level binary format. Languages like C, C++, Rust, and Go compile to WASM modules that run in JavaScript runtimes.
Why WASM?
- Performance: near-native speed (C/Rust compiled to WASM often runs at 50–80% of native)
- Polyglot: reuse existing C/C++/Rust code without rewriting in JS
- Predictable: no JIT warmup, consistent execution time
- Safe: runs in a sandboxed environment with no system access by default
Compiling Rust to WASM
# Add target
rustup target add wasm32-unknown-unknown
# Compile
cargo build --target wasm32-unknown-unknown --release
// Load and use in JavaScript
import { readFile } from "fs/promises";
import path from "path";
const wasmBuffer = await readFile(path.join(import.meta.dirname, "target/wasm32-unknown-unknown/release/mylib.wasm"));
const wasmModule = await WebAssembly.instantiate(wasmBuffer, {
env: {
// import functions provided to WASM
log: (ptr, len) => {
const bytes = new Uint8Array(wasmModule.exports.memory.buffer, ptr, len);
console.log(new TextDecoder().decode(bytes));
}
}
});
const result = wasmModule.exports.compute(42);
console.log("WASM result:", result);
Use Cases
- Image/video processing: libvips, ffmpeg.wasm
- Cryptography: libsodium, ring
- Compression: zstd, brotli
- Games and physics engines: Box2D, Bullet
- CAD and 3D: Figma's rendering engine
WASM Limitations
- No direct DOM access (must go through JS)
- Limited standard library
- Different memory model (linear memory, manual allocation)
- Tooling is younger than JavaScript
For pure business logic, JavaScript is usually faster to develop and similar in performance. Reach for WASM when you have specific performance, security, or reuse needs.
Profiling Your Code
Node Built-in Profiler
# CPU profile (10 second sample)
node --prof app.js
# Generates isolate-*.log
node --prof-process isolate-*.log > profile.txt
# Heap profile (memory)
node --heap-prof app.js
Or use the inspector with Chrome DevTools:
node --inspect app.js
# Open chrome://inspect in Chrome
# Click "inspect" to see profiles, flame charts, snapshots
V8 Hidden Class Inspector
node --print-opt-code app.js # dumps optimised code
node --trace-opt app.js # shows optimisation decisions
node --trace-deopt app.js # shows deoptimisations
The most common deopt cause: polymorphic call sites or unstable object shapes. Look for "deopted" in the trace.
Production Profiling
For production, use 0x (flame graph generator):
npx 0x app.js
# Generates a flame graph in ~/Documents/0x/<pid>.flamegraph.html
Each bar is a function. Wide bars = expensive functions. Tall stacks = deep call chains.
Memory Leak Detection
# Heap snapshot at startup
node --inspect app.js
# Take snapshots in DevTools, compare over time
# Programmatic snapshots
import v8 from "node:v8";
v8.writeHeapSnapshot("snap-" + Date.now() + ".heapsnapshot");
In DevTools, compare two snapshots. Objects that grow between snapshots are leaking.
Bun Profiling
Bun ships with built-in profiling:
bun --hot --inspect app.ts
# Same Chrome DevTools workflow as Node
Bun also has --cpu-prof and --heap-prof flags.
Guided Checkpoint
Run the following to verify your understanding of runtime internals — all commands should complete without errors and produce predictable output.
# 1. Confirm which engine your runtime uses
node -e "console.log(process.versions.v8)" # V8 (Node)
# bun --version is not a V8 version check; Bun uses JavaScriptCore
# 2. Trigger and observe a deoptimisation
node -e "
function double(x) { return x * 2; }
for (let i = 0; i < 100000; i++) double(i); // warm up — JIT compiles
double('string'); // type assumption breaks
" 2>&1 | grep -i deopt || echo "No deopt trace (add --trace-deopt flag)"
# 3. Verify UV_THREADPOOL_SIZE effect
UV_THREADPOOL_SIZE=16 node -e "
console.log('Thread pool size:', process.env.UV_THREADPOOL_SIZE);
"
# 4. Run the event loop phase-order test
node -e "
console.log('sync');
setTimeout(() => { console.log('timeout'); Promise.resolve().then(() => console.log('timeout-microtask')); }, 0);
setImmediate(() => { console.log('immediate'); queueMicrotask(() => console.log('immediate-queueMicrotask')); });
Promise.resolve().then(() => console.log('promise-then'));
queueMicrotask(() => console.log('queueMicrotask'));
console.log('sync-end');
"
3. Anti-Patterns & Common Pitfalls
Documented failure modes and how to detect/prevent them.
1. Microtask starvation
Microtasks drain after every task and after every await. If a promise chain keeps scheduling new microtasks (via .then() that returns a resolved promise), the microtask queue never fully drains, and macrotasks (I/O callbacks, timers) never get a chance to run. This can make the process appear unresponsive even when the CPU is idle.
Detection: Under moderate load, setTimeout callbacks are delayed by hundreds of milliseconds despite a lightly-loaded system. Adding a setTimeout(..., 0) in a test harness shows it never fires.
Prevention: Use setImmediate to yield back to the event loop between batches of microtasks. Limit the depth of promise chains; prefer async/await with explicit await at boundaries to let the event loop breathe.
2. Creating objects with unstable shapes in hot paths
Adding properties to objects after construction, deleting properties, or initialising properties in different orders across different call sites causes V8 to track multiple hidden classes. This prevents inline cache hits and forces the JIT to use slower generic property lookup code.
Detection: node --trace-opt app.js | grep -v "not optimized" shows functions repeatedly deoptimising. Profile output shows KeyedStoreIC or LoadIC taking disproportionate time.
Prevention: Always initialise all properties in the constructor. If an object needs dynamic keys, use a Map or WeakMap instead. Never delete obj.prop in production hot paths.
3. Polymorphic call sites across many types
A function called with many different argument types (e.g., a format function called with string, number, Date, and custom objects) creates a polymorphic or megamorphic call site. The JIT can no longer inline the call and must use a generic dispatch stub.
Detection: node --trace-opt app.js shows "NOT OPTIMISED" for functions with polymorphic arguments. Flame graphs show a wide stub dispatcher taking significant time.
Prevention: Use function overloads or split the function into typed variants.
4. Deoptimising by mixing types in the same array
When an array contains both integers and floating-point numbers (or strings interleaved with numbers), the JIT can no longer assume a single numeric representation and falls back to a generic array element handler.
Detection: Hot loops over arrays suddenly become slower; node --trace-deopt shows deoptimisations on array-access instructions.
Prevention: Normalise arrays to a single type before processing. If a data source is heterogeneous, coerce at ingestion time rather than inside the loop.
5. Ignoring SharedArrayBuffer synchronisation
Using SharedArrayBuffer without Atomics (or with incorrectly ordered reads/writes) leads to data races that are non-deterministic and hard to reproduce.
Detection: Intermittent corruption in shared worker state; results vary between runs with identical inputs.
Prevention: Always pair SharedArrayBuffer with Atomics.load, Atomics.store, or Atomics.add. Do not use regular Int32Array indexing on shared buffers for concurrent reads.
6. Running node --inspect in production without a firewall
The Chrome DevTools inspector opens a TCP port (default 9229). If exposed to public networks, it allows remote code execution through the DevTools protocol.
Detection: Security scanners flag open port 9229; netstat -tlnp shows the inspector bound to 0.0.0.0.
Prevention: Always bind node --inspect=127.0.0.1:9229 (localhost only), or disable the inspector in production entirely. Use a tunnel or VPN rather than exposing the port.
4. Independent Challenge
A problem to solve without step-by-step guidance; promotes synthesis.
Challenge: Build a runtime profiler that detects hidden-class instability
Problem: Write a Node script (hidden-class-tracker.js) that:
- Defines two object constructors (
StablePointandUnstablePoint) —StablePointalways initialisesxtheny;UnstablePointsometimes initialisesyfirst, sometimes addszafter construction - Creates 10,000 instances of each and passes them through a hot loop (
for) that accesses.xand.y - Uses
node --trace-optoutput (redirected to a file) to count how many times each constructor's access function is reported as "optimised" vs "NOT OPTIMISED" - Prints a comparative summary: which shape is faster and by approximately how many iterations before deoptimisation occurs
Constraints:
- Do not measure wall-clock time directly — rely on the
--trace-optoutput as evidence - Include a brief written explanation (in the script's comments) of what hidden-class difference causes the observed behaviour
- The script must be runnable with
node hidden-class-tracker.jsafter compiling (or usingnode --trace-opt)
No solution is provided. The goal is to observe the effect of object shape stability on JIT optimisation through direct instrumentation of V8's trace output.
5. Consolidation & Key Invariants
Bullet-list summary of the must-remember rules.
- Microtasks drain after every event-loop phase — not just at loop end;
awaitandPromise.thenfire before any queued macrotask, even if already scheduled. - Hidden classes require stable shapes — always initialise properties in constructor, in consistent order; never
delete; useMapfor dynamic keys. - Tier-up JIT depends on consistent types — passing mixed types to a hot function causes deoptimisation; split or overload rather than polymorphism.
- Worker threads are for CPU-bound work, not I/O — the event loop + libuv already handles concurrent I/O; only use Workers when the task is compute-heavy.
SharedArrayBufferrequiresAtomics— concurrent access without atomic operations produces non-reproducible data races.- Profile with
--trace-opt/--trace-deoptbefore guessing — the evidence of why a function is slow is in the engine's own trace output, not intuition. - WASM is predictable but not automatic — near-native speed requires correct memory management; reach for WASM only after confirming JS is actually the bottleneck.
6. Next Steps
Sequenced links to dependent lessons, deeper dives, or production guides.
- nodejs-runtime — Deep dive into Node's event loop and module systems that this internals article builds on
- bun-runtime — Understand Bun's JavaScriptCore three-tier JIT and its differences from V8
- ../languages/typescript-advanced — Strong typing helps the JIT make better assumptions; use it to avoid polymorphic call sites
- ../backend/performance-monitoring — Apply profiling techniques (flame graphs, heap snapshots) to production APIs built with Bun or Node
- V8 blog — Deep technical posts on JIT, hidden classes, and optimisation heuristics
- Chrome DevTools documentation — How to interpret profiles, compare heap snapshots, and use the Performance panel effectively
Change Log
All meaningful modifications should be tracked here with a date and session context.
2026-08-29
- Migrated to 6-section canonical template
- Expanded from full lecture depth to 6-section scaffold
- Added Architectural Overview: V8 vs JSC pipeline, interpreter roles, hidden classes
- Added Deep Dive: JIT tier-up, deoptimisation, inline caches, Worker thread pool, SharedArrayBuffer, WASM, profiling commands
- Added Guided Checkpoint (4 verification commands including
node --inspectand event loop order test) - Added Anti-Patterns (6 failure modes: microtask starvation, unstable shapes, polymorphic calls, array type mixing, SharedArrayBuffer races, inspector exposure)
- Added Independent Challenge: hidden-class tracker using
--trace-opt - Added Consolidation (7 bullet rules)
- Updated Next Steps with relative links to nodejs-runtime, bun-runtime, TypeScript advanced, performance monitoring, V8 blog, DevTools docs