Skip to content
Educora
Advanced22 min22 / 27

Performance and memory

Make code fast and lean: the Big-O cost of array, `Map` and `Set` operations, debounce and throttle, memoisation, garbage collection and memory leaks.

Check yourself
In this lesson you will learn
  • Estimate the Big-O cost of array, Map and Set operations 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.

OperationCost
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, findO(n)
arr.sort()O(n log n)
map.get/set/has, set.add/has/deleteO(1) on average
obj[key]O(1) on average
JavaScript
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 }
For 2,000 items the nested loop makes about 2 million comparisons, the 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.

JavaScript
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');
The result depends on the device, so no expected output is given here.

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.

JavaScript
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
Out of four calls only two ran: the last one of each “burst”. Every new call cancels the old timer.
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
TechniqueWhen it runsTypical use
debounceonce, X ms after the events stopsearch, form validation, autosave
throttleat most once every X msscrolling, 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.

JavaScript
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
Without a cache the same values are recomputed hundreds of thousands of times; with memoisation each 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 setInterval that 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.
JavaScript
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
After 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.
Exercise

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.

Exercise · JavaScript
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
Exercise

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.

Exercise · JavaScript
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/Set lookups 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 Set or Map once 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 WeakMap does not keep its keys alive.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which lookup is O(1) on average?