# Why JavaScript performance is weird

> Modern JavaScript engines are astonishingly fast, but only because they spend most of their time pretending your code is better than it actually is.

By Matthew D. Webb · 2020-01-01 · 10 min read
Canonical: https://mdwebb.io/articles/why-javascript-performance-is-weird/
AI involvement: Updated with images, diagrams and the interactive demo.

---

JavaScript performance feels strange because a modern engine isn't really running the code you wrote. It's running the code it wishes you'd written, and hoping you won't do anything to spoil the illusion.

Most of us carry around a comfortingly simple mental model: you write some code, the browser reads it, the CPU runs it. The reality has rather more moving parts. A modern engine parses your code, interprets it, watches it, profiles it, optimises it, generates machine code for it, and every so often throws all of that work in the bin because you did something it wasn't expecting.

The surprising thing is not that JavaScript can be slow. The surprising thing is that it's as fast as it is, given what it has to work with. Almost all of that speed comes from assumptions. While the assumptions hold, everything is lovely. When they don't, things get weird.

## The trading desk example

Picture a pricing screen on a trading platform. Every instrument looks roughly like this:

```js
const trade = {
  symbol: "GBPUSD",
  bid: 1.3521,
  ask: 1.3523,
};
```

And so does the next one:

```js
const eur = {
  symbol: "EURUSD",
  bid: 1.1632,
  ask: 1.1634,
};
```

The engine notices something it cares about a great deal - every object has exactly the same shape. So it builds an internal representation that says, in effect, `symbol` lives in slot one, `bid` in slot two, `ask` in slot three, and from then on a property lookup is nearly free. It no longer has to go hunting for `bid`. It already knows where `bid` lives. This is a big part of why JavaScript can keep up with traditionally compiled languages on a surprising number of workloads. It has learned the pattern.

Then somebody ships this:

```js
trade.pnl = 125000;
```

Nothing looks wrong. The feature works, the screen behaves, the numbers are correct. But the shape has changed. One object shape just became two, and the engine has slightly less confidence than it had a moment ago. At the scale a trading floor runs at, that confidence is the whole game. I spent years building interfaces where the difference between a fast screen and a slow one was measured in [a strict latency budget](/articles/latency-budgets-trading-uis/), and this is exactly the sort of innocent line that quietly spends it.

## Hidden classes

Most JavaScript developers never hear the phrase "hidden class". They use them every day regardless.

V8, the engine inside Chrome and Node, builds internal descriptions of object structure. Two objects with the same properties added in the same order can share the same hidden class, which lets the engine generate genuinely specialised machine code. It gets to stop asking questions. Instead of "does this object have a `bid` property, and if so, where", it can say "I know precisely where `bid` is". That distinction sounds trivial. It isn't. Modern processors reward predictability handsomely, and a JavaScript engine spends an enormous amount of effort manufacturing predictability out of a language that is fundamentally dynamic.

<figure>
  <img src="/diagrams/hidden-class-shapes.svg" alt="Two objects with the same properties in the same order share one hidden class that maps symbol, bid and ask to fixed slots. Adding a fourth property, pnl, forks a second hidden class, so the call site now has two shapes to reason about instead of one." />
</figure>

## The sports betting problem

Say you're processing football bets. Most tickets look like this:

```js
{ stake: 20, odds: 2.5, market: "Match Winner" }
```

Millions of them, all the same, all efficient. Then marketing launch a promotion:

```js
{ stake: 20, odds: 2.5, market: "Match Winner", boostedOdds: true }
```

Still fine. Then another:

```js
{ stake: 20, odds: 2.5, market: "Match Winner", boostedOdds: true, cashbackEligible: true }
```

Then another, and another, and another, because marketing have discovered they can. Eventually the engine stops seeing a consistent structure at all. Every lookup now has several possibilities to weigh, so it starts generating more defensive code, and performance begins to drift. Nothing is broken. Nothing crashes. The application is entirely correct. It has simply become harder for the runtime to predict what's coming next, and a great many performance problems in large JavaScript applications turn out to be exactly this and nothing more. Not expensive algorithms, not weak hardware. Just a slow erosion of predictability that nobody signed off on because nobody could point at the commit that did it.

## The fastest code is boring

One of the recurring jokes of performance engineering is that boring code is usually fast code. Predictable shapes, predictable types, predictable execution paths. The quickest systems I've worked on were rarely the cleverest; they were the most consistent. The engine rewards consistency more than almost anything else you can offer it - a slightly deflating thing to learn after years of admiring clever code.

## The expensive string

Consider this function:

```js
function calculateExposure(position) {
  return position.quantity * position.price;
}
```

For thousands of calls, every `position` looks like this:

```js
{ quantity: 100, price: 15 }
```

The engine notices, profiles, optimises, and emits specialised machine code on the assumption that `quantity` is a number. Then someone wires in data from an external API:

```js
{ quantity: "100", price: 15 }
```

The number is now a string. The result is still technically valid, because JavaScript will cheerfully coerce it and hand you the right answer. But the assumption underneath the fast code has just been falsified. The engine now has evidence that `quantity` might not always be a number, which means its optimised machine code might no longer be safe. So V8 does something called deoptimisation, which is exactly as cheerful as it sounds: it abandons the fast path and falls back to a slower one while it works out what's actually going on. The engine literally changes its mind. Most developers never notice it happening, and it happens constantly.

## Inline caches

One of the most important tricks inside a modern engine is the inline cache. When a function keeps accessing `trade.bid` against the same object shape, the engine effectively remembers where `bid` lives and makes every future lookup dramatically faster. That happy state is called a monomorphic call site, and life there is good.

Then the same code starts receiving different shapes. The engine keeps a few lookup strategies around and checks which one applies, which is polymorphic and still perfectly manageable. Eventually enough variations turn up that it gives up trying to specialise at all and falls back to the slow, general path. That's megamorphic, which sounds like the final boss of a video game but is really just a performance engineer's way of saying nobody has the faintest idea what's arriving next.

It's easier to feel this than to read about it. Below is a single hot call site, `trade.bid`, and you get to feed it. Keep sending the same shape and it stays monomorphic and fast. Bolt on a new field each time and watch it slide through polymorphic into megamorphic. Send a price as a string and watch the engine bin its optimised code on the spot.

<div data-island="hidden-class-demo"></div>

## Delete is not free

This one tends to catch people out. Take an object:

```js
const trader = { name: "Matt", desk: "FX" };
```

and later remove a property from it:

```js
delete trader.desk;
```

Harmless-looking - often isn't. The engine built its internal structure around the assumption that the property existed, and removing one can force the object into a markedly less efficient representation. In a lot of cases, setting the value to `null` or `undefined` is cheaper than deleting it outright. This is one of those details that doesn't matter at all until, very abruptly, it matters a great deal.

## Arrays are liars

Arrays are another place JavaScript will happily mislead you. Start with something dense and tidy:

```js
const prices = [1, 2, 3, 4];
```

The engine can optimise this aggressively, because it's a contiguous run of numbers and everything is predictable. Then somebody writes:

```js
prices[1000000] = 5;
```

You still have an array. Technically. But not really. The engine can no longer treat it as a densely packed list, and internally it may switch to something far closer to a dictionary. One line changed the data structure underneath you. Most developers never notice. The engine certainly does, and it's the one keeping score.

## Why benchmarks lie

This is why JavaScript benchmarks are so notoriously unreliable. Tiny changes produce enormous differences. Sometimes you're measuring the optimised version, sometimes the deoptimised one. Sometimes the engine gets clever enough to spot that your benchmark has no observable effect and deletes the code entirely, leaving you to marvel at how fast nothing runs. Sometimes you're accidentally measuring memory allocation instead of computation. Performance testing is hard because the runtime is an active participant in the experiment. You aren't measuring a static system. You're measuring one that learns, and occasionally one that cheats.

## The real architecture

The mental model most of us start with is source code, then the CPU. By now it should be obvious how much happens in between.

<figure>
  <img src="/diagrams/js-engine-pipeline.svg" alt="The naive model is source code straight to the CPU. The real pipeline runs source through a parser, interpreter, profiler and optimiser before reaching machine code, with a deoptimiser looping the work back to the interpreter whenever an assumption breaks, to be re-profiled and re-optimised." />
</figure>

The engine is continuously making predictions about your program. Good performance is what happens when those predictions are right. Bad performance is often just the engine discovering they weren't and cleaning up after itself.

## The wrong lesson

The wrong thing to take from all of this is to start micro-optimising every object and array you own. That way lies madness, and a codebase nobody else can read. The overwhelming majority of applications are not bottlenecked on hidden classes or inline caches. They're bottlenecked on network latency, on rendering work nobody needed, on poor architecture, on slow database access, or simply on doing too much. I've argued before that [most front-end performance problems aren't JavaScript problems at all](/articles/most-js-performance-problems-arent-js/), and that remains the first place to look by a wide margin.

The goal isn't to write code for the engine. It's to understand what the engine is trying to do on your behalf, so that on the rare occasion it does matter, you know which assumption you've broken. Most of the time it's astonishingly good at its job, and the most useful thing you can do is stay out of its way. This is the one place I'll allow a chess comparison: you don't play for a clever-looking move, you play the position in front of you, and most positions don't call for fireworks.

## The interesting part

The genuinely interesting thing about JavaScript performance isn't that modern engines are fast. It's how they got there. JavaScript was never designed to go toe to toe with heavily optimised compiled languages, and yet a modern engine routinely runs millions of operations a second. It manages it through an extraordinary pile of assumptions, predictions, shortcuts, caches and educated guesses, most of which amount to betting that your code will keep behaving the way it behaved five seconds ago.

Usually it wins that bet. Occasionally it doesn't. And when it doesn't, JavaScript performance starts to look a little weird.

## Further reading

- [JavaScript engine fundamentals: Shapes and Inline Caches](https://mathiasbynens.be/notes/shapes-ics): Mathias Bynens on hidden classes (shapes) and inline caches, the clearest plain-English account of the machinery this whole post leans on.
- [What's up with monomorphism?](https://mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html): Vyacheslav Egorov, who has actually built these engines, on why monomorphic call sites are fast and how they degrade.
- [Elements kinds in V8](https://v8.dev/blog/elements-kinds): the V8 team on how arrays are represented internally, and exactly how a single sparse write tips a packed array into a dictionary.
- [Launching Ignition and TurboFan](https://v8.dev/blog/launching-ignition-and-turbofan): the interpreter-and-optimiser pipeline straight from the source, useful for seeing where profiling and deoptimisation actually sit.
