Optimising in Practice

A while back a teammate sent me a pull request titled “perf: keep User objects monomorphic”. It was genuinely good work. Every User was now built through one constructor with the same properties in the same order, so every call site that touched a user stayed on the fast path. It shaved a couple of microseconds off a function that ran a few hundred times per request. We merged it. The page still took 1.9 seconds to load, exactly as before, because those 1.9 seconds were forty database queries fired one after another, and no object shape on earth was going to fix that.

Everything you read in this part is true. Shapes matter, inline caches matter, deopts are real and they do cost you. This last lesson is about the other half of the skill, the half that separates people who know how the engine works from people who make things fast: knowing when any of it matters, and, far more often, knowing when it does not.

Most of the time it does not. Not because the engine is unimportant, but because of an order-of-magnitude gap you have to feel in your bones before the rest of this makes sense.

The order of magnitude that reframes everything

Your CPU is fast in a way that is genuinely hard to picture, so borrow the classic trick and stretch time. Pretend one nanosecond took one whole second. On that scale, reading a value the CPU already holds in cache is basically a heartbeat. Reaching out to main memory takes a minute or two. Pulling a few kilobytes off a fast SSD takes hours. A network round trip to a machine in the same datacenter takes days. And a round trip to a server on the far side of the world takes years.

if one nanosecond took one second, everything else runs on this scaleCPU cachemain memorySSD readdatacenter hopacross the world~1 ns~100 nstens of us~0.5 ms~150 ms1 second~2 minutes~hours~days~yearsengine-level micro-optimisationnetwork, disk and the databaseone slow request usually contains both ends of this ladder at once
Latency stretched so one nanosecond is one second. Engine-level work lives at the fast end; networks and databases live at the slow end.

The engine-level work this part taught you lives at the fast end of that ladder. Making a loop monomorphic, keeping an array packed, dodging a deopt: you are shaving nanoseconds, sometimes microseconds. A single unindexed database query lives at the slow end. When one request contains both, the query is not a little bigger than the loop. It is thousands, often millions, of times bigger. You can make the loop infinitely fast, delete it outright, and the request will not notice.

Where your request’s time actually goes

Put a profiler around a real, slow interaction, front to back, and the breakdown almost never looks the way the team guessed. The wait is dominated by things that have nothing to do with V8.

one ~1.8 s request, by where the time wentnetwork520 msdatabase queries840 ms · 46%render360 msJS execution: 90 ms (5%)the only slice V8 tuning can touchbiggest slice: start the afternoon hereshave the 90 ms loop to nothing and the request is still ~1.7 s
A typical slow request, broken down. The JavaScript-execution slice, the only part this whole course can shrink, is the thin one.

The usual suspects, roughly in order of how often they are the real problem:

  • Round trips. Every hop to a database, cache, or third-party API is a trip down to the slow end of the ladder. Do several in series and the latencies stack. The worst version is the N+1 query, one query to get a list and then one more per row, a picket fence of small waits that add up to seconds.
  • Slow queries. A query that scans a whole table instead of using an index turns a millisecond into a second. Fixing it is a database index, not a code trick.
  • Doing work you did not need to do. Fetching rows you will never show, recomputing a value you already had, re-rendering a component whose inputs did not change. The fastest work is the work you delete, and a cache is often how you delete it.
  • Rendering and layout. On the front end, the browser’s own layout and paint frequently dwarf your script. A janky interaction is usually too much DOM work or a layout thrash, which is why Core Web Vitals measures interaction latency and layout shift, not how monomorphic your call sites are.

Notice the JavaScript-execution slice in that diagram. That thin sliver is the only part this entire part of the course can help you shrink. On most real pages and endpoints, it is not where the time is.

A hierarchy of optimisation

So there is an order to this, and it is the same every time. Attack the biggest, cheapest wins first and stop when the numbers are good enough. Engine-level work sits at the bottom of the priority list precisely because it sits at the top of the difficulty-to-payoff ratio.

wider = bigger payoff3 · Engine micro-optimisationlast resortshapes · inline caches · deopts2 · Fix the macro costsnetwork · queries · I/O · rendering1 · Do less workbiggest payoffalgorithms · data structures · caching
The optimisation pyramid. The wide base is where the wins are; the tiny tip is engine micro-optimisation, and you reach for it last.

Do less work. This is the whole game, and it is almost never about the engine. A better algorithm or data structure changes the shape of the cost curve, and constant-factor tricks cannot compete with that. An accidental O(n²) loop over a list that keeps growing will eventually swamp every micro-optimisation you own, and no amount of monomorphism rescues an algorithm that simply does quadratically too much.

// Slow on big lists: indexOf rescans from the start each time -> O(n^2)
function hasDuplicates(items) {
  return items.some((it, i) => items.indexOf(it) !== i);
}

// Fast: one pass, membership is O(1) -> O(n)
function hasDuplicatesFast(items) {
  const seen = new Set();
  for (const it of items) {
    if (seen.has(it)) return true;
    seen.add(it);
  }
  return false;
}

On a hundred thousand items the first version is a hang and the second is instant. That gap is thousands-fold, and not a single hidden class was involved. Fix the shape of the work long before you worry about the shape of the objects.

Fix the macro costs. Once the algorithm is sane, this is where the real seconds hide: batch the N+1 into one query, add the index, cache the response, split or trim the bundle so the browser parses less, stop the layout thrash. These are the middle band, and for the vast majority of apps they are the last stop. You will hit “fast enough” here and never need the tip of the pyramid at all.

Then, and only then, the engine. If you have done less work, fixed the macro costs, and a profiler still points at a genuinely hot, CPU-bound function, now this part of the course pays for itself. Not before.

Measure first, or you are guessing

Here is the uncomfortable truth that the whole measuring chapter is built on: intuition about performance is almost worthless. Every engineer has a gut feeling about which line is slow, and that feeling is wrong often enough that acting on it is a coin flip with extra steps. The bottleneck is rarely where you think. It is usually somewhere boring you never suspected, and the thing you were sure about turns out to be two percent of the time.

So you do not guess. You measure a real workload, find the biggest cost, fix that one thing, and measure again. That loop is the entire discipline.

still slow? attack the next biggest cost1 · MEASUREa real workload2 · FINDthe biggest cost3 · FIXone thing4 · MEASUREagain, prove it movedthe alternative, crossed out:guess the slow linemicro-optimise itship and hopemoves the real number by nothing
The optimisation loop. Measure, find the biggest cost, fix one thing, measure again. The crossed-out path is how you burn a day.

Step four is the one people skip, and it is the one that makes this engineering instead of superstition. You always re-measure, because plenty of “optimisations” shrink the thing you were staring at and leave the total untouched. A profiler is not only for finding the problem. It is for proving you fixed it. And measure the real workload, not a hand-rolled microbenchmark, because those lie in creative ways all their own, which is a whole lesson in benchmarking traps.

Here is that lesson made tangible. Below is a mock request with four cost slices you can optimise. Try to get the bar under the dashed “half” line. You will find you cannot do it by shrinking the JavaScript loop, no matter how hard you tune it, and you can do it easily by cutting the query.

interactiveWhere is the time? Optimise the biggest slice, not the tuneable one

That is the measure-first lesson in miniature. The tuneable slice, the one this whole part taught you to attack, is the one that cannot move the number. The big slices can, and they are boring.

When the engine internals earn their keep

None of this means the engine chapter was wasted. There is a real and important set of cases where the tip of the pyramid is exactly where the win is. You just have to be honest about whether you are in one.

Did you measure it,and is it on a path users wait on?Profile a real workload first.Until then you are guessing.noyesIs the biggest cost network, a query,disk I/O, or rendering?Fix the macro cost.Engine tuning will not help here.yesnoIs it a hot, CPU-bound functionon a path that runs a lot?Clean, boring code is enough.Move on to the next thing.noyesNow the rules from this part pay offstable shapes · monomorphic call sites · packed arraysno deopts in the hot loop · steady allocation
A decision guide. Engine internals only apply after you have measured, ruled out the macro costs, and found a genuinely hot CPU-bound function.

The cases that pass that gauntlet look like this:

  • A genuinely hot inner loop. A physics step, a parser, a codec, a hashing routine, a layout engine. Something that runs millions of times per second and is provably on the critical path.
  • Large-scale data processing. Transforming millions of rows, where a per-element cost is multiplied by a huge count and the engine’s representation choices (Smis versus boxed doubles, packed versus holey arrays) start to matter.
  • A shape created millions of times. One object built in an inconsistent way, allocated in a tight loop, can churn hidden classes and garbage enough to show up. Here the cost of a single object, times millions, is real.
  • A library on everyone’s critical path. A hot function in a framework core or a widely-used utility gets its cost multiplied across every app that imports it. The math that makes micro-optimisation pointless for you makes it worthwhile for them.

The tax on premature micro-optimisation

Reach for the tip of the pyramid when you have not earned it and you pay for it three ways.

You make the code worse to read. Compare these, imagining orders has a dozen entries:

// Clear, and the engine handles it perfectly well
const totals = orders.map((o) => o.amount * o.qty);

// "Optimised": preallocated, hand-indexed, and now nobody wants to touch it
const totals2 = new Array(orders.length);
for (let i = 0, n = orders.length; i < n; i++) {
  const o = orders[i];
  totals2[i] = o.amount * o.qty;
}

For a dozen orders the second version is not measurably faster, it is just uglier, and you have spent your readability budget to buy nothing. That budget is real. The next person who has to change this loop, possibly you in six months, pays interest on it.

You waste your own time, both the hour you spent tuning a cold function and the hours later spent debugging the “clever” version.

You sometimes make it slower. This is the one that stings. The optimiser is already handling the straightforward version well, and your hand-tuning can fight it. A common own goal: reusing one mutable object with different shapes to “save allocations”, which quietly turns a clean monomorphic call site megamorphic and lands you slower than where you started. You were arguing with a compiler that was already on your side.

Summary

  • Feel the order of magnitude. Engine-level work shaves nanoseconds to microseconds; network, disk and database work costs milliseconds. When both are in one request, the slow end wins by a factor of thousands. Optimising the fast end is often invisible.
  • Know where the time actually goes. For most apps the real costs are macro: round trips, N+1 and slow queries, oversized bundles, layout and rendering, and redundant work. The JavaScript-execution slice this part can shrink is usually the thin one.
  • Work the hierarchy, biggest wins first. Do less work (better algorithms, data structures, caching), then fix the macro costs (I/O, network, rendering), and only then, if a profiler still points at a hot CPU-bound function, reach for engine internals.
  • Measure first, always, and re-measure. Intuition about performance is a coin flip. Profile a real workload with a flame chart, fix the biggest cost, and prove the total moved. Never trust a hand-rolled microbenchmark.
  • When internals do matter, the rules are just clean code. Hot loops, large-scale data, shapes created in the millions, and code on everyone’s critical path are the real cases. There, stable shapes, monomorphic call sites, packed arrays, no deopts and steady allocation pay off, and they are what tidy code does anyway.
  • Premature micro-optimisation is a tax. Unreadable code, wasted time, sometimes a regression from fighting the optimiser, and folklore that goes stale on the next engine release.

What this means for your code: understand the machine so you can reason about performance, not so you can perform rituals over it. The worth of this whole part is not a checklist of tricks to sprinkle everywhere. It is a mental model accurate enough that when a profiler finally lands on a hot function, you know why it is slow and what to change. Measure a real workload, fix the biggest cost first (it is almost never the engine), and keep your objects and arrays consistent enough that the fast path is simply what you get. Let measurement, not folklore, decide where you spend your effort. Then get back to shipping.