Algorithm

The Algorithms Worth Knowing in Code Review: Cutting Big O Without Being Clever

The small set of algorithm changes that actually come up in code review, each one shown as before and after, with the Big O it removes.

4 Aug 2026

The Algorithms Worth Knowing in Code Review: Cutting Big O Without Being Clever

Most slow code I find in review is not a slow function. It is the right function called far too many times. The usual shape is a loop with a second loop hidden inside it, and the second loop is hidden because it is spelled find, includes or filter.

You do not need a data structures course to catch these. You need to notice one thing: when the body of a loop walks over data again, the cost multiplies.

flowchart diagram: A loop over n items

Here are the ones worth knowing.

Looking things up with find inside a loop

Ts
// Don't
const rows = orders.map((order) => ({
  ...order,
  customer: customers.find((c) => c.id === order.customerId),
}));

find walks the whole customer list. Doing that once per order means every order pays for every customer. A thousand orders and a thousand customers is a million comparisons for work that should take two thousand.

Ts
// Do
const byId = new Map(customers.map((c) => [c.id, c]));

const rows = orders.map((order) => ({
  ...order,
  customer: byId.get(order.customerId),
}));

Build the index once, then every lookup is instant. This is the single most common fix in this whole list.

O(n × m) becomes O(n + m).

includes on an array inside a filter

Ts
// Don't
const missing = allSkus.filter((sku) => !stockedSkus.includes(sku));

Same problem wearing different clothes. includes scans from the start every time, so this is a nested loop even though there is only one visible.

Ts
// Do
const stocked = new Set(stockedSkus);
const missing = allSkus.filter((sku) => !stocked.has(sku));

Set and Map are the two structures that pay for themselves in ordinary application code. If you are checking membership more than once, build one.

O(n × m) becomes O(n + m).

Spreading into an accumulator

Ts
// Don't
const byId = orders.reduce(
  (acc, order) => ({ ...acc, [order.id]: order }),
  {} as Record<string, Order>,
);

This looks tidy and it is quadratic. Each round copies everything collected so far, so building a table of ten thousand orders copies about fifty million properties.

Ts
// Do
const byId = new Map(orders.map((order) => [order.id, order]));

If you need a plain object because it gets serialised, build it with a normal loop and mutate the local. Mutating an object your own function created is not a purity problem, it is an implementation detail.

O(n²) becomes O(n).

Using shift as a queue

Ts
// Don't
const queue = [...tasks];
while (queue.length) {
  const task = queue.shift();
  process(task);
}

shift removes the first element, which means every remaining element moves down one position. Once the list is large, draining it this way costs far more than the work you are doing.

Ts
// Do
let head = 0;
while (head < tasks.length) {
  process(tasks[head]);
  head++;
}

Move a pointer instead of moving the data. The same applies to unshift in a loop, and to splice used to remove items while iterating: build a new array with filter in one pass instead.

O(n²) becomes O(n).

Sorting inside the loop

Ts
// Don't
for (const group of groups) {
  const ranked = [...allPlayers].sort((a, b) => b.score - a.score);
  group.leader = ranked.find((p) => p.groupId === group.id);
}

The sort does not depend on the group, so it is being redone for no reason. Anything inside a loop that does not use the loop variable can move out of it, and a sort is the most expensive thing that usually gets left behind.

Ts
// Do
const ranked = [...allPlayers].sort((a, b) => b.score - a.score);

const leaderByGroup = new Map<string, Player>();
for (const player of ranked) {
  if (!leaderByGroup.has(player.groupId)) {
    leaderByGroup.set(player.groupId, player);
  }
}

for (const group of groups) {
  group.leader = leaderByGroup.get(group.id);
}

One sort, one pass, then instant lookups.

O(n × m log m) becomes O(m log m + n).

Adding up the same numbers again and again

Ts
// Don't
function revenueBetween(days: number[], from: number, to: number) {
  let total = 0;
  for (let i = from; i <= to; i++) total += days[i];
  return total;
}

const results = ranges.map((r) => revenueBetween(days, r.from, r.to));

One range is fine. Hundreds of ranges over the same array means adding the same numbers repeatedly.

Ts
// Do
const prefix = [0];
for (const value of days) {
  prefix.push(prefix[prefix.length - 1] + value);
}

const revenueBetween = (from: number, to: number) => prefix[to + 1] - prefix[from];

const results = ranges.map((r) => revenueBetween(r.from, r.to));

Store the running total once, then any range is one subtraction. This one shows up in dashboards and reports constantly, and almost nobody reaches for it.

O(n × q) becomes O(n + q).

Recomputing a window

Ts
// Don't
// highest 7 day total
let best = 0;
for (let i = 0; i + 7 <= days.length; i++) {
  let sum = 0;
  for (let j = i; j < i + 7; j++) sum += days[j];
  best = Math.max(best, sum);
}

Each window recalculates six values it already had. Slide the window instead: add the number entering, subtract the number leaving.

Ts
// Do
let sum = 0;
for (let i = 0; i < 7; i++) sum += days[i];

let best = sum;
for (let i = 7; i < days.length; i++) {
  sum += days[i] - days[i - 7];
  best = Math.max(best, sum);
}

O(n × k) becomes O(n).

Sorting everything to get the top ten

Ts
// Don't
const topTen = [...events].sort((a, b) => b.score - a.score).slice(0, 10);

Sorting arranges a million items so you can throw away nine hundred and ninety thousand nine hundred and ninety of them. It also copies the whole array first.

Ts
// Do
function topK<T>(items: T[], k: number, score: (item: T) => number): T[] {
  const best: T[] = [];
  for (const item of items) {
    if (best.length < k) {
      best.push(item);
      best.sort((a, b) => score(b) - score(a));
    } else if (score(item) > score(best[k - 1])) {
      best[k - 1] = item;
      best.sort((a, b) => score(b) - score(a));
    }
  }
  return best;
}

Keep only the best ten seen so far. A real heap is better again, but for a small k this version is simple, readable, and enough. For a list of a few hundred, leave the sort alone.

O(n log n) becomes O(n log k).

Scanning a sorted array

Ts
// Don't
const slot = slots.find((s) => s.startsAt >= wantedTime);

If slots is already sorted by time, walking from the front wastes the ordering you paid for.

Ts
// Do
function firstAtOrAfter(slots: Slot[], time: number): Slot | undefined {
  let low = 0;
  let high = slots.length;
  while (low < high) {
    const mid = (low + high) >> 1;
    if (slots[mid].startsAt >= time) high = mid;
    else low = mid + 1;
  }
  return slots[low];
}

The catch is that sorting to enable one binary search is a loss. This pays when the data is already sorted, or when you search it many times.

O(n) becomes O(log n).

Recursion with no memory

Ts
// Don't
function ways(n: number): number {
  if (n <= 1) return 1;
  return ways(n - 1) + ways(n - 2);
}

Every call splits into two more, and the same values get computed thousands of times. This is the one on the list that goes from fine to frozen with no warning, because the cost doubles with each extra step.

Ts
// Do
function ways(n: number): number {
  const memo = new Map<number, number>([
    [0, 1],
    [1, 1],
  ]);

  const go = (k: number): number => {
    const hit = memo.get(k);
    if (hit !== undefined) return hit;
    const value = go(k - 1) + go(k - 2);
    memo.set(k, value);
    return value;
  };

  return go(n);
}

Any recursion that calls itself more than once on overlapping inputs wants a cache. Tree walks, dependency graphs and path finding are where this shows up in real code, not in puzzle questions.

O(2ⁿ) becomes O(n).

One query per item

Ts
// Don't
for (const order of orders) {
  const customer = await db.customer.findUnique({ where: { id: order.customerId } });
  rows.push({ ...order, customer });
}

This is the same nested loop as the first example, except each inner step is a network round trip. It is the most expensive version of the mistake and the easiest one to see in a log.

Ts
// Do
const ids = [...new Set(orders.map((o) => o.customerId))];
const customers = await db.customer.findMany({ where: { id: { in: ids } } });
const byId = new Map(customers.map((c) => [c.id, c]));

const rows = orders.map((order) => ({ ...order, customer: byId.get(order.customerId) }));

Fetch once, index once, then join in memory. Note the Set around the ids, which removes duplicates before the query rather than after.

O(n) round trips becomes one.

Doing the work to answer yes or no

Ts
// Don't
if (users.filter((u) => u.role === "admin").length > 0) { ... }
const found = users.filter((u) => u.id === id)[0];

filter visits every element and builds an array you immediately throw away.

Ts
// Do
if (users.some((u) => u.role === "admin")) { ... }
const found = users.find((u) => u.id === id);

some and find stop at the first match. Same complexity in the worst case, much better in the normal case, and no wasted allocation.

When to leave it alone

None of this matters when the list has eight items and always will. A nested loop over a fixed menu is clearer than a Map and it will never be your problem. The useful question in review is not "is this O(n²)", it is "where does n come from". If n is a config file, ignore it. If n is rows in a table, orders in a month, or anything a customer can add to, then the nested loop is a bug with a delay on it.

The other half of the answer is to measure before you rewrite anything clever. The changes above are cheap because they make the code shorter as well as faster. When a fix makes the code harder to read, it needs a number behind it, not an argument about complexity.


I write about system design and the senior-to-staff transition every week in Monday BY Gazar on Substack, and I break down architecture and engineering decisions on Gazar Breakpoint on YouTube.

If you are an engineer targeting staff or principal and want this kind of thinking applied to your actual situation, I do 1:1 mentorship.

Go further
Live cohort on Maven

From Senior to Staff: Master the Architecture Skills That Get You Promoted

Go from shaky in design reviews to the engineer everyone trusts to architect the hard stuff.

View the live cohort

Keep reading