Skip to content

    Frontend System Design

    The JavaScript Engine: Parsing, JIT and Garbage Collection

    How a JavaScript engine parses, compiles and optimises code, how garbage collection frees memory, and what that means for startup, jank and leaks.

    By Published 14 min read

    • frontend-system-design
    • javascript-engine
    • jit
    • garbage-collection
    Share

    Standard lessonadvancedFrontend System Design

    Before this: How a Browser Actually Works

    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

    1. 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.
    2. 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.
    3. 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

    Source is parsed to bytecode; hot code moves up through compiler tiers, and a failed check drops it backJavaScript source to Parser; Parser to Ignition (bytecode); Ignition to Sparkplug (warm); Sparkplug to Maglev (hotter); Maglev to TurboFan (hottest)V8 (ONE ENGINE, DATED POSTS)JavaScript source: downloaded textJavaScriptsourcedownloaded textParser: lazy, pre-parseParserlazy, pre-parseIgnition: bytecode, feedbackIgnitionbytecode,feedbackSparkplug: baseline machine codeSparkplugbaseline machinecodeMaglev: fast optimisingMaglevfast optimisingTurboFan: peak performanceTurboFanpeak performancebytecodewarmhotterhottest

    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

    stateDiagram-v2 accTitle: Lifecycle of a function in a tiered engine accDescr: A function starts interpreted, collecting feedback. If it is hot it is compiled by a faster tier. If an assumption in the optimised code fails, it deoptimises back to the interpreter and may be optimised again with wider feedback. [*] --> Interpreted Interpreted --> Baseline: warm Baseline --> Optimised: hot, feedback is stable Interpreted --> Optimised: hot (tiers may be skipped) Optimised --> Interpreted: a check fails (deoptimise) Optimised --> Optimised: same assumptions hold

    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

    flowchart TD accTitle: Generational garbage collection accDescr: New objects are allocated in the young generation. A minor collection frees dead ones and copies survivors. Objects that survive twice are promoted to the old generation, which is collected by a major mark, sweep and compact collection that runs in slices and in the background. N["New object
    (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

    flowchart TD accTitle: Matching a symptom to the part of the engine to examine accDescr: Slow first load points to parse and compile cost and bytes shipped. A hot function that is slow after warm-up points to type and shape instability, found with a profile. Memory that keeps growing points to a leak found with heap snapshots. Frequent short pauses point to allocation churn. A{"What do users see?"} -->|"slow first load"| B["Parse and compile cost:
    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

    NOTE

    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

    Each opened view registers a window handler whose closure keeps the view's DOM aliveRouter to Chart view (create, remove); Chart view to resize handler (registers); window to resize handler (keeps); resize handler to Chart view (references)PAGERouter: opens and closes viewsRouteropens and closesviewsChart view: DOM nodesChart viewDOM nodesresize handler: closure over viewresize handlerclosure over viewwindow: global, a rootwindowglobal, a rootcreate, removeregisterskeepsreferences

    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

    1. Reproduce. Open and close a view 50 times (illustrative), take heap snapshots before and after with a forced collection, and filter for detached nodes.
    2. Fix and compare. Add the cleanup and repeat; growth should stop.
    3. Add a regression check in an end-to-end run that opens and closes views and fails on growing node counts.
    4. 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 FinalizationRegistry or WeakRef for 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.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments