AW Dev Rethought

✂️ Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away - Antoine de Saint-Exupéry

Runtime Internals: What the V8 Engine Does Before Running Your Code


When you load a page or start a Node process, there’s a gap between your JavaScript arriving and your JavaScript running. Most engineers treat that gap as a black box labelled “the engine.” It isn’t one thing, and most of what happens in it is V8 making decisions about your code before it has seen it execute even once.

Understanding those decisions explains several things that otherwise look like magic: why bundle size matters even on a fast connection, why a function gets dramatically faster after a few hundred calls, and why adding one innocuous line sometimes makes a hot path slower.


Nothing Runs Until All of It Is Read

The source arrives as bytes. V8 decodes it, scans it into tokens, and parses it — and nothing executes until the top level of your script has been parsed.

That’s a cost proportional to the amount of JavaScript, not the amount you use. A 2MB bundle where you call three functions still has to be read in full before anything happens, because the engine can’t know what’s a function declaration and what’s a syntax error until it has looked.

Parse cost scales with how much JavaScript you ship, not how much of it you run.

This is the part of bundle size that survives a fast network. You can serve 2MB over a good connection in almost no time and still pay for parsing it on a mid-range phone, where the CPU is the scarce resource rather than the bandwidth.


V8 Reads Some Functions Twice

V8 doesn’t fully parse everything up front. For function bodies it doesn’t expect to run immediately, it does a lighter pre-parse — enough to confirm the syntax is valid and record what variables and scopes exist, without building the full syntax tree.

If that function is later called, V8 goes back and parses it properly. Which means parts of your code get read twice.

That sounds wasteful, and usually isn’t — most applications ship far more code than any single session runs, and skipping full parses for the unused majority is a net win. But it means the engine is constantly guessing which category your function falls into.

It takes hints. A function expression wrapped in parentheses and called immediately is a strong signal that it should be parsed eagerly, which is one reason the (function(){ … })() pattern has historically been faster to start than its unwrapped equivalents. Bundlers emit this shape partly for that reason.

The practical consequence: code that runs at module load is paid for twice if V8 guessed wrong, and top-level work is the most expensive work in your program.


Bytecode First, and Four Tiers Above It

V8 does not compile your JavaScript straight to machine code. It compiles to bytecode and runs it in an interpreter called Ignition.

That seems backwards until you consider memory. Bytecode is far more compact than machine code, and compiling everything to native up front would cost both time and a great deal of RAM for functions that run once. Ignition starts executing almost immediately and, crucially, watches what happens while it does.

Above it sit three compilers, each a different trade between compile speed and output quality. Sparkplug is a baseline JIT that translates bytecode to machine code almost one-to-one — no optimisation, but it removes the interpreter’s per-instruction dispatch overhead, and it keeps Ignition’s stack frame layout so switching between them is cheap. Maglevcompiles quickly, produces faster code, and speculates lightly. TurboFan is slow to compile, produces the fastest code, and speculates aggressively.

Functions climb this ladder on an interrupt budget that scales with the function’s bytecode length and ticks down as it runs. When it hits zero, V8 decides whether to promote the function and to which tier. Published example thresholds from V8’s own flags sit around 8 invocations to allocate feedback, 400 for Maglev, 3000 for TurboFan — but these are tuning heuristics that vary by platform and release, not contracts.

Because the budget scales with size, a large function called rarely doesn’t waste compile time, while a tiny function in a hot loop climbs fast. And a long-running loop that never returns gets on-stack replacement — V8 swaps the running frame for optimised code mid-loop rather than waiting for the function to be called again.


It Is Watching What Types You Pass

While Ignition runs, inline caches record what each operation actually sees: the shapes of the objects, the targets of the calls, the types of the operands. That goes into a feedback vector attached to the function.

The optimising compilers read this to decide what to specialise on. If a property access has only ever seen one object shape, TurboFan can compile it into a direct memory offset instead of a lookup. If it has seen four shapes, it compiles something considerably more defensive.

V8 isn’t compiling your code. It’s betting on it.

This is why monomorphic code — functions that consistently receive the same shapes — runs faster than code doing the same work on varied inputs. Not because polymorphism is bad style, but because the speculation the compiler can safely make is narrower.


And It Takes the Optimisation Back

Those bets can lose. When optimised code encounters something its speculation ruled out — a string where it has only ever seen numbers, an object with an unexpected shape — V8 deoptimises. It reconstructs the interpreter frame from the optimised one and resumes execution at a lower tier.

Deopt is correct, necessary, and expensive. A function that deoptimises repeatedly can end up slower than one that was never optimised, because it pays the compile cost again and again and runs in the interpreter in between.

This is the mechanism behind the mysterious performance regression. You added a line that passes null on one code path, that path runs once an hour, and a hot function has been bailing out of TurboFan ever since.


What This Changes About Code You Write

Four things worth carrying.

Ship less JavaScript, and care about it independently of transfer size — parse and compile happen on the user’s CPU, and that’s where low-end devices lose.

Keep top-level work minimal. Module-load code is parsed eagerly, runs before anything is interactive, and is the hardest work to defer later.

Keep the shapes of objects passed to hot functions consistent. Initialise all properties in the same order, avoid adding fields conditionally, and don’t mix types through one parameter if you can avoid it.

And treat microbenchmarks with suspicion. A function called ten times is being measured in the interpreter. The same function called a hundred thousand times is being measured in TurboFan. Those are different programs, and which one your benchmark measured is usually not the one your users run.


50 Machine Learning Algorithms Cheatsheet Most downloaded free PDF 50 Machine Learning Algorithms Cheatsheet Blueprints of Intelligence — 50 ML Algorithms decoded Download free Downloaded 434 times

If this article helped you, you can support my work on AW Dev Rethought.


Rethought Relay:
Link copied!

Enjoyed this post?

Stay in the loop

New posts + weekly digest, straight to your inbox.

or

Create a free account

  • Save posts to your vault
  • Like posts & build history
  • New-post alerts

Comments

Add Your Comment

Comment Added!