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. Readingarray[5000]is as fast asarray[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.
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-arrayarray.splice(i, 0, x):O(n), everything shifts.array.includes(x)orarray.indexOf(x)inside a loop:O(n²). ASetor aMapbrings it back toO(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 aMapfirst.
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.