Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~22 min· topic 3 of 8

Topic 15.3

Performance

In one line

Fast JavaScript comes from doing less work, not from clever tricks: measure first with performance.now, marks and the DevTools Performance panel, then remove repeated work with memoisation, better data structures and batched DOM updates.

Think of it like this

A busy kitchen. A slow cook doesn't need to chop faster; they need to stop doing useless work. They write down answers they already worked out ("how much salt for 4 people? 1 spoon") instead of tasting again (memoisation). They keep spices in labelled jars instead of searching the whole shelf each time (the right data structure). They carry all the plates in one trip instead of ten (batching). And before changing anything, they time the dinner rush to see where the minutes really go (measuring).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

performance.now()
A precise timer in milliseconds since the page started, used to measure how long code takes.
Profiling
Recording what a program does over time to find where the time really goes.
Long task
Any piece of main-thread work longer than 50 ms; the page can't respond to input while it runs.
Memoisation
Remembering a function's answer for given arguments so the next identical call returns it instantly.
Big-O
A way to describe how work grows as the input grows, like O(n) (grows evenly) or O(n²) (grows much faster).
Layout
The browser step that works out the size and position of every element.
Layout thrashing
Alternating DOM writes and layout reads in a loop, forcing the browser to redo layout again and again.
DocumentFragment
A lightweight holder for DOM nodes; adding it to the page moves all its children in at once.
Web Worker
A background thread that runs JavaScript without blocking the page, but can't touch the DOM.

Step by step

01Measure first

Wrap the suspect code in two performance.now() calls. Repeat it a few times: the first run is often slower because the engine hasn't optimised the code yet.

measure.jswhole filejavascript
const t0 = performance.now();
buildReport(data);
const ms = performance.now() - t0;
console.log("buildReport took", ms.toFixed(1), "ms");

02Find the hot spot in DevTools

Open DevTools, go to Performance, press Record, do the slow thing, press Stop. Long tasks have a red corner. Click one and read the flame chart from top (the event) to bottom (the functions it called). Wide bars are where time goes. Bottom-Up sorts functions by their own time.

03Cache repeated answers

If the same pure function is called with the same inputs again and again, memoise it. The Map stores answers by argument.

memoize.jswhole filejavascript
function memoize(fn) {
  const cache = new Map();
  return (arg) => {
    if (!cache.has(arg)) cache.set(arg, fn(arg));
    return cache.get(arg);
  };
}

04Pick a faster lookup

Checking "is this id in that list?" inside a loop is a classic slowdown. Turn the list into a Set once, then each check is near-instant.

05Batch DOM reads and writes

The browser waits to do layout until it must. A read like offsetHeight after a write makes it must. Group reads together, then writes together, so layout happens once.

Batch DOM reads and writesdiagram
Rendering diagram…

06Check the fix with the same measurement

Re-run the exact measurement from step 1 and the DevTools recording. If the number didn't move, the change was not worth its complexity: undo it.

Try it yourself

  1. 1

    Grow the input

    In the second example, change both lengths from 5000 to 20000. Predict: does the Array.includes time grow 4 times or 16 times? Run it a few times and compare. (The Set.has time should grow only about 4 times.)

  2. 2

    Count the slow calls

    In the first example, change slowFib(20) to slowFib(25). Predict the call count (hint: it grows by about 1.6 times per step). Then see how the memoised version's count changes.

  3. 3

    Record a profile

    Open this page's DevTools Performance panel, record, press Run on the second example, stop. Find the measures named Array.includes and Set.has in the Timings track.

Code & diagrams

Memoisation: 21,891 calls down to 21 New tab

The second `fastFib(20)` made no new calls: the answer came straight from the cache. The recursive calls go through the memoised `fastFib`, so every sub-answer is cached too.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

6765 calls: 21891
6765 calls: 21
6765 calls: 21
5
Measure: Array.includes versus Set.has New tab

Both find the same 2500 ids. The page shows the times: the `Set` version is many times faster because each lookup no longer scans 5000 items. Only the counts are logged, because times differ on every run.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

2500 2500 true
[ 'Array.includes', 'Set.has' ]
Batch DOM work: one insert, reads before writes New tab

After `append`, the fragment is empty: its children moved into the list. Doubling all boxes needed one forced layout for the reads and one at the end, instead of one per box.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1000 Item 1000 0
[ 20, 30, 40 ] [ 40, 60, 80 ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Memoise an impure function

Write const now = memoize(() => Date.now()); and call now() twice, a second apart.

terminal
$ Console
── what you'll see ──
Both calls return the same timestamp: the second one is stale.

Break #2

Thrash the layout

In the third example, replace the read-then-write code with one loop: boxes.forEach((b) => { b.style.height = b.offsetHeight * 2 + "px"; }); and record it in the Performance panel with many more boxes.

terminal
$ DevTools Performance
── what you'll see ──
Many purple "Layout" blocks, each flagged "Forced reflow is a likely performance bottleneck".

Myth vs fact

Myth

Micro-optimisations like for instead of forEach make apps fast.

Fact

Engines optimise both well. Real wins come from doing less work: fewer DOM changes, better algorithms, less code to load. Measure before changing style for speed.

Myth

Date.now() is fine for timing code.

Fact

It has 1 ms resolution and follows the system clock, which can jump. performance.now() is monotonic (never goes backwards) and more precise.

Myth

Caching always helps.

Fact

A cache costs memory and adds the risk of stale data. If inputs rarely repeat, a memoised function is slower (extra lookup and storage) and wastes memory.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Chrome reports long tasks through PerformanceObserver with type "longtask", and newer ones via "long-animation-frame" (LoAF) entries that attribute the delay to specific scripts. These feed real-user monitoring of INP.

  • ▸

    To keep the page responsive during heavy loops, yield to the browser between chunks: await scheduler.yield() where supported (Chromium), or await new Promise((r) => setTimeout(r, 0)) as a fallback.

  • ▸

    Engines optimise functions that always see the same object shapes (hidden classes). Adding properties in different orders or deleting properties makes objects slower to access in hot code; initialise all fields in the constructor.

  • ▸

    performance.now() resolution is reduced because high-precision timers enable side-channel attacks such as Spectre; pages that opt into cross-origin isolation (COOP + COEP headers) get finer timers and SharedArrayBuffer.

Remember this

  1. 1

    Measure before you optimise. Guessing where code is slow is usually wrong. performance.now() returns a high-resolution time in milliseconds (with decimals) since the page started; take it before and after some work and subtract. It never jumps backwards, unlike Date.now(), which follows the system clock. Browsers deliberately round it (to about 0.1 ms, or 5 microseconds on cross-origin-isolated pages) to block timing attacks, so time a block of work, not one tiny operation.

  2. 2

    performance.mark("name") and performance.measure("label", startMark, endMark) record named moments and durations that show up in the DevTools Performance panel under "Timings". That panel records everything the browser does: JavaScript (a flame chart of which function called which, and for how long), style calculation, layout (working out sizes and positions), paint and frames. A long task is any task over 50 ms: during it the page can't react to clicks or typing. That is what the INP (Interaction to Next Paint) metric in Core Web Vitals punishes.

  3. 3

    Memoisation means caching a function's results by its arguments: the first call computes and stores, later calls with the same arguments return the stored answer. It only works for pure functions (Topic 15.1); memoising something that depends on time or outside state returns stale answers. Classic win: naive recursive Fibonacci makes over 21,000 calls for fib(20); memoised, it makes 21. The cost is memory: an unbounded cache grows forever, so real caches limit size (an LRU cache drops the least recently used entry) or use a WeakMap keyed by objects (Topic 14.4).

  4. 4

    Avoid work by choosing the right data structure and algorithm. array.includes(x) checks items one by one: O(n), meaning the time grows with the number of items. A Set or Map lookup is about O(1): roughly constant time. Inside a loop, that turns O(n²) work into O(n). Hectal's /dsa course teaches this way of thinking (Big-O) in depth. Other ways to skip work: return early, compute a value once outside a loop, and debounce or throttle fast-firing events like typing and scrolling (Topic 15.7 builds a debounce).

  5. 5

    DOM work is usually the most expensive part. Every change can make the browser recalculate styles, layout and paint. Two rules: batch inserts (build many elements in a DocumentFragment or one array and add them in one go, instead of innerHTML += in a loop, which re-parses the whole list every time) and don't interleave reads and writes. Reading a layout value such as offsetHeight right after changing styles forces the browser to do layout now (a forced synchronous layout); doing that in a loop is layout thrashing. Read everything first, then write everything, or schedule writes in requestAnimationFrame (Topic 11.7).

  6. 6

    Load less code. On real sites the biggest cost is often how much JavaScript is downloaded, parsed and run at startup. Split rarely used code with dynamic import() (Topic 12.2), load scripts with defer, and check bundle size. Keep long work off the main thread: split it into chunks that yield to the browser, or move it to a Web Worker (a background thread with no DOM access). For network caching and CDNs, see Hectal's /system-design course.

Explain it without notes

01

How would you find out why a page feels slow when typing in a search box?

02

What is memoisation, when is it safe, and what does it cost?

03

What is layout thrashing and how do you avoid it?

04

Why is performance.now() better than Date.now() for measuring code?

Practice

01

Write memoize(fn) that supports any number of arguments by building a key with JSON.stringify(args). Show that a counting function runs once for repeated calls.

02

You have orders (10,000 items with customerId) and vipIds (an array of 2,000 ids). Write the fast way to get the orders from VIP customers.

Trade-offs

  • ↔

    Memoisation trades memory for speed; an unbounded cache in a long-lived page is a slow memory leak.

  • ↔

    Batched or virtualised rendering is much faster for big lists, but adds code complexity and can hurt accessibility (find-in-page and screen readers only see rendered rows) if done carelessly.

Done when you can

  • I can time code with performance.now() and with marks and measures.

  • I can record a profile and find a long task in DevTools.

  • I can write a memoise helper and explain when it is unsafe.

  • I can replace a slow includes loop with a Set.

  • I can avoid layout thrashing by batching reads and writes.