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.
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.
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.
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
Grow the input
In the second example, change both lengths from 5000 to 20000. Predict: does the
Array.includestime grow 4 times or 16 times? Run it a few times and compare. (TheSet.hastime should grow only about 4 times.) - 2
Count the slow calls
In the first example, change
slowFib(20)toslowFib(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
Record a profile
Open this page's DevTools Performance panel, record, press Run on the second example, stop. Find the measures named
Array.includesandSet.hasin the Timings track.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
6765 calls: 21891
6765 calls: 21
6765 calls: 21
5Both 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
Console
Nothing logged yet.Expected console output
2500 2500 true
[ 'Array.includes', 'Set.has' ]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
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.
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.
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
PerformanceObserverwith 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), orawait 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 andSharedArrayBuffer.
Remember this
- 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, unlikeDate.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
performance.mark("name")andperformance.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
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 aWeakMapkeyed by objects (Topic 14.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. ASetorMaplookup is about O(1): roughly constant time. Inside a loop, that turns O(n²) work into O(n). Hectal's/dsacourse 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
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
DocumentFragmentor one array and add them in one go, instead ofinnerHTML +=in a loop, which re-parses the whole list every time) and don't interleave reads and writes. Reading a layout value such asoffsetHeightright 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 inrequestAnimationFrame(Topic 11.7). - 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 withdefer, 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-designcourse.
Explain it without notes
How would you find out why a page feels slow when typing in a search box?
What is memoisation, when is it safe, and what does it cost?
What is layout thrashing and how do you avoid it?
Why is performance.now() better than Date.now() for measuring code?
Practice
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.
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
includesloop with aSet.I can avoid layout thrashing by batching reads and writes.