Skip to editor content
learningjavascript.orglesson 18 of 25

Performance Optimization

Fast applications keep users engaged. Slow ones drive them away. Performance optimization is not about premature micro-tuning; it is about understanding common bottlenecks and applying proven patterns to eliminate them. In this lesson you will learn debouncing and throttling for controlling execution frequency, memoization for caching expensive computations, lazy loading for deferring unnecessary work, requestAnimationFrame for smooth animations, Web Workers for offloading heavy tasks, and practical memory management to prevent leaks.

What You'll Learn

By the end of this lesson you will build the three shapes fast code is made of and get them passing their own checks: a memoize wrapper proven to cache by a call counter rather than a stopwatch, a "latest call wins" reducer that is debouncing with the timers taken out, and a batch splitter of the kind you hand a background worker one chunk at a time. You arrive able to write a function that returns a function and closes over private state (07-closures-and-scope) and to reason about work scheduled for later (08-async-and-promises); you leave able to measure before you optimize, and to name which of these patterns a given slowdown actually calls for.

The discipline matters more than the tricks. Every performance number in this lesson was measured rather than assumed, and one of them — a claim that batching DOM writes is faster — was measured and turned out to be false. Treat a performance intuition as a hypothesis until a benchmark agrees with it.

Measuring Performance

Before optimizing anything, you need to measure it. performance.now() gives you high-resolution timestamps for accurate benchmarking:

One boundary to keep straight before you start. The measuring and scheduling tools in this lesson — performance.now(), setTimeout, requestAnimationFrame and Worker — are not part of the JavaScript language. TC39, the committee that owns the language, defines syntax, objects like Promise and Math, and the rules the engine follows; it defines no clock and no scheduler. Those four come from whatever host is running your code: browsers define them (requestAnimationFrame and Worker only exist there), and Node defines its own versions of performance.now() and setTimeout, which is why the fences below run on the playground's Node at all. The practical consequence is that a program using them is portable only as far as its host: a requestAnimationFrame loop is browser-only code, while performance.now() and setTimeout happen to exist in both places.

// Benchmarking utility
function benchmark(label, fn) {
  const start = performance.now();
  const result = fn();
  const end = performance.now();
  console.log(label + ": " + (end - start).toFixed(3) + "ms");
  return result;
}

// Compare two approaches to summing numbers
benchmark("for loop", function() {
  let sum = 0;
  for (let i = 0; i < 100000; i++) {
    sum += i;
  }
  return sum;
});

benchmark("reduce", function() {
  const arr = [];
  for (let i = 0; i < 100000; i++) {
    arr.push(i);
  }
  return arr.reduce(function(sum, n) { return sum + n; }, 0);
});

// Multiple runs for more accurate results
function benchmarkAvg(label, fn, runs) {
  runs = runs || 10;
  const times = [];
  for (let i = 0; i < runs; i++) {
    const start = performance.now();
    fn();
    times.push(performance.now() - start);
  }
  const avg = times.reduce(function(a, b) { return a + b; }, 0) / times.length;
  const min = Math.min.apply(null, times);
  const max = Math.max.apply(null, times);
  console.log(label + ": avg=" + avg.toFixed(3) + "ms, min=" + min.toFixed(3) + "ms, max=" + max.toFixed(3) + "ms");
}

benchmarkAvg("String concatenation", function() {
  let str = "";
  for (let i = 0; i < 10000; i++) {
    str += "x";
  }
}, 5);

benchmarkAvg("Array join", function() {
  const parts = [];
  for (let i = 0; i < 10000; i++) {
    parts.push("x");
  }
  parts.join("");
}, 5);

Debouncing

Debouncing ensures a function only runs after a caller stops invoking it for a set period. This is essential for events that fire rapidly, like typing in a search box. Without debouncing, you might send a network request for every single keystroke:

function debounce(fn, delay) {
  let timer = null;
  return function() {
    const context = this;
    const args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function() {
      fn.apply(context, args);
    }, delay);
  };
}

// Simulate rapid keystrokes
let apiCallCount = 0;

function searchAPI(query) {
  apiCallCount++;
  console.log("API call #" + apiCallCount + ': searching for "' + query + '"');
}

const debouncedSearch = debounce(searchAPI, 300);

// Simulate a user typing "javascript" one character at a time
const chars = "javascript".split("");
let totalDelay = 0;

chars.forEach(function(char, i) {
  const typed = chars.slice(0, i + 1).join("");
  totalDelay += 50; // 50ms between keystrokes
  setTimeout(function() {
    console.log("Keystroke: " + typed);
    debouncedSearch(typed);
  }, totalDelay);
});

// After all typing finishes, check results
setTimeout(function() {
  console.log("\nTotal keystrokes: " + chars.length);
  console.log("Total API calls: " + apiCallCount);
  console.log("Saved " + (chars.length - apiCallCount) + " unnecessary requests!");
}, totalDelay + 500);

Predict

Trace this by hand before running it. Five save calls fire back-to-back in the same synchronous burst, then the program pauses. How many times does the handler actually run, and with what value?

function debounce(fn, delay) {
let timer = null;
return function () {
  const args = arguments;
  clearTimeout(timer);
  timer = setTimeout(function () {
    fn.apply(null, args);
  }, delay);
};
}

let saveCount = 0;
const save = debounce(function (value) {
saveCount++;
console.log("Saved: " + value);
}, 200);

save("h");
save("he");
save("hel");
save("hell");
save("hello");

console.log("Burst done. saveCount so far: " + saveCount);

This is the technique 25-form-validation pointed forward to. That lesson validates on every input event, which is the right call for a length or format check that costs nothing — and the wrong call for a rule that has to ask a server whether a username is already taken. Debouncing is what separates the two: it lets the cheap checks stay per-keystroke while the expensive one waits for the typing to stop. Note the division of labour with the previous lesson too — debouncing decides whether to start the request; AbortController from 13-web-apis-and-fetch cancels one already on its way. A production search box usually wants both.

Throttling

While debouncing waits until activity stops, throttling limits a function to run at most once per time interval. This is ideal for scroll and resize events where you need periodic updates but not on every pixel:

function throttle(fn, limit) {
  let waiting = false;
  let lastArgs = null;

  return function() {
    const context = this;
    lastArgs = arguments;

    if (!waiting) {
      fn.apply(context, lastArgs);
      waiting = true;
      setTimeout(function() {
        waiting = false;
        if (lastArgs) {
          fn.apply(context, lastArgs);
          lastArgs = null;
        }
      }, limit);
    }
  };
}

// Compare unthrottled vs throttled scroll handling
let rawCount = 0;
let throttledCount = 0;

function onScroll(position) {
  rawCount++;
}

const throttledOnScroll = throttle(function(position) {
  throttledCount++;
  console.log("Throttled handler: scroll position = " + position);
}, 200);

// Simulate 50 rapid scroll events over 500ms
for (let i = 0; i < 50; i++) {
  const scrollPos = i * 20;
  setTimeout(function() {
    onScroll(scrollPos);
    throttledOnScroll(scrollPos);
  }, i * 10);
}

setTimeout(function() {
  console.log("\nRaw events fired: " + rawCount);
  console.log("Throttled calls: " + throttledCount);
  console.log("Reduced by " + Math.round((1 - throttledCount / rawCount) * 100) + "%");
}, 1000);

Memoization

Memoization caches the result of a function call so that repeated calls with the same arguments return instantly without recomputing. The win comes from one place and one place only: how many times the same input arrives. If a workload asks for 200 results but only 10 of them are distinct, a cache turns 200 expensive calls into 10 — so you can predict the speedup before you run it.

function memoize(fn) {
  const cache = {};
  return function(n) {
    // Object.hasOwn checks the cache's OWN keys only. Plain `n in cache` would
    // also find inherited names like "toString", so memoize("toString") would
    // hand back Object.prototype.toString without ever calling fn.
    if (Object.hasOwn(cache, n)) {
      return cache[n];
    }
    const result = fn(n);
    cache[n] = result;
    return result;
  };
}

// An expensive pure function, with a counter so you can see how often it runs
let calls = 0;
function slowHash(n) {
  calls++;
  let h = n;
  for (let i = 0; i < 200000; i++) {
    h = (h * 31 + i) % 1000003;
  }
  return h;
}

// 200 requests, but only 10 distinct inputs — the same values keep coming back
const requests = [];
for (let i = 0; i < 200; i++) {
  requests.push(i % 10);
}

calls = 0;
const start1 = performance.now();
let sumSlow = 0;
for (const n of requests) {
  sumSlow += slowHash(n);
}
const time1 = performance.now() - start1;
console.log("Without memoization: " + time1.toFixed(1) + "ms, slowHash ran " + calls + " times");

const fastHash = memoize(slowHash);
calls = 0;
const start2 = performance.now();
let sumFast = 0;
for (const n of requests) {
  sumFast += fastHash(n);
}
const time2 = performance.now() - start2;
console.log("With memoization:    " + time2.toFixed(1) + "ms, slowHash ran " + calls + " times");

console.log("Same answers: " + (sumSlow === sumFast));
console.log("200 calls over 10 distinct inputs, so expect about " +
  (requests.length / 10) + "x — measured " + (time1 / time2).toFixed(1) + "x");

The call counter is the honest measure here: slowHash runs 200 times without the cache and 10 times with it, so the expected speedup is 20x — and the measured number lands near it (measured 20.3x, 20.7x, 21.1x, 21.4x and 22.7x across five runs on the playground's Node v20.20.2, and separate runs have gone as high as 25.7x; the spread is the cache's own bookkeeping plus timer noise, so treat anything from about 20x to 26x as the expected band rather than a fixed number). Note what memoization did not do: slowHash is exactly as slow as it ever was. A cache never makes a function faster, it only makes you call it less often. That is also why a cache is worthless for a function whose arguments are almost always new — 200 distinct inputs would mean 200 misses and no speedup at all. Whether those misses also make the program measurably slower depends on what you wrapped. Against slowHash the bookkeeping is lost in the noise, because a 200,000-iteration body dwarfs a single property lookup — measure it yourself with the fence below rather than taking the claim on trust. Wrap something cheap like n => n * 2, though, and the same all-miss workload runs several times slower than calling it directly. Cache the expensive and repeated; never the cheap.

// Measure the cost of a cache that NEVER hits: 200 distinct inputs, so every
// lookup is a miss and the cache bookkeeping is pure overhead.
function memoize(fn) {
  const cache = {};
  return function (n) {
    if (Object.hasOwn(cache, n)) return cache[n];
    const result = fn(n);
    cache[n] = result;
    return result;
  };
}

function slowHash(n) {
  let h = n;
  for (let i = 0; i < 200000; i++) {
    h = (h * 31 + i) % 1000003;
  }
  return h;
}

const distinct = [];
for (let i = 0; i < 200; i++) distinct.push(i);

function timeDirect() {
  const t = performance.now();
  let s = 0;
  for (const n of distinct) s += slowHash(n);
  return performance.now() - t;
}

function timeMemoized() {
  const f = memoize(slowHash);
  const t = performance.now();
  let s = 0;
  for (const n of distinct) s += f(n);
  return performance.now() - t;
}

// Warm up BOTH first. Without this the loop that runs first pays the JIT's
// start-up cost and the comparison measures warm-up, not caching overhead —
// which is exactly how a benchmark reports a 10% difference that is not there.
timeDirect();
timeMemoized();

const overheads = [];
for (let run = 0; run < 6; run++) {
  const direct = timeDirect();
  const memoized = timeMemoized();
  overheads.push((memoized / direct - 1) * 100);
}
overheads.sort(function (a, b) { return a - b; });
console.log("all-miss overhead, 6 runs:");
console.log("  min " + overheads[0].toFixed(1) + "%");
console.log("  max " + overheads[overheads.length - 1].toFixed(1) + "%");

Run it a few times. The range straddles zero — the miss costs about as much as it saves, which is to say nothing you can measure against a body this expensive. The warm-up lines are the load-bearing part of that fence, not a nicety: delete them and whichever loop runs first absorbs the JIT's start-up cost, and the same code will report the memoized version as several percent faster or slower purely by position. A benchmark that measures warm-up instead of the thing it names is the most common way performance numbers go wrong, including in write-ups that look careful.

Beware benchmarks that claim thousandfold memoization wins, usually with a recursive Fibonacci. Those are real speedups but they are not the cache's doing: memoizing fib turns exponential recursion into linear, roughly 30 million calls at fib(35) down to about 36. That is a change in algorithmic complexity, which is why the "speedup" keeps growing as n grows — and why an ordinary loop, with no cache at all, beats the recursive version just as thoroughly.

Arrange the code

Reassemble the body of the function memoize returns — the four lines that decide, on every call, whether to serve from the cache or do the work. The demo calls it twice with the same argument and prints a call counter, so a broken cache is visible in the counter rather than only in the timing. Eight of the twenty-four arrangements run without crashing, and all eight print the right answer, 36. Only one of them actually caches. Why must these four run in this order, and what is it that the other seven break while still looking correct?

  1. const result = fn(n);
  2. cache[n] = result;
  3. return result;
  4. if (Object.hasOwn(cache, n)) return cache[n];

Recall

Without scrolling up: debouncing here stops you from FIRING a search request on every keystroke — it waits until typing pauses. But in the previous lesson (Web APIs and Fetch) you met a tool for a different problem: a request has ALREADY been sent, and now a newer one should replace it. What was that tool, and how is its job different from debouncing?

Lazy Loading and Deferred Computation

Lazy loading means you only compute or load something when it is actually needed, not upfront. This reduces initial load time and memory usage.

The code below uses one piece of object syntax you have not met yet: a property defined with get in front of it — a getter. Writing get profile() { ... } means profile is read like a plain property, user.profile with no parentheses, but every read secretly runs that function body. That is precisely what makes lazy loading work here: the expensive computation is not triggered by creating the object, it is triggered by somebody reading the property, and if nobody ever reads it the work never happens. (Getters get a fuller treatment in 18-classes-and-inheritance; this one sentence is all you need to read the example.)

// Lazy property computation
function createLazyUser(rawData) {
  let processedProfile = null;

  return {
    get name() { return rawData.name; },
    get profile() {
      if (processedProfile === null) {
        console.log("  Computing profile (expensive operation)...");
        // Simulate expensive computation
        processedProfile = {
          displayName: rawData.name.toUpperCase(),
          initials: rawData.name.split(" ").map(function(w) { return w[0]; }).join(""),
          joinedYear: new Date(rawData.joined).getFullYear(),
          activityScore: rawData.posts * 2 + rawData.comments
        };
      }
      return processedProfile;
    }
  };
}

const users = [
  { name: "Alice Johnson", joined: "2022-03-15", posts: 45, comments: 120 },
  { name: "Bob Smith", joined: "2023-01-20", posts: 12, comments: 85 },
  { name: "Charlie Lee", joined: "2021-08-10", posts: 230, comments: 450 }
];

const lazyUsers = users.map(createLazyUser);

// Names are cheap to access
console.log("User names (no heavy computation):");
lazyUsers.forEach(function(u) { console.log("  " + u.name); });

// Profile is only computed when accessed
console.log("\nAccessing first user's profile:");
console.log("  ", lazyUsers[0].profile);

console.log("\nAccessing first user's profile again (cached):");
console.log("  ", lazyUsers[0].profile);

console.log("\nSecond user profile never computed - zero cost!");

requestAnimationFrame

When updating the DOM for animations, requestAnimationFrame synchronizes your updates with the browser's repaint cycle (typically 60fps — measured at 60–61 callbacks per second in Chrome on a 60.00 Hz display). The point is not raw speed but alignment. A timer fires whenever its delay happens to elapse, with no relationship to the display; rAF is scheduled to run immediately before the next repaint, so your update is phase-aligned with painting in a way a free-running timer is not. setTimeout(fn, 0) fires around 228 times a second on that same display, against 60 painted frames — roughly three of every four updates you compute are thrown away before anyone sees them. rAF also pauses on its own in a background tab, where a timer keeps burning CPU on frames nobody is looking at:

// Simulating requestAnimationFrame behavior
// In a real browser, rAF syncs with the display refresh rate

function simulateAnimation() {
  const totalFrames = 20;
  let frame = 0;
  const positions = [];
  const startTime = performance.now();

  function animate() {
    frame++;
    const progress = frame / totalFrames;

    // Easing function: smooth deceleration
    const eased = 1 - Math.pow(1 - progress, 3);
    const x = Math.round(eased * 300);
    positions.push(x);

    // Build a visual bar
    const bar = "|" + "=".repeat(Math.floor(x / 10)) + ">" + " ".repeat(30 - Math.floor(x / 10)) + "|";
    console.log("Frame " + String(frame).padStart(2) + ": " + bar + " x=" + x);

    if (frame < totalFrames) {
      // In real code: requestAnimationFrame(animate)
      setTimeout(animate, 16); // ~60fps
    } else {
      const elapsed = performance.now() - startTime;
      console.log("\nAnimation complete in " + elapsed.toFixed(0) + "ms");
      console.log("Frames rendered: " + frame);
      console.log("Average FPS: " + (frame / (elapsed / 1000)).toFixed(1));
    }
  }

  animate();
}

console.log("Smooth animation with easing:");
simulateAnimation();

Web Workers Overview

JavaScript is single-threaded, so a heavy computation blocks everything else: UI updates, event handlers, animations. Web Workers let you run code in a separate background thread. While we cannot create actual Worker files in this playground, understanding the concept and API is essential:

// Web Workers concept demonstration
// In real code, you create a Worker from a separate file:
//   const worker = new Worker("heavy-task.js");
//   worker.postMessage({ data: largeArray });
//   worker.onmessage = function(e) { console.log(e.data); };

// Simulating the main thread vs worker thread pattern
function simulateMainThread(task) {
  console.log("[Main] Starting heavy task on main thread...");
  const start = performance.now();
  const result = task();
  const time = performance.now() - start;
  console.log("[Main] Done in " + time.toFixed(2) + "ms - UI was BLOCKED!");
  return result;
}

function simulateWorkerThread(task) {
  return new Promise(function(resolve) {
    console.log("[Main] Sending task to worker thread...");
    console.log("[Main] UI remains responsive!");
    const start = performance.now();

    // Simulate worker processing in background
    setTimeout(function() {
      const result = task();
      const time = performance.now() - start;
      console.log("[Worker] Completed in " + time.toFixed(2) + "ms");
      console.log("[Main] Received result from worker");
      resolve(result);
    }, 100);
  });
}

// Heavy computation: finding primes
function findPrimes(limit) {
  const primes = [];
  for (let n = 2; n <= limit; n++) {
    let isPrime = true;
    for (let d = 2; d <= Math.sqrt(n); d++) {
      if (n % d === 0) { isPrime = false; break; }
    }
    if (isPrime) primes.push(n);
  }
  return primes;
}

// Without worker: blocks the main thread
const primes1 = simulateMainThread(function() { return findPrimes(10000); });
console.log("Found " + primes1.length + " primes\n");

// With worker: main thread stays free
simulateWorkerThread(function() { return findPrimes(10000); }).then(function(primes2) {
  console.log("Found " + primes2.length + " primes");
  console.log("\nWhen to use Web Workers:");
  console.log("  - Image/video processing");
  console.log("  - Large data parsing (CSV, JSON)");
  console.log("  - Complex calculations (crypto, physics)");
  console.log("  - Sorting/filtering very large datasets");
});

Transfer

You just saw why a heavy computation on the main thread blocks the UI, and how a Web Worker moves it to a background thread. New requirement: a photo-editing app applies a slow blur filter to a large image, and right now clicking the filter button freezes every other control for two seconds. Of these, which change actually keeps the UI responsive during the blur — and why?

Memory Management and Avoiding Leaks

JavaScript uses garbage collection to automatically free memory, but certain patterns prevent objects from being collected, causing memory leaks that degrade performance over time:

// Common memory leak patterns and fixes

// LEAK 1: Forgotten timers
console.log("--- Leak: Forgotten Timers ---");
function leakyTimer() {
  const hugeData = new Array(10000).fill("data");
  // This interval keeps hugeData alive forever!
  const id = setInterval(function() {
    // uses hugeData
  }, 1000);
  // FIX: always clear intervals when done
  return function cleanup() {
    clearInterval(id);
    console.log("  Timer cleared, memory can be freed");
  };
}
const cleanup1 = leakyTimer();
cleanup1();

// LEAK 2: Detached DOM references (conceptual)
console.log("\n--- Leak: Event Listener Accumulation ---");
function demonstrateListenerLeak() {
  const listeners = [];

  // BAD: adding listeners without removing them
  function addLeakyListener(name) {
    const handler = function() { console.log(name + " clicked"); };
    listeners.push(handler);
  }

  // GOOD: track listeners and provide cleanup
  function addCleanListener(name) {
    const handler = function() { console.log(name + " clicked"); };
    listeners.push(handler);
    return function remove() {
      const index = listeners.indexOf(handler);
      if (index > -1) {
        listeners.splice(index, 1);
        console.log("  Removed listener: " + name);
      }
    };
  }

  addLeakyListener("button1");
  addLeakyListener("button2");
  console.log("  Listeners without cleanup: " + listeners.length);

  const remove3 = addCleanListener("button3");
  const remove4 = addCleanListener("button4");
  console.log("  Listeners after adding more: " + listeners.length);

  remove3();
  remove4();
  console.log("  Listeners after cleanup: " + listeners.length);
}
demonstrateListenerLeak();

// LEAK 3: Closures holding large references
console.log("\n--- Leak: Closures Retaining Large Data ---");
function processData() {
  const rawData = new Array(100000).fill("raw");
  // BAD: the returned function keeps all of rawData alive
  // return function() { return rawData.length; };

  // GOOD: extract only what you need
  const length = rawData.length;
  const summary = "Processed " + length + " items";
  // rawData can now be garbage collected
  return function() { return summary; };
}
const getSummary = processData();
console.log("  " + getSummary());

// WeakMap for cache that doesn't prevent garbage collection.
// A WeakMap is a lookup table like a plain object, with two differences that
// matter here: its keys are OBJECTS rather than strings, and it does not keep
// those keys alive — drop every other reference to a key and both the key and
// its cached value become collectable. A plain object cache would hold them
// forever, which is exactly the leak this avoids.
// You read and write it with METHODS rather than bracket syntax: .set(key, value)
// stores, .get(key) reads back, .has(key) asks whether a key is present. (Those
// three are the same API JavaScript's Map has; a WeakMap is the collectable
// variant of it. Note there is no prototype-chain problem to work around here —
// unlike a plain object, a WeakMap starts genuinely empty, which is why .has is
// safe where `in` was not.)
console.log("\n--- WeakMap for GC-friendly caching ---");
const cache = new WeakMap();

function expensiveCompute(obj) {
  if (cache.has(obj)) {
    console.log("  Cache hit!");
    return cache.get(obj);
  }
  const result = { computed: obj.value * 2, timestamp: Date.now() };
  cache.set(obj, result);
  console.log("  Computed and cached");
  return result;
}

let myObj = { value: 42 };
expensiveCompute(myObj);
expensiveCompute(myObj);
// When myObj is set to null, both the key and value are eligible for GC
myObj = null;
console.log("  Object released - WeakMap entry will be garbage collected");

Prose is a poor teacher for leaks, because a leak's whole nature is that nothing goes wrong at the moment you make the mistake. The block below makes one observable.

Debug

This program builds twenty one-line summaries and keeps them in a registry. Each summary is a short string, so the registry should cost almost nothing — the assertion allows a generous 20 MB. It fails anyway, and the assertion message tells you how much the heap is really holding. The program does not crash, the summaries are all correct, and nothing looks wrong at the point the mistake is made. Form a hypothesis about WHAT the registry is actually holding on to before you change anything, then fix it so memory stays flat.

const assert = require('assert');

// Build a summary function for one batch of raw records. The caller only ever
// needs the summary line — the raw records are scratch data for this call.
function makeSummary(batchId) {
const rawData = new Array(200000).fill("raw record");
const summary = "batch " + batchId + ": " + rawData.length + " items";
return function () {
	return summary + " (of " + rawData.length + " raw)";
};
}

// A long-lived registry: the app keeps one summary per batch, forever.
const registry = [];
for (let i = 0; i < 20; i++) {
registry.push(makeSummary(i));
}

console.log(registry[0]());

// How much did those 20 summaries actually retain? Each closure should hold a
// short string and nothing else.
const megabytes = process.memoryUsage().heapUsed / 1024 / 1024;

assert.ok(
megabytes < 20,
'20 one-line summaries should retain well under 20 MB, but the heap holds ' +
	megabytes.toFixed(1) + ' MB',
);
console.log("Memory stayed flat.");

Expected output:

batch 0: 200000 items (of 200000 raw)
Memory stayed flat.

Try It Yourself

Reading about these patterns is not the same as building them. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — a memoize, a "latest call wins" reducer, and a batch splitter — and the checks below them fail until each one behaves correctly. Run it as-is and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.

Notice how the checks avoid clocks entirely. The whole point of these patterns is timing behaviour, but a timing benchmark is not reproducible — so instead the checks observe the SHAPE of the behaviour. Memoization is verified with a call counter (a correct cache runs the wrapped function once per distinct argument, not once per call); the debounce rule is modelled as a synchronous burst of data whose LAST value wins; and worker-style batching is checked by the chunks it produces. The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

const assert = require('assert');

// A call counter proves caching WITHOUT a clock: if the cache works, the wrapped
// function body runs once per distinct argument, no matter how often you call it.
let slowSquareCalls = 0;
function slowSquare(n) {
slowSquareCalls++;
return n * n;
}

// A synchronous burst of save events, modelled as plain data instead of real
// timers. Each object is one call in the burst, in the order it happened.
const burst = [
{ value: "h" },
{ value: "he" },
{ value: "hel" },
{ value: "hell" },
{ value: "hello" },
];

// TODO 1: return a memoized version of fn that caches by its single argument.
//   Calling it twice with the same n must run fn only ONCE.
//   memoize(slowSquare) -> a function; calling it twice with 4 -> slowSquareCalls === 1
function memoize(fn) {
// your code here
}

// TODO 2: collapse a synchronous burst of calls into its LAST value — the
//   debounce rule "latest call wins", modelled over data with no timers.
//   latestWins([{ value: "h" }, { value: "hello" }]) -> "hello"
function latestWins(events) {
// your code here
}

// TODO 3: split items into batches of at most size, in order, for chunked
//   processing (the shape a Web Worker consumes one batch at a time).
//   chunkArray([1, 2, 3, 4, 5], 2) -> [[1, 2], [3, 4], [5]]
function chunkArray(items, size) {
// your code here
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const fastSquare = memoize(slowSquare);
assert.strictEqual(
typeof fastSquare,
"function",
"TODO 1: memoize must return a function that wraps fn",
);
assert.strictEqual(
fastSquare(4),
16,
"TODO 1: memoized fn must still return the real result (4 -> 16)",
);
fastSquare(4);
fastSquare(4);
assert.strictEqual(
slowSquareCalls,
1,
"TODO 1: memoize must cache — slowSquare should run once for repeated arg 4, not on every call",
);
assert.strictEqual(
fastSquare(5),
25,
"TODO 1: a new argument (5) must compute a fresh result",
);
assert.strictEqual(
slowSquareCalls,
2,
"TODO 1: a new argument must run fn exactly once more (now twice total)",
);
assert.strictEqual(
latestWins(burst),
"hello",
"TODO 2: latestWins must return the last value of the burst ('hello'), the debounce winner",
);
assert.deepStrictEqual(
chunkArray([1, 2, 3, 4, 5], 2),
[[1, 2], [3, 4], [5]],
"TODO 3: chunkArray must split items into ordered batches of at most size",
);

console.log("All checks passed.");
console.log("slowSquare actually ran " + slowSquareCalls + " times (once per distinct argument)");
console.log("Latest wins:", latestWins(burst));
console.log("Batches:", chunkArray([1, 2, 3, 4, 5], 2));

Expected output:

All checks passed.
slowSquare actually ran 2 times (once per distinct argument)
Latest wins: hello
Batches: [ [ 1, 2 ], [ 3, 4 ], [ 5 ] ]

Once it passes, try two variations and predict each before running:

  1. Number vs. string key collision. Below the checks, create a FRESH memoized function — const fresh = memoize(slowSquare); — and set slowSquareCalls = 0 (the build's own checks already warmed fastSquare's cache, so you need an empty one). Now call fresh(4), then fresh(4.0), then fresh("4"), logging slowSquareCalls after. Write down how many times slowSquare actually runs — one, two or three — before running. (Output-changing: you are predicting the final call count.)
  2. Batch-size edge cases. Call chunkArray([1, 2, 3, 4, 5], 1) and then chunkArray([1, 2, 3, 4, 5], 10), and write down both returned structures before running. (Output-changing: you are predicting both returned batch structures.)

After you have written your prediction down, check it against what actually happens.

What you should have seen
  1. slowSquare runs exactly once for all three calls. The cache is a plain object, and object keys are coerced to strings, so 4, 4.0 and "4" all collapse to the single key "4". That is convenient here and a real hazard elsewhere: a cache keyed this way cannot tell the number 4 from the string "4", so a function whose answer depends on that difference would be served the wrong cached value. It is also why the lookup uses Object.hasOwn(cache, n) rather than n in cache — a plain object arrives with inherited names like toString and valueOf already visible to in, so those arguments would be served as cache hits from Object.prototype and the wrapped function would never run for them.
  2. Size 1 gives five single-item batches, [[1], [2], [3], [4], [5]]; size 10 gives one batch holding the whole array, [[1, 2, 3, 4, 5]], because slice past the end just stops at the last element rather than padding or throwing. A worker that can only process one item per message wants the first shape.

Key Takeaways

  • Always measure before optimizing: use performance.now() and run multiple iterations for accurate benchmarks
  • Debouncing delays execution until input stops, ideal for search fields and form validation
  • Throttling limits execution to once per interval, ideal for scroll and resize handlers
  • Memoization caches function results to avoid redundant computation on repeated calls
  • Lazy loading defers work until it is needed, reducing initial load time
  • requestAnimationFrame runs your update exactly once per displayed frame (60fps on a 60 Hz display), phase-aligned with the repaint, and pauses in background tabs
  • Web Workers run heavy computation in a background thread so the UI stays responsive
  • Prevent memory leaks by clearing timers, removing event listeners, and avoiding closures that capture large data unnecessarily
  • Use WeakMap and WeakSet for caches where entries should be garbage-collected when no longer referenced

Pro Tip: The biggest performance wins almost never come from micro-optimizations. They come from doing less work: fewer network requests, fewer recomputations, less rendering. Before reaching for a clever trick, ask yourself: "Can I avoid this work entirely?" Note that "less work" is not the same as "fewer calls" — as 10-dom-and-events showed, shuffling the same DOM writes into one batch changes nothing measurable, while genuinely not doing them does. Debouncing a search input from 10 requests down to 1 removes 90% of the work outright — the kind of win you rarely get from tuning the code inside a single request.

Next Steps

Now that you know how to make JavaScript fast, it is time to put everything together. The capstone project will have you build a complete application combining the core concepts you have learned so far.

Next lesson

Capstone Project: Task Manager

Build a real in-browser Task Manager: render tasks with the real DOM, persist them across reloads with localStorage, and validate a real form. The pure-logic layers still run in the embedded playground.

60 min