Big O for the Impatient: Why Your Loop Is Slow (and When It Matters)

The page loads in 80 ms locally. In prod, on the real database, it takes 9 seconds and freezes the tab. The code did not change. The data did: 60 test rows locally, 14,000 in production.

The culprit was a ten-line loop, perfectly readable, hunting duplicates in a list. It had no bug. It just had the wrong shape. And understanding that shape is all Big O is about. No math, promise: just enough to read your own code and know which part will explode as the data grows.

Big O measures a shape, not a speed

First intuition to break: O(...) does not tell you "this takes 3 milliseconds". It tells you how the time reacts when you double the data. It is a curve shape, not a stopwatch. Three families cover the overwhelming majority of the job:

  • O(1) — constant. Data size changes nothing. Reading array[5000] is as fast as array[5].
  • O(n) — linear. Twice the data, twice the time. A loop that walks everything.
  • O(n²) — quadratic. Twice the data, four times the time. The killer class, and the one my loop had.
Three growth curves: O(1) flat, O(n) straight, O(n²) exploding amount of data (n) → time → O(1) O(n) O(n²) at small scale, all three look alike then O(n²) breaks away
The trap: on 60 test rows, all three curves are glued together. It is at production scale that O(n²) breaks away.

That is why my bug was invisible locally: at n = 60, O(n) does 60 operations and O(n²) does 3,600. The difference is microseconds, nobody sees it. At n = 14,000, O(n) does 14,000 operations, O(n²) does 196 million. That, you feel.

The loop that breaks away

The offending code, stripped to the bone: for each element, I rescan the whole list to see if it appears twice.

// ✗ O(n²): a loop INSIDE a loop
const dupes = [];
for (const a of list) {
    for (const b of list) {        // ← the rescan that costs
        if (a.id === b.id && a !== b) dupes.push(a);
    }
}

The same trap hides in a sneakier shape, the one that got me for years: includes() inside a loop. It looks like a single loop, but includes hides a second one.

// ✗ disguised O(n²): each includes() rescans the whole array
const seen = [];
for (const item of list) {
    if (seen.includes(item.id)) continue;   // includes = O(n), in a loop = O(n²)
    seen.push(item.id);
}

The fix is one word: Set. A Set (or an object, or a Map) is a hash table: checking membership with .has() is O(1), whatever the size. The double loop collapses into a single one.

// ✓ O(n): the Set answers in O(1), one pass is enough
const seen = new Set();
const dupes = [];
for (const item of list) {
    if (seen.has(item.id)) dupes.push(item);   // O(1)
    seen.add(item.id);
}

My 9 seconds dropped to 40 milliseconds. No clever optimization, no cache: just the right curve shape.

Why the Set is magic: array versus linked list

To see where that O(1) comes from, you have to open the hood of data structures. It all rests on one question: where do the elements live in memory? Memory is one long row of numbered cells.

An array stores its elements in cells glued next to each other. Since it knows the start address, it instantly computes where the 5000th lives: read in O(1). The flip side: inserting at the front forces everything else to shift over by one, so O(n). That is exactly what unshift() does in JavaScript, where push() (append at the end) is free.

A linked list does the opposite: its elements are scattered anywhere, and each holds the address of the next, like a treasure hunt. Inserting shifts nothing (O(1)), but reading the 5000th forces you to follow the arrows one by one from the start (O(n)). You will almost never write one by hand: in PHP it is SplDoublyLinkedList, in Python collections.deque, in Go container/list, and in JavaScript… you code it yourself.

A hash table (the Set, the Map, Python's dict, PHP's array) plays in another league: a function turns the key directly into a memory position. No walking, no shifting: read, write, test membership, all in O(1). That is why swapping an array.includes() for a Set.has() turns a quadratic curve into a linear one.

The hidden O(n) behind an innocent line

The real skill is not reciting these classes, it is spotting them in your own code, often tucked behind innocent syntax:

  • array.unshift(x) and a mid-array array.splice(i, 0, x): O(n), everything shifts.
  • array.includes(x) or array.indexOf(x) inside a loop: O(n²). A Set or a Map brings it back to O(n).
  • An SQL query WHERE email = ? with no index on the column: the database does a full scan, O(n) over millions of rows. The index is its version of binary search, O(log n).
  • A .find() inside a .map() to "join" two lists: again a loop in a loop. Index one of the two lists into a Map first.

Conclusion

Nobody will ask you to rewrite a hash table, your language ships one. That is not what Big O is for. It is for reading a loop and seeing its future: this one will hold at a million elements, that one will freeze the page. It is a reading skill, not a writing one.

And the irony is that AI changes nothing here, quite the opposite. An assistant happily produces an O(n²) loop that passes every test on 50 rows and dies in prod. Recognizing the curve shape in generated code is something you cannot delegate. If the topic clicks, I wrote a whole reading note on Grokking Algorithms, the book that finally reconciled me with algorithms without a single formal proof.

Comments (0)