The Engines That Run Your Code

Part 3 of 8 — Series index

Here's something that doesn't happen on the server: your code runs on a different runtime depending on which application the user opened your page in. Not a different version of the same runtime — a different implementation, written by a different company, with different performance characteristics, different optimization strategies, and different pathological cases.

On a server you pick your JVM, your Go toolchain, your .NET runtime once, and you own it. On the edge, your code is portable in the way that concrete is portable — it's the same shape everywhere, but the ground it runs on is different every time.

The three engines you ship to

Three JavaScript engines power essentially the entire web:

V8 — built by Google. Powers Chrome, Edge, and the various Chromium derivatives. Also the engine behind Node.js, Deno, and a surprising number of embedded scripting environments. When people say "V8 is fast," they usually mean it's aggressive: it compiles hot code to tightly specialized machine code very quickly, and falls back gracefully when its assumptions are wrong.

SpiderMonkey — built by Mozilla. Powers Firefox. It's the original JavaScript engine, shipped with Netscape in 1996, and has been in continuous evolution since. Modern SpiderMonkey is a tiered system with its own optimizer (Warp) that emphasizes compilation speed and robustness on the messy real-world web.

JavaScriptCore — built by Apple. Powers Safari, and, because of iOS platform rules, every browser on iOS regardless of what the app store listing claims. Its top optimization tier compiles JavaScript through LLVM, the same compiler infrastructure used by clang for C++. Yes, your JavaScript and the operating system you're running on may be going through the same optimizer.

All three are JIT compilers. All three interpret your code initially for fast startup, profile its behavior at runtime, and progressively optimize "hot" functions into increasingly specialized machine code. Each one does this with a different tier structure and a different set of biases.

Why the same code behaves differently

JIT compilers don't optimize in general. They optimize based on observed behavior. They watch your functions run, form hypotheses about the types and shapes of the values flowing through them, and generate code that's fast if those hypotheses hold.

When the hypotheses don't hold, the optimizer throws away its work and starts over. This is called deoptimization, and it's where most engine-specific performance surprises come from.

function add(a, b) { return a + b; }

// After many calls with numbers, V8 specializes for numbers.
// This call violates the assumption:
add("hello", "world");
// The specialized version is discarded; execution falls back to bytecode.

If a function is polymorphic — sometimes called with numbers, sometimes strings, sometimes objects of different shapes — the engine either can't specialize it or has to keep deopting and respecialising. Either way, it runs slower than a version that sees consistent types.

Hidden classes and why object shape matters

JavaScript objects are conceptually hash maps. Treating them as hash maps at runtime would be slow. All three engines use a technique generically called "hidden classes" or "maps" to make object property access as fast as field access in a compiled language.

The trick: whenever you create an object with specific properties in a specific order, the engine notes the "shape" of that object and caches the property offsets. Any object that shares the same shape can use the same cached offsets. Access becomes a constant-time field read instead of a hash lookup.

const p1 = { x: 1, y: 2 };   // shape: { x, y }
const p2 = { x: 3, y: 4 };   // same shape — fast
const p3 = { y: 4, x: 3 };   // different shape — different hidden class
p1.z = 5;                     // p1 transitions to a new shape

If you create a million objects with the same shape, property access is about as fast as it can be. If you create a million objects with slightly different shapes — some with an optional field, some with fields added in different orders — each shape transition costs you.

The corollary: consistent object construction patterns matter. Initialize all fields in the constructor, even the ones you don't have values for yet. Don't conditionally add fields. Don't use delete on object properties — it bumps the object into a slower "dictionary mode" that disables most of these optimizations.

Inline caching

Building on hidden classes, all three engines use inline caching — they remember, at each property-access site in compiled code, where they last found the property, and try that offset first on the next call.

function getX(obj) { return obj.x; }
getX({ x: 1, y: 2 });  // first call: look up x, cache its offset
getX({ x: 2, y: 3 });  // same shape: cache hits, direct memory access
getX({ a: 1, x: 2 });  // different shape: cache misses, fall back

When every call site sees the same shape, you get monomorphic inline caches, the fastest case. Mixed shapes give you polymorphic caches, which are slower but still optimized. Highly variable shapes give you megamorphic caches, which are basically back to hash lookups.

Writing "boring" JavaScript — consistent shapes, consistent types, no clever metaprogramming at hot paths — is the single biggest thing you can do to help the engine help you.

Where the engines diverge

They all use the same general strategy, but they weight it differently.

The practical consequence: a micro-optimized loop may be fastest on one engine and mid-pack on another. A pathological pattern — polymorphic hot paths, frequent hidden-class churn, heavy use of arguments or delete — may merely slow you down on V8 and fall off a cliff on another engine.

This is why performance testing on multiple engines isn't optional at the edge. You don't get to pick a runtime.

The Node.js trap

A common pitfall for engineers coming from backend Node.js: Node uses V8, so surely everything transfers?

Not quite. Node has libuv, a C thread pool backing its I/O, plus native modules, plus generous OS-level resource limits, plus long-lived processes that give the optimizer plenty of time to warm up. The browser has Web APIs instead of libuv, no native modules, strict sandboxing, and short-lived tabs where a function might be called fifty times before the user navigates away — too few for the optimizer to tier up all the way.

V8-in-Node and V8-in-browser have the same engine and very different performance envelopes. Benchmarks from one don't predict the other.

Writing engine-friendly code

A short list of habits that help all three engines:

None of these are exotic. They all come down to predictability: the more predictable your code, the more the engine can commit to specialization.

What to take from this

You don't need to master the internals of three optimizing compilers. You need to internalize three things.

One, your code runs on a sophisticated, JIT-compiled, profile-guided runtime that rivals serious server VMs in optimization capability. It's not "just interpreted." Treating it with the same respect you'd give the JVM will generally be rewarded.

Two, the engines reward consistency and punish cleverness. Boring code is fast code. Object shapes, function arity, argument types — the more uniform these are, the faster everything runs.

Three, multi-engine testing is part of performance work. A profiler in one browser tells you about one implementation. The edge ships to all of them.


Next: When Server Patterns Break at the Edge →