- Estimate the Big-O cost of array,
MapandSetoperations and choose the right data structure - Limit how often expensive functions run with debounce, throttle and memoisation
- Explain garbage collection by reachability and avoid typical memory leaks
A search box sends a request to the server on every keystroke — ten letters, ten requests. In a list of 50,000 users, the check “is this email already taken?” walks through the whole array every time. The page lags and the phone gets hot. Performance rarely depends on micro-tricks; it depends on two things: choosing the right data structure and not doing unnecessary work.
Big-O in practice
Big-O notation shows how the work grows as the input size n grows, ignoring constant factors. O(1) does not depend on n; O(log n) is binary search; O(n) is one pass; O(n log n) is good sorting; O(n²) is a nested loop over the same data. For n = 100,000, O(n) means a hundred thousand steps, while O(n²) means ten billion.
| Operation | Cost |
|---|---|
arr[i], arr.push(x), arr.pop() | O(1) |
arr.shift(), arr.unshift(x) | O(n) in general: the items have to move |
arr.includes(x), indexOf, find | O(n) |
arr.sort() | O(n log n) |
map.get/set/has, set.add/has/delete | O(1) on average |
obj[key] | O(1) on average |
function hasDuplicatesSlow(items) {
let steps = 0;
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
steps++;
if (items[i] === items[j]) return { found: true, steps };
}
}
return { found: false, steps };
}
function hasDuplicatesFast(items) {
const seen = new Set();
let steps = 0;
for (const item of items) {
steps++;
if (seen.has(item)) return { found: true, steps };
seen.add(item);
}
return { found: false, steps };
}
const ids = Array.from({ length: 2000 }, (_, i) => i);
console.log(hasDuplicatesSlow(ids));
console.log(hasDuplicatesFast(ids));▸ Expected output
{ found: false, steps: 1999000 }
{ found: false, steps: 2000 }Set only 2,000. If the data grows 10 times, the first version gets 100 times slower, the second only 10 times.Measure before optimising. performance.now() gives milliseconds with a fractional part, and the DevTools Performance panel shows where the time goes. Run the code below: the numbers will differ on every device, but the ratio is clear.
const n = 20000;
const list = Array.from({ length: n }, (_, i) => i);
const set = new Set(list);
let start = performance.now();
for (let i = 0; i < n; i++) list.includes(i);
console.log('Array.includes:', Math.round(performance.now() - start), 'ms');
start = performance.now();
for (let i = 0; i < n; i++) set.has(i);
console.log('Set.has:', Math.round(performance.now() - start), 'ms');Debounce and throttle
Some events fire very often: input on every letter, scroll and resize dozens of times per second. Debounce waits until the events have stopped for X ms and then calls the function once — a search box, autosave. Throttle calls the function at most once every X ms — scroll position, a button clicked again and again.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const search = debounce((text) => console.log('Searching for:', text), 200);
search('j');
search('ja');
search('jav');
setTimeout(() => search('javascript'), 500);▸ Expected output
Searching for: jav Searching for: javascript
function throttle(fn, interval) {
let waiting = false;
return (...args) => {
if (waiting) return;
fn(...args);
waiting = true;
setTimeout(() => {
waiting = false;
}, interval);
};
}
const handleScroll = throttle((y) => console.log('scroll handled at', y), 100);
[0, 30, 60, 90].forEach((y) => handleScroll(y));
setTimeout(() => [150, 180].forEach((y) => handleScroll(y)), 150);▸ Expected output
scroll handled at 0 scroll handled at 150
| Technique | When it runs | Typical use |
|---|---|---|
| debounce | once, X ms after the events stop | search, form validation, autosave |
| throttle | at most once every X ms | scrolling, window resizing, firing in a game |
Memoisation
Memoisation means remembering a pure function's results by their arguments: a second call with the same argument returns the stored answer at once. It trades memory for speed. It works only for pure functions, and a cache without a limit can grow forever.
function memoize(fn) {
const cache = new Map();
return (n) => {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
let calls = 0;
const slowFib = (n) => {
calls++;
return n < 2 ? n : slowFib(n - 1) + slowFib(n - 2);
};
console.log(slowFib(25), 'calls:', calls);
calls = 0;
const fastFib = memoize((n) => {
calls++;
return n < 2 ? n : fastFib(n - 1) + fastFib(n - 2);
});
console.log(fastFib(25), 'calls:', calls);
console.log(fastFib(25), 'calls:', calls);▸ Expected output
75025 calls: 242785 75025 calls: 26 75025 calls: 26
n is computed only once.Garbage collection and memory leaks
JavaScript manages memory by itself: the garbage collector removes objects that are no longer reachable from the “roots” — global variables, the current call stack, active closures. Modern engines use mark-and-sweep: everything reachable from the roots is marked, and the rest is cleared. You never call the garbage collector — you just must not keep references you don't need.
- Forgotten timers: a
setIntervalthat is never cleared keeps its closure and all its data alive. - Event listeners left on removed elements, and elements removed from the DOM but still stored in variables.
- Global caches and arrays that only ever grow.
const visits = new WeakMap();
function track(user) {
visits.set(user, (visits.get(user) ?? 0) + 1);
}
let user = { name: 'Elvin' };
track(user);
track(user);
console.log(visits.get(user), visits.has(user));
user = null;
const timer = setInterval(() => console.log('tick'), 1000);
clearInterval(timer);
console.log('Interval cleared: nothing keeps the callback alive');▸ Expected output
2 true Interval cleared: nothing keeps the callback alive
user = null the object is unreachable, and the garbage collector may remove it together with its WeakMap entry — the engine decides exactly when. WeakMap keys must be objects, and a WeakMap cannot be iterated.Write memoize(fn) with a Map so that slowSquare runs only once per argument. The current version has no cache — that is why the number of real calls is 4 instead of 2.
let calls = 0;
const slowSquare = (n) => {
calls++;
return n * n;
};
function memoize(fn) {
// add a cache
return (n) => fn(n);
}
const fastSquare = memoize(slowSquare);
console.log(fastSquare(9), fastSquare(9), fastSquare(4), fastSquare(9));
console.log('real calls:', calls);▸ Expected output
81 81 16 81 real calls: 2
Complete debounce(fn, delay): the returned function must call fn only once, delay ms after the last call. Right now it does not wait at all and saves every letter.
function debounce(fn, delay) {
// wait `delay` ms after the last call, then call fn once
return fn;
}
const save = debounce((text) => console.log('Saved:', text), 300);
save('H');
save('He');
save('Hel');
setTimeout(() => save('Hello'), 100);
setTimeout(() => save('Hello!'), 700);▸ Expected output
Saved: Hello Saved: Hello!
Key points
- Big-O shows how work grows with the data: array search is O(n),
Map/Setlookups are O(1) on average, and a nested loop over the same data is O(n²). - Instead of searching an array again and again, build a
SetorMaponce and use it; measure before optimising. - Debounce runs once after the events stop; throttle runs at most once per interval.
- Memoisation caches the results of pure functions — speed paid for with memory.
- The garbage collector removes unreachable objects; leaks come from forgotten timers, listeners and growing caches, while a
WeakMapdoes not keep its keys alive.
Check yourself
10 questions. Every correct answer earns XP.