InterviewsVector
intermediateCommon7 min read · Updated Aug 23, 2026

Optimize a Slow Function: A Step-by-Step JavaScript Exercise

A hands-on JavaScript optimization exercise: take a realistic slow function and speed it up in stages — profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, and chunk off the main thread.


The problem

Here's a function that builds a report. On big inputs it freezes the tab for seconds. Make it fast — and narrate what you do and why.

This is the open-ended optimization exercise senior interviews use to watch you think. There's no single trick; there's a disciplined order. Do the steps in the wrong order — micro-tuning before fixing the algorithm — and you'll polish code that was never the bottleneck.

The one-liner: profile → fix the algorithm → cut constants → get off the main thread. In that order, measuring between each.

Here's the starting function. It takes a list of orders and a list of "active" customer ids, and returns each active customer's total spend plus a formatted label:

function buildReport(orders, activeIds) {
  const report = [];
  for (const id of activeIds) {
    // recompute the customer's orders from scratch, every id
    const mine = orders.filter((o) => o.customerId === id);
    let total = 0;
    for (const o of mine) {
      total += o.amount * currencyRate(o.currency); // pure, but called a lot
    }
    report.push({
      id,
      total,
      label: `${activeIds.length} active · ${new Date().getFullYear()} report`,
    });
  }
  return report;
}

Step 0: measure, don't guess

Before touching anything, get a baseline and find the hot path with the profiler:

performance.mark("report-start");
buildReport(orders, activeIds);
performance.mark("report-end");
performance.measure("buildReport", "report-start", "report-end");

The flame chart will show almost all the time inside that orders.filter(...) line. That points straight at the algorithm — which is where the biggest win almost always is.

Step 1: fix the algorithm (the 90% win)

For each of the A active ids, we scan all N orders with filter. That's O(A·N) — the accidental quadratic. Do one pass to group orders by customer into a Map (O(N)), then each lookup is O(1):

function buildReport(orders, activeIds) {
  const byCustomer = new Map();           // one O(N) pass instead of A scans
  for (const o of orders) {
    const list = byCustomer.get(o.customerId);
    if (list) list.push(o);
    else byCustomer.set(o.customerId, [o]);
  }
 
  const report = [];
  for (const id of activeIds) {
    const mine = byCustomer.get(id) ?? []; // O(1) lookup
    let total = 0;
    for (const o of mine) total += o.amount * currencyRate(o.currency);
    report.push({ id, total, label: `${activeIds.length} active · ${new Date().getFullYear()} report` });
  }
  return report;
}

O(A·N) → O(N + A). On 10k orders and 1k active ids that's ~10,000,000 comparisons down to ~11,000. Everything after this is polish by comparison — which is why you do it first.

Step 2: hoist invariant work out of loops

new Date().getFullYear() and activeIds.length are recomputed on every iteration but never change. Date construction in particular isn't free. Compute loop invariants once:

const year = new Date().getFullYear();
const label = `${activeIds.length} active · ${year} report`; // identical for every row

Same idea for repeated property access and .length reads in hot inner loops — read once into a local. Small individually; free to do; they add up in the tightest loops.

Step 3: cut needless allocations

Step 1 built a full array of orders per customer just to sum them. We never need the arrays — only the totals. Fold the sum into the grouping pass and allocate nothing per order:

function buildReport(orders, activeIds) {
  const totals = new Map();
  for (const o of orders) {
    const add = o.amount * currencyRate(o.currency);
    totals.set(o.customerId, (totals.get(o.customerId) ?? 0) + add); // number, not array
  }
 
  const year = new Date().getFullYear();
  const label = `${activeIds.length} active · ${year} report`;
  return activeIds.map((id) => ({ id, total: totals.get(id) ?? 0, label }));
}

Fewer allocations means less for the garbage collector to sweep — fewer GC pauses, less jank. (Beware the accidental-quadratic allocation: never rebuild a growing array with spread inside a loop.)

Step 4: memoize pure repeated calls

currencyRate(currency) is pure and called once per order, but there are only a handful of currencies — so it recomputes the same few answers thousands of times. Cache it:

import { memoize } from "./memoize"; // see /javascript/custom-memoize-function
const rate = memoize(currencyRate);   // one lookup per distinct currency, then O(1)

Memoization is the space-for-time trade again — worth it only when the function is pure, the call is hot, and the key space is small. If keys are unbounded, bound the cache with an LRU so the optimization doesn't become a leak.

Predict the outputYou've applied Step 1 (O(A·N) → O(N+A)). Which remaining change delivers the next-biggest win?
// per row, still running:
new Date().getFullYear()      // invariant
currencyRate(o.currency)      // pure, ~5 distinct inputs, called per order
report.push({ ...spread })    // per row

Step 5: get long work off the main thread

Suppose even the O(N) version is genuinely huge and blocks the UI. The fix isn't a faster algorithm — it's not blocking:

  • Chunk it. Process a slice, then yield so the browser can paint and handle input, then continue. await scheduler.yield() (or a setTimeout(…, 0) / MessageChannel fallback) breaks one 500 ms long task into many short ones, keeping the page responsive.
  • Offload it. For CPU-bound work with little DOM interaction, move it to a Web Worker — a separate thread — and post the result back. The main thread stays free for rendering and input.
async function buildReportChunked(orders, activeIds, chunk = 5000) {
  const totals = new Map();
  for (let i = 0; i < orders.length; i += chunk) {
    for (const o of orders.slice(i, i + chunk)) {
      totals.set(o.customerId, (totals.get(o.customerId) ?? 0) + o.amount * rate(o.currency));
    }
    await scheduler.yield?.() ?? new Promise((r) => setTimeout(r, 0)); // let the UI breathe
  }
  const label = `${activeIds.length} active · ${new Date().getFullYear()} report`;
  return activeIds.map((id) => ({ id, total: totals.get(id) ?? 0, label }));
}

Chunking doesn't reduce total work — it keeps the frame budget intact so the app doesn't freeze. That's often what "make it faster" actually means to a user: responsive, not merely quicker.

The meta-lesson

  1. Measure — never optimize blind.
  2. Algorithm first — O(A·N) → O(N) dwarfs every constant-factor tweak.
  3. Constants next — hoist invariants, cut allocations, memoize pure hot calls.
  4. Responsiveness last — chunk or offload so long work doesn't block the UI.
  5. Re-measure after each step, and stop when it's fast enough. Chasing wins the profiler says are gone is how "optimization" becomes unreadable code for no benefit.

Interview follow-ups to be ready for

  • "Where would you stop?" When the profiled time is under budget for the real input size. Past that, you're trading readability for gains no user perceives — premature optimization.
  • "Readability vs speed?" The O(N) map version is both clearer and faster than the nested scan — the best optimizations usually are. Reach for cleverness only when the profiler forces you to, and comment why.
  • "When a Web Worker over chunking?" Worker when the work is CPU-bound and touches little/no DOM (workers have no DOM access) and you can pay the serialization cost of postMessage. Chunking when the work must stay on the main thread but can yield.
  • "How do you keep it from regressing?" Add a performance mark or benchmark with a threshold in CI so the quadratic can't sneak back in.

  • Why interleaving DOM reads and writes forces synchronous layout, the read/write batching fix, requestAnimationFrame, and compositor-only properties.

  • How GC reachability actually works, the four classic leaks, WeakMap/WeakRef, and finding retained memory with heap snapshots.

  • Big-O reasoning the way interviewers want it: the Map/Set/object/array cheat sheet, hidden O(n) costs like spread and includes, and trading memory for speed.

  • performance.now vs Date.now, the User Timing API, PerformanceObserver, DevTools flame charts, and the micro-benchmark traps that make numbers lie.