What you'll learn: By the end of this lesson you will be able to explain what the ECMAScript specification fixes and what it leaves to each engine, describe how V8 turns source text into running code (lazy parsing, bytecode, tiers of compilers, speculation and deoptimisation), explain generational garbage collection and why the specification promises almost nothing about it, and use that to reason about startup cost, hot-path speed, pauses and memory leaks in a front-end system, without cargo-culting micro-optimisations.
Think of an interpreter at a conference who starts translating at once, sentence by sentence. After a while she notices the speaker keeps repeating one phrase, so she prepares a printed card for it and reads from the card. If the speaker suddenly says something the card did not expect, she drops the card and goes back to live translation. In the background a cleaning crew walks the hall from the front door, and clears away any chair that no path from the door reaches.
A JavaScript engine does both jobs: it runs code before fully understanding it, guesses from what it has seen, checks the guesses, and reclaims unreachable memory. Earlier lessons covered the browser's processes and threads and the event loop; this one looks inside the box that runs your script.
Scope. This lesson describes V8, the engine in Chrome, Edge and Node.js, from V8's own posts and documentation, dated where it matters. Other engines (SpiderMonkey, JavaScriptCore) differ and are not described. Internals change, as the dated posts show: treat the structure as stable and names and thresholds as dated.
The core idea
- The specification defines behaviour, not technique. ECMAScript says what a program must do. How an engine gets there (interpret, compile, optimise, collect memory) is the engine's business.
- Engines are fast because they guess and verify. They watch what actually happens, compile code specialised for it, put checks in front of the guesses, and fall back when a check fails.
- Memory is reclaimed by reachability, on the engine's schedule. You get no guarantee about when or even whether an unreachable object is freed. The cost shows up as pauses and as memory that grows when something still points at data you thought was gone.
What the specification fixes, and what it does not
The ECMAScript specification says its algorithms "are not intended to imply the use of any specific implementation technique" and that more efficient algorithms may exist. The specification therefore does not prescribe whether an engine parses lazily, interprets, compiles to machine code or does something else: it defines the behaviour programs must show.
Memory is the sharpest case. The section on WeakRef and FinalizationRegistry says: "This specification does not make any guarantees that any object or symbol will be garbage collected. Objects or symbols which are not live may be released after long periods of time, or never at all." It adds that liveness as defined there is undecidable, that engines use conservative approximations such as reachability, and that "there is expected to be significant implementation leeway". Finalization callbacks may be omitted "for any reason". MDN agrees: the language cannot trigger collection, and finalization timing is not guaranteed.
From source text to running code
Figure 1. V8's tiers, as its own posts describe them
How to read this: code starts in the interpreter and moves to faster, costlier-to-compile tiers as it proves hot. Failed checks send optimised code back to a lower tier (Figure 2). The posts this lesson draws on do not give the thresholds for moving up, so neither does it.
Parsing, lazily
V8's post "Blazingly fast parsing, part 2: lazy parsing" (April 2019) explains the basic bargain. Compiling everything up front costs CPU time and memory, so the parser pre-parses inner functions: it checks they are valid and records what outer functions need, without producing code. A pre-parsed function is fully parsed and compiled on demand when first called. The same post says V8 guesses a parenthesised function expression, (function () { ... }), will be called and compiles it eagerly, and warns against adding parentheses indiscriminately.
For a front-end architect the consequence is plain: bytes of JavaScript are not free even when never called. In V8's scheme a function the page never calls is still pre-parsed, which means checked for validity. Shipping less (see code splitting and tree shaking) lowers that work.
Chrome also caches compiled code. "Code caching for JavaScript developers" (April 2019, updated June 2020) describes cold, warm and hot loads (the third skips compilation) and advises splitting stable library code from frequently changing code so its cache survives deploys. A later post, "Explicit Compile Hints" (April 2025), describes a file-level comment, //# allFunctionsCalledOnLoad, that asks V8 to compile a whole file eagerly (Chrome 136 at that date), and says to use it sparingly because compiling too much costs time and memory.
The tiers
| Tier | What it is (source) | Cost to produce | Role |
|---|---|---|---|
| Ignition | Interpreter. A register machine with an accumulator, running bytecode (2016 post). | Cheapest | Starts everything; collects feedback |
| Sparkplug | Non-optimising compiler: one linear pass over bytecode to machine code, no intermediate representation (2021 post). | Very low | Quick speed-up without optimisation |
| Maglev | Mid-tier optimising compiler using bytecode plus runtime feedback; "roughly 10x slower than Sparkplug, and 10x faster than TurboFan" to compile (2023 post, Chrome M117 desktop). | Moderate | Good code quickly |
| TurboFan | Optimising compiler "focused on peak performance". | Highest | Hot code |
Names are not the lesson. The May 2017 post "Launching Ignition and TurboFan" says that from V8 v5.9 (Chrome 59) these two ran all JavaScript, replacing Full-codegen (whose code it says typically took almost a third of Chrome's JavaScript heap) and Crankshaft. Sparkplug and Maglev came later, and a March 2025 post says TurboFan's JavaScript backend had moved to a new intermediate representation, Turboshaft, with its frontend being replaced by Maglev. The architecture ("interpret, then compile what is hot") persisted while components were swapped.
Guess, check, deoptimise
Benedikt Meurer's "An Introduction to Speculative Optimization in V8" (2017) gives the mechanism. Ignition records feedback, such as the kinds of values an operation saw, in a per-function feedback vector. The optimising compiler assumes the future will look like the past, leaves out handling for unseen cases, and guards the result with checks. If a check fails, "we go back to interpreting the bytecode instead": the deoptimizer restores the interpreter's state. Maglev's post agrees that its code must be able to deoptimise and that recompiling later is cheap.
Illustration 1: a minimal example
function add(a, b) {
return a + b;
}
function run(label, fn) {
let s = 0;
for (let i = 0; i < 300000; i++) s += fn(i);
console.log(label, s);
}
run("integers", (i) => add(i, 1));
run("fractions", (i) => add(i + 0.5, 1.25));
run("strings", (i) => add("n", i).length);
Running node --trace-opt --trace-deopt on this (Node v26.12.1, V8 14.6.202.34-node.40) and keeping only the lines about add, one run printed:
compile add -> MAGLEV
deopt add (deopt-eager, reason: not a Smi) out of MAGLEV code
compile add -> MAGLEV
deopt add (deopt-eager, reason: not a Number) out of MAGLEV code
compile add -> MAGLEV
(These lines are condensed from V8's longer trace lines. The exact sequence varies between runs because compilation is concurrent: another run also compiled add with TurboFan before the first deoptimisation.) The reasons fit Meurer's account: "not a Smi" is what a function compiled for small integers reports when it is given a fraction, and "not a Number" fits the strings. The trace does not label which call caused which event, so that mapping is the likely reading, not a proof. Each time V8 compiled again with wider feedback; the program never failed, it only got slower for a moment.
Shapes and why consistent objects help
Mathias Bynens and Benedikt Meurer's "JavaScript engine fundamentals: Shapes and Inline Caches" (2018) says objects with the same property keys share a shape, so the engine need not store the keys per object, and an inline cache memorises the shape and the offset at which a property was found. Order matters: { x: 4, y: 5 } and { y: 5, x: 4 } get different shapes. V8 calls them hidden classes or maps; its "Fast properties in V8" post (2017) adds that the same properties added in the same order reach the same hidden class, and that slow, dictionary-style properties are typically slower than fast ones. The "Elements kinds" post (2017) says an array's kind (for example packed small integers) only becomes more general, and an array with a hole stays "holey" forever, but that the gain from avoiding holes is often small.
Figure 2. One function's possible lifecycle
How to read this: this is a simplification. V8's posts do not give the rules for when each transition happens. What matters for design is the cycle: guesses that keep being wrong make code move back and forth between tiers.
For application code the advice is modest: create objects of one kind the same way (same properties, same order), keep hot functions to consistent argument types, and do not rewrite code on folklore without a profile. How much you ship and when it runs usually matters more.
Garbage collection
MDN defines the idea: an object is reclaimable when it is unreachable from the roots (in a page, the global object), found by mark-and-sweep. It adds that no modern engine uses reference counting, which would leak cycles. Two objects that point at each other are collected once nothing reachable points at either.
V8's "Trash talk: the Orinoco garbage collector" describes how V8 does this fast. Its starting observation is the generational hypothesis, "most objects die young". New objects go to a small young generation (the nursery). A minor collection, the scavenger, evacuates live objects between two semi-spaces, so half the young generation is always empty, and objects that survive a second collection are promoted to the old generation. The old generation uses a major collection with marking, sweeping and optional compaction. To keep pauses short V8 uses work that is parallel (main thread and helpers at the same time), incremental (small slices of main-thread work) and concurrent (helper threads in the background), and write barriers that track pointers across generations. It can also use idle time between frames (about 16.6 ms each at 60 fps).
Figure 3. Generational collection in one picture
(young generation)"] --> M{"Minor collection:
still reachable?"} M -->|"no"| F1["Freed"] M -->|"yes, first time"| S["Copied within young generation"] S --> M M -->|"yes, survived twice"| O["Promoted to old generation"] O --> J{"Major collection:
still reachable?"} J -->|"no"| F2["Freed (sweep, maybe compact)"] J -->|"yes"| O
How to read this: short-lived objects, the majority, die cheaply in the young generation. Long-lived ones are promoted, so what you keep alive decides how much the major collector has to do.
Memory problems you can cause
Chrome's DevTools guide names three: a leak (usage keeps growing), bloat (more than needed) and frequent collections (script pauses during them). A common leak is a detached DOM node, removed from the page but still referenced from JavaScript. The tools are the Memory panel's heap snapshot, allocation timeline and detached-elements profile.
Because the specification guarantees nothing, never use garbage collection as a design mechanism. MDN says WeakRef semantics are almost completely unguaranteed and FinalizationRegistry callbacks may never run: use them only for non-critical cleanup, and try...finally or explicit disposal for anything that must happen.
Figure 4. Symptom to first suspect
ship less, split, cache"] A -->|"hot path slow after warm-up"| C["Profile: unstable types or shapes,
deoptimisation loops"] A -->|"memory grows over a session"| D["Leak: heap snapshots,
detached nodes, listeners, caches"] A -->|"frequent small pauses"| E["Allocation churn in hot code:
reuse objects, move work off thread"]
How to read this: start from the user-visible symptom, not from an engine feature. Each branch ends in a measurement, not a rewrite.
Illustration 2: a realistic example
A dashboard shows a grid of 10,000 rows (illustrative), formats each with the same few helpers, and updates every second. At first load the cost is mostly parsing and compiling the bundle. After warm-up the row formatter is hot. Each update allocates thousands of small objects that die within a second, which the young generation handles cheaply. Growth over a day would come from something kept alive, not from the churn.
Illustration 3: a failure and an edge case
The leak: an app registers a resize handler on window each time a chart view opens, and its closure references the view's DOM nodes. Closing the view detaches the nodes from the page but the handler still reaches them, and each visit adds more. The edge case is a micro-benchmark: a function timed on its first calls measures the interpreter and compilation, and one fed a single type measures a path your real data may not take.
Trade-offs
| Engine feature | What you gain | What it costs | What you control |
|---|---|---|---|
| Lazy parsing and compilation | Faster start | First call pays compile cost | Bytes shipped, what runs at startup |
| Tiered JIT | Fast start and fast hot code | CPU and memory to compile; slow phases while warming | Stable types and shapes in hot code |
| Speculation and deoptimisation | Specialised, fast code | Falling back when guesses fail | Consistent inputs |
| Generational GC | Cheap short-lived allocation | Pauses; memory held until collected | What you keep reachable |
In practice: a dashboard that slows down over a long session
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
An internal operations dashboard stays open for a whole shift. Users switch between chart views many times. After a few hours on shared laptops it becomes sluggish and sometimes the tab is reloaded by the browser. Three engineers own the app.
Starting architecture
Figure 5. How a view leaves something behind
How to read this: window is reachable, it holds the handler, and the handler holds the view. Removing the view from the page does not make it unreachable.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Remove the handler when the view closes | Fixes this leak | A cleanup path per view | Other leaks remain |
| Hold views through weak references and rely on finalizers | Looks automatic | Nothing is guaranteed to run | Specification promises no collection or callback |
| A view lifecycle that owns and releases every subscription | Fixes the class | A small framework convention | Discipline in review |
The decision
| Field | Decision record |
|---|---|
| Context | Memory grows with every view opened; detached nodes are retained. |
| Decision | Every view returns a cleanup function that removes its listeners, timers and subscriptions, and the router calls it on close. |
| Consequences, good | The leak class is closed; behaviour does not depend on collector timing. |
| Consequences, bad | Authors must write cleanup, and a missed one still leaks. |
| Revisit when | Heap snapshots after a scripted session still show growth. |
Rollout and verification
- Reproduce. Open and close a view 50 times (illustrative), take heap snapshots before and after with a forced collection, and filter for detached nodes.
- Fix and compare. Add the cleanup and repeat; growth should stop.
- Add a regression check in an end-to-end run that opens and closes views and fails on growing node counts.
- Check low-end devices, where memory pressure shows first.
The 2 a.m. failure
After the fix, support reports that the app's live connection count grows during long shifts and the server rejects new connections. A well-meaning change had made each view register its socket with a FinalizationRegistry so it would be "closed automatically when the view is collected". The views are collected late or not at all, because the specification promises neither collection nor callbacks. First three checks: (1) compare open sockets with open views; (2) search for resource cleanup tied to garbage collection; (3) look in the server logs for connections without a close handshake. Mitigation: close sockets in the view's cleanup function. Follow-up: a lint rule against FinalizationRegistry for resource release.
Architect's notes
- Think in user symptoms. Slow start, slow hot path, growing memory and pauses have different causes and tools.
- Bytes first. Less JavaScript is less parsing and compiling for every user.
- Do not chase engine folklore. Names and thresholds change; profile first.
- Own lifetimes explicitly. Cleanup is design; garbage collection is not.
- Test slow devices and long sessions. Both reveal costs a fast laptop and a short test hide.
Common mistakes
- Assuming unreferenced means freed now. The engine decides when, if ever.
- Relying on
FinalizationRegistryorWeakReffor correctness. - Micro-optimising for hidden classes without a profile.
- Benchmarking cold code, or one input type.
- Forgetting that shipped functions cost work even if never called.
- Leaving listeners, timers and closures that reference removed DOM.
Key takeaways
- The specification defines behaviour; engines choose how, including when to free memory, and it promises no collection and no finalizer call.
- V8 interprets first, collects feedback, promotes hot code through compiler tiers, and deoptimises when an assumption fails.
- Lazy parsing and code caching mean shipped bytes cost something even if never called.
- Consistent shapes and argument types help optimised code; measure the gain.
- Garbage collection is by reachability and generational; pauses and leaks still happen.
- Fix leaks by releasing references explicitly and checking heap snapshots.
Practice exercises
Exercise 1 — Starter (30 min): Run the add example with node --trace-deopt, reorder the three run calls, and note what changes in the reasons.
Exercise 2 — Intermediate (2 hours): Build a page that opens and closes a view 50 times with and without a resize cleanup. Compare heap snapshots and find the detached nodes.
Exercise 3 — Advanced (half day): Record load and a minute of use of a page you work on, classify the cost (parse and compile, hot script, garbage collection), and say which of Figure 4's symptoms applies.
Related reading
In this course:
- How a Browser Actually Works — processes, threads and where the engine sits.
- The Event Loop and the Main Thread — tasks, long tasks and yielding.
- Web Workers, Worklets and Off-Main-Thread Architecture — moving heavy work off the main thread.
- Code Splitting, Lazy Loading and Tree Shaking — shipping less JavaScript.
- Loading Performance and Core Web Vitals — measuring the user-visible result.
Official documentation
- ECMAScript Language Specification: Processing Model of WeakRef and FinalizationRegistry Targets and Algorithm Conventions
- V8: Launching Ignition and TurboFan, Sparkplug and Maglev
- V8: Blazingly fast parsing, part 2: lazy parsing and Code caching for JavaScript developers
- V8: Trash talk: the Orinoco garbage collector and Fast properties in V8
- MDN: Memory management and Chrome DevTools: Fix memory problems