Skip to content
Software Atlas

Big-O, Intuitively

Why a function that's instant with 100 users can take 11 seconds with 100,000: growth rates you can feel, how to read them off code (hidden loops included), amortized cost, space, and what a real stopwatch says — measured in Go and JavaScript.

⏱ ~45 min read13 sections10 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceBig-O describes how the work an algorithm does grows as its input grows. It doesn't tell you how fast code is today; it tells you what happens when the input gets 10× or 1,000× bigger.

Here is a small function you might write on your first week. It checks whether any email address appears twice in a list, by comparing every email with every email after it:

dupes.go
func hasDuplicate(emails []string) bool {
    for i := 0; i < len(emails); i++ {
        for j := i + 1; j < len(emails); j++ { // every later email
            if emails[i] == emails[j] {
                return true
            }
        }
    }
    return false
}

It passes every test. It's fast in staging, where there are 100 users. Then the product succeeds, there are 100,000 users, and the nightly job that calls it starts timing out. Here are real timings from this machine, next to a version that remembers the emails it has seen in a map:

One function, four sizes
hasDuplicate(emails) two ways, run in Go on this machine (worst case: no duplicates, so every comparison happens). Bars use a log scale: each grid step is 10× longer.
nested loops (compare every pair)10 µs
map of emails seen so far9 µs
With 100 emails both versions finish in about 10 microseconds (a microsecond, µs, is a millionth of a second). Nobody could tell them apart — which is exactly why the slow one ships.
Go 1.24, 4-core Intel Xeon VM shared with other work; single runs, rounded. JavaScript shows the same shape (§09).

Here is that fast version. It walks the list once, and for each email asks the map "have I seen you before?" — a check that takes about the same time however many emails the map holds:

dupes.go
func hasDuplicateFast(emails []string) bool {
    seen := make(map[string]bool, len(emails)) // emails seen so far
    for _, e := range emails {                 // one loop: n steps
        if seen[e] {                           // O(1) on average
            return true
        }
        seen[e] = true
    }
    return false
}

Nothing in the code changed — only the size of the input, which we call n. The nested-loop version does about n²/2 comparisons, so 1,000× more users means about 1,000,000× more work. That relationship between n and the amount of work is what Big-O notation writes down.

You don't need any maths beyond that to use Big-O well. You need to be able to look at code and say "this is the handshake shape", and know what to do about it.

What you'll learn

  1. How to count steps instead of seconds, and why constants don't matter (§02), then the common growth shapes, with a race you can drag (§03).
  2. How to read code for its Big-O, including the loops hidden inside library calls (§04), and what best/worst/average case mean (§05).
  3. Amortized cost: why appending to a slice or array is O(1) even though it sometimes copies everything (§06), and space complexity (§07).
  4. What a real stopwatch says (§08), how to fix accidental quadratic code (§09), and how complexity shows up in production systems — including attackers who exploit it (§10).
Foundations§02

Counting steps, not seconds

In one sentenceBig-O counts how many basic steps an algorithm takes for an input of size n, then keeps only the part of the formula that grows fastest.

Why not just time it?

Seconds depend on everything except the algorithm: the CPU, the language, what else the machine is doing, whether the data is already in a fast Cache close to the CPU. The same code can be 3× faster on your laptop than in CI. So instead we count steps: simple things that take roughly fixed time, like comparing two numbers, adding, or reading one item of an Array.

The count of steps as n grows is called the algorithm's time complexity. It's a property of the algorithm, so it's the same on every machine.

A worked count

Take hasDuplicate from §01 with n emails. The outer loop picks email 0 and the inner loop compares it with the n−1 emails after it; then email 1 with n−2 others; and so on, down to 0. Adding those up:

(n−1) + (n−2) + … + 1 + 0 = n(n−1)/2 = ½n² − ½n comparisons

With 100 emails that's 4,950 comparisons. With 100,000 emails it's 4,999,950,000 — about 5 billion. That one formula explains the 10 µs and the 11 seconds you saw. Here are those comparisons drawn as a grid — watch what doubling n does to it:

The handshake grid
Row i, column j: one comparison of email i with email j. hasDuplicate only compares each pair once (j after i), so it fills the triangle above the diagonal.
emails (n)
8
comparisons done
0 / 28
n(n−1)/2 = 8×7/2
Press Play to run hasDuplicate on 8 emails (none of them duplicates, so every pair gets checked). Each lit cell is one comparison.

Only the biggest term survives

Real step counts are messy. Suppose careful counting gave some function 3n² + 10n + 50 steps: 3 steps per pair, 10 steps of bookkeeping per item, 50 steps of setup. Watch what each part contributes as n grows:

n3n²10n50totalshare from 3n²
103001005045067%
1,0003,000,00010,000503,010,05099.7%
1,000,0003,000,000,000,00010,000,000503,000,010,000,05099.9997%

At small n the extras matter. At any n where performance is a question, the n² term is the whole story. Drag n yourself:

Who owns the total?
steps(n) = 3n² + 10n + 50. Drag n and watch which part of the formula the cost actually comes from.
n = 10
3n²
10n
50
3n²
66.7%
300 steps
10n
22.2%
100 steps
50
11.1%
50 steps
total
450
steps
Already the n² term is the biggest slice. The others still show up, but they're shrinking fast.

So Big-O makes two simplifications:

  1. Drop the smaller terms. 10n and 50 vanish next to 3n² as n grows.
  2. Drop the constant multiplier. 3n² and ½n² are both just O(n²). The constant depends on the machine and the language; the shape doesn't. Whatever the constant, doubling n makes n² four times bigger.

That's why hasDuplicate, at ½n² − ½n comparisons, is simply O(n²).

Foundations§03

The common shapes of growth

In one sentenceA handful of shapes cover almost all code you'll write. The quickest way to tell them apart is to ask: "what happens when n doubles?"
ClassNameWhen n doubles…Feels like
O(1)constantno changeReading arr[5]; looking up a key in a map (on average)
O(log n)logarithmicone more stepFinding a name in a phone book by opening it in the middle
O(n)linear2×Reading every page; summing a list
O(n log n)"n log n"a bit more than 2×A good general-purpose sort
O(n²)quadratic4×Everyone shakes hands with everyone at a party
O(2ⁿ)exponentialsquares (each +1 to n doubles it)Trying every combination of pizza toppings

Two more you'll meet occasionally: O(n³) (three nested loops over n, like comparing every triple of items: 8× when n doubles) and O(n!) (trying every ordering of n items — 10 items already have 3.6 million orderings).

The one that needs explaining: log n

log₂ n answers one question: how many times can you halve n before you get down to 1?

nhalvings to reach 1 (log₂ n)
83 (8 → 4 → 2 → 1)
1,000about 10
1,000,000about 20
1,000,000,000about 30

So an algorithm that throws away half of the remaining data at every step needs only about log₂ n steps. A thousand times more data costs just ten more steps. The classic example is Binary search over a sorted list:

Halving: binary search, step by step
Each check throws away half of what's left. That's where O(log n) comes from.
2
5
8
12
16
23
38
45
56
61
72
77
81
90
94
99
candidates left
16
checks so far
0
Find 72 in 16 sorted numbers. Reading them one by one could take 16 checks. Instead, look at the middle.
1 / 5

And O(n log n) is "n items, each touched about log n times". It's what good sorting algorithms cost; §04 shows exactly where it comes from.

The race

Now put them side by side. Drag n from 1 to a billion. The time labels pretend that one step takes one microsecond (µs, a millionth of a second) — slow for a modern CPU, but it makes the numbers easy to feel:

The growth race
Each bar is how many steps an algorithm of that class takes. Time labels assume one step = 1 µs (a millionth of a second). Click a row to hide it.
n = 10
At n = 10, everything is fast. Even 2ⁿ is only 1,024 steps. For small inputs, the class barely matters. Drag right.
Log scale: each 1/18 of the width is 10× more steps; bars that would run past 10^18 are clamped at the edge. Real step costs vary; this shows shape, not exact timings.

Things to notice while you drag:

  • For tiny n, nothing matters. At n = 10, every class finishes in about a millisecond or less.
  • O(2ⁿ) dies first and hardest. At n = 64 it needs more steps than there are grains of sand on Earth (by common estimates). Exponential algorithms are only usable when n stays tiny (a few dozen at most).
  • O(n²) is the one that bites in real code. It's fine at 1,000 and painful at 100,000 — the size that real tables, logs and user lists actually reach. Most "it was fine in testing" performance bugs are an accidental O(n²).
  • O(log n) barely moves. A billion items, 30 steps. That's why databases and search structures work so hard to be logarithmic (see Indexes & B-Trees).
Practitioner§04

Reading code for its Big-O

In one sentenceTo find the Big-O of code, find the loops — including the ones hiding inside function calls — and ask how many times each one runs as n grows.

Five rules that cover most code

  1. A simple statement is O(1). Assigning, comparing, adding, reading xs[i] or m[key].
  2. A loop over n items is n × (the cost of its body). A body of O(1) gives O(n).
  3. Code in sequence adds; then keep the biggest. A loop over n followed by another loop over n is 2n = O(n). An O(n) loop followed by an O(n²) one is O(n²).
  4. Nested loops multiply. A loop over n inside a loop over n is O(n²) — but only if both depend on n. If the inner loop always runs 3 times, it's 3n = O(n).
  5. A loop that halves (or doubles) its counter is O(log n). It reaches the end after about log₂ n rounds (§03).

Some inputs have two sizes. Checking each of n orders against m products is O(n × m); reading two files one after the other is O(n + m). Keep both letters when the sizes are independent — don't quietly call it n².

Recursion: draw the tree

When a function calls itself (Recursion), loops are no help. Instead, draw the tree of calls, work out how much work each level of the tree does, and multiply by the number of levels.

Merge sort is the classic example. It splits the list in half, sorts each half by calling itself, and then merges the two sorted halves:

mergesort.go
// import "cmp"

// mergeSort returns the items of xs in order (xs itself is left alone).
func mergeSort[T cmp.Ordered](xs []T) []T {
    if len(xs) <= 1 {
        return xs // 0 or 1 items: already sorted
    }
    mid := len(xs) / 2
    left := mergeSort(xs[:mid]) // sort each half the same way…
    right := mergeSort(xs[mid:])
    return merge(left, right)   // …then merge: O(n) on each level
}

func merge[T cmp.Ordered](a, b []T) []T {
    out := make([]T, 0, len(a)+len(b))
    for len(a) > 0 && len(b) > 0 {
        if a[0] <= b[0] { // take the smaller front item
            out, a = append(out, a[0]), a[1:]
        } else {
            out, b = append(out, b[0]), b[1:]
        }
    }
    out = append(out, a...) // one side may have leftovers
    return append(out, b...)
}

Step through it on 8 numbers and count:

Where n log n comes from: merge sort's recursion tree
Splitting goes down, merging comes back up. Count the work per level, then count the levels.
3827433982105
top
levels
0 of log₂ 8 = 3
items touched by merging
0
n × log₂ n = 8 × 3 = 24
Merge sort on n = 8 numbers. The plan: split the list in half, sort each half (the same way, by calling itself — that's recursion), then merge the two sorted halves.
1 / 7

Every level of the tree touches all n items once while merging, and there are log₂ n levels, so the total is n × log₂ n. That's the "n log n" of good sorts. (Go's slices.Sort and JavaScript's Array.prototype.sort use different, more refined algorithms, but they're O(n log n) too.)

Hidden loops: the bugs you can't see

A single line can contain a whole loop. Every method below scans its input from the start, so calling it once per item of a loop turns O(n) into O(n²):

Looks like one stepWhat it actually doesCost
Go slices.Contains(s, x), slices.Indexchecks items one by oneO(n)
Go strings.Contains(s, sub), strings.Indexsearches through the stringO(len(s)), roughly
JS arr.includes(x), arr.indexOf(x), arr.find(…)checks items one by oneO(n)
JS arr.shift(), arr.unshift(x)moves every other item one slotO(n)
JS [...arr, x], arr.concat(…), arr.slice()copies the whole arrayO(n)
Go slices.Insert, slices.Delete in the middleshifts the items after itO(n)
JS Object.keys(obj).lengthbuilds a list of all keysO(n)

On their own, each is fine. The bug is putting one inside a loop over the same data. That's the shape to train your eye for:

// Looks like one loop. It's two.
const allowed = users.filter((u) => !bannedIds.includes(u.id)); // ← includes scans bannedIds every time

Measured on Node 22 with 100,000 ids in each list, this pattern took about 12 seconds; the fixed version (§09) took about 30 ms.

Your turn: what's the Big-O?

Answer before you click. Each explanation says which rule applies.

QUESTION 1 / 7
A loop with a small inner loop
function stars(rows) {
  for (let i = 0; i < rows.length; i++) {   // n times
    for (let j = 0; j < 3; j++) {           // always 3 times
      console.log(rows[i], "*");
    }
  }
}
QUESTION 2 / 7
A loop that doubles
func countDoublings(n int) int {
    steps := 0
    for i := 1; i < n; i *= 2 { // i = 1, 2, 4, 8, 16, …
        steps++
    }
    return steps
}
QUESTION 3 / 7
filter + includes
// users and bannedIds both have about n items
const allowed = users.filter((u) => !bannedIds.includes(u.id));
QUESTION 4 / 7
Two loops, one after the other
func minMax(xs []int) (int, int) {
    lo := xs[0]
    for _, x := range xs { lo = min(lo, x) } // n steps
    hi := xs[0]
    for _, x := range xs { hi = max(hi, x) } // n more steps
    return lo, hi
}
QUESTION 5 / 7
A queue drained with shift()
const queue = Array.from({ length: n }, (_, i) => i);
while (queue.length > 0) {
  const job = queue.shift(); // take the first item
  handle(job);               // assume handle is O(1)
}
QUESTION 6 / 7
Recursion that calls itself twice
function fib(n) {
  if (n < 2) return n;
  return fib(n - 1) + fib(n - 2); // two calls, each almost as big as n
}
QUESTION 7 / 7
The triangle loop
for i := 0; i < n; i++ {
    for j := i + 1; j < n; j++ { // starts after i: fewer steps each round
        compare(xs[i], xs[j])
    }
}
Answered 0 / 7
Practitioner§05

Best, worst and average case (and O vs Θ vs Ω)

In one sentence"Its Big-O" isn't always one answer. The same code can be fast on one input and slow on another, so say which case you mean: best, worst or average.

The simple example: searching a list

Looking for a value by checking items from the start:

  • Best case: O(1). It's the first item.
  • Worst case: O(n). It's the last item, or not there at all.
  • Average case: O(n). If it's equally likely to be anywhere, you check about n/2 items — still linear.

The best case is rarely useful ("my search is O(1) if you're lucky"). The worst case is the usual default because it's a promise: it can't get slower than this. The average case matters when the worst case is rare — which is the story of the next two examples.

Quicksort: a great average with a nasty worst case

Quicksort picks one item, the pivot, moves smaller items to its left and bigger ones to its right, then sorts each side the same way. Here's the textbook version that always uses the first item as the pivot:

quicksort.go
func quicksort[T cmp.Ordered](a []T) {
    if len(a) < 2 {
        return
    }
    pivot, p := a[0], 0 // ← first item as the pivot: the weak spot
    for i := 1; i < len(a); i++ {
        if a[i] < pivot { // move smaller items to the front
            p++
            a[p], a[i] = a[i], a[p]
        }
    }
    a[0], a[p] = a[p], a[0] // put the pivot between the two groups
    quicksort(a[:p])        // everything smaller than the pivot
    quicksort(a[p+1:])      // everything bigger
}

On random input, the pivot lands somewhere near the middle, so each split roughly halves the data: log n levels of n work, O(n log n), the same tree as merge sort.

On input that's already sorted, the first item is the smallest. Nothing is smaller than it, so the "smaller" side is empty and the "bigger" side has n−1 items. Every level peels off just one item: n levels of up to n work, O(n²). Measured on this machine:

Sorting 100,000 ints in Go (this machine, Go 1.24)
textbook quicksort, random order~6 ms
textbook quicksort, already sorted~2.1 s
slices.Sort, already sorted~0.08 ms
Same function, same 100,000 numbers: 340× slower because of the order they arrived in. The standard library's slices.Sort (pattern-defeating quicksort) picks its pivots more cleverly and notices already-sorted runs, so sorted input is its best case, not its worst.

Sorted input isn't exotic. It's what you get when you re-sort a list that barely changed, or sort rows that came out of a database index (which already returns them in order). That's why real libraries never use a naive pivot: Go uses pdqsort, and V8 uses TimSort for Array.prototype.sort. Both guarantee O(n log n) in the worst case.

Hash maps: O(1) on average

A Hash map lookup is O(1) on average: a Hash function turns the key into a slot number and you jump straight there. In the worst case, if many keys land on the same slot, the map has to check them one by one, and a lookup becomes O(n). Real maps keep this rare with good hash functions and by growing as they fill up; Hash Maps shows how. When people say "maps are O(1)", they mean the average.

O, Ω and Θ in plain words

You'll see three symbols. All three describe how a cost grows — they just put the bound in different places:

SymbolRead asMeansExample
O(g)"big O"grows at most like g — a ceilingLinear search is O(n)
Ω(g)"big omega"grows at least like g — a floorAny sort must at least look at every item: Ω(n)
Θ(g)"big theta"grows exactly like g — ceiling and floorSumming a list is Θ(n): never less, never more

A ceiling can be loose. Linear search is technically also O(n²) — "at most n²" is true, just useless. Θ removes that slack. In everyday engineering talk, people say "O" but mean the tight Θ bound, and this page does the same.

Practitioner§06

Amortized cost: why append is O(1)

In one sentenceSome operations are usually cheap and occasionally expensive. Amortized cost is the average over a whole sequence of them — and for appending to a growable array, that average is a constant, even though one append can copy everything.

The problem: arrays can't stretch

An Array is one block of memory with a fixed size. A Go Slice or a JavaScript array feels growable, but underneath it's a fixed-size array with some spare room. The number of slots is its capacity; the number in use is its length.

While there's spare room, an append just writes into the next free slot: O(1). When the array is full, the runtime has to allocate a bigger array and copy every existing item into it before writing the new one. That single append is O(n). (The memory side of this — the allocations it causes — is covered in Stack vs Heap §09.)

So is append O(1) or O(n)? Both, for different appends. The question that matters for a loop is: what does a long run of appends cost in total?

The doubling trick

Suppose the array doubles its capacity whenever it fills up. Walk through 16 appends starting from empty: it grows at appends 1, 2, 3, 5, 9 (to capacities 1, 2, 4, 8, 16), copying 0 + 1 + 2 + 4 + 8 = 15 items in total. Add the 16 writes and the whole run costs 31 steps: under 2 per append.

That isn't luck. Each copy is half the size of the next one, so all the copies so far add up to less than the current size: 1 + 2 + 4 + … + n/2 < n. n appends cost less than n writes plus n copies — under 2n steps, so O(1) each, on average. Try it, then flip the toggle to a policy that grows by a fixed +4 slots instead:

Appending to a growable array
Cost of one append = 1 (write the item) + the number of items copied if the array had to grow. Deterministic: no randomness.
backing array · capacity 0filled = in use
cost of each append (last 0)tall bar = a copy
no appends yet
appends
0
items copied (total)
0
average cost / append
—
stays under 2
bank (3 coins per append)
0
never negative
An empty slice with capacity 0. Press Append 1 a few times. Each append costs 1 (write the item) — plus, when the array is full, the cost of copying every existing item into a new, bigger array.
Real Go doubles up to 256 items, then the factor shrinks gradually from ×2 towards ×1.25 as the slice gets bigger, and sizes are rounded up to what the memory allocator likes; V8 grows JS arrays to about ×1.5 + 16. Any fixed ratio gives O(1) amortized; a fixed amount doesn't.

The rule to remember: grow by a ratio (×2, ×1.5, ×1.25), never by a fixed amount. Any ratio above 1 gives O(1) amortized; a smaller ratio just means more frequent, smaller copies. With growth factor g, each item gets copied on average about 1/(g−1) times: once for ×2, four times for ×1.25.

What Go and V8 actually do

Here's Go's real growth for a slice of ints, on Go 1.24:

grow.go
var s []int
for i := 0; i < 2000; i++ {
    before := cap(s)
    s = append(s, i)
    if cap(s) != before { // append had to grow the array
        fmt.Printf("append #%d: cap %d → %d, copied %d\n", i+1, before, cap(s), len(s)-1)
    }
}
append #1: cap 0 → 1, copied 0
append #2: cap 1 → 2, copied 1
append #3: cap 2 → 4, copied 2
append #5: cap 4 → 8, copied 4
…
append #257: cap 256 → 512, copied 256
append #513: cap 512 → 848, copied 512
append #849: cap 848 → 1280, copied 848
append #1281: cap 1280 → 1792, copied 1280
append #1793: cap 1792 → 2560, copied 1792

Go doubles while the capacity is below 256. Past that, each growth adds (cap + 768) / 4, so the factor shrinks gradually from ×2 towards ×1.25 as the slice gets bigger — you can see ×1.66, ×1.5, ×1.4 above, and by 100,000 items it's about ×1.25. Then the size is rounded up to one its memory allocator likes (512 + 320 = 832 became 848). That's nextslicecap in Go 1.24's runtime/slice.go. These details can change between versions — but it's always a ratio. Counting copies for 1,000,000 appends, we got 4,154,015: about 4.2 copies per append — close to the 1/(1.25−1) = 4 that the "about 1/(g−1)" rule predicts for a slice that has mostly grown at ×1.25. A constant.

V8, the JavaScript engine in Chrome and Node, grows an array's backing store to about 1.5× the new length, plus 16 (checked on Node 22 by inspecting the array's internals; also an engine detail). So push is O(1) amortized too.

Amortized is not the same as average

  • Amortized is a guarantee. It holds for every sequence of operations, with no luck involved. Any n appends cost O(n) total.
  • Average case (§05) is about typical inputs. A hash map is O(1) on average, but a bad set of keys can make it slower.

And amortized O(1) still means one append in a while is slow. In a batch job nobody notices. In code that must answer within microseconds every time, the occasional big copy shows up as a latency spike. When you know the final size, skip the copies entirely:

out := make([]Item, 0, len(rows)) // length 0, capacity len(rows): never grows
for _, r := range rows {
    out = append(out, toItem(r))
}

The same idea appears all over: Go's strings.Builder grows its buffer by a ratio, hash maps double their table when they get too full (Hash Maps), and a queue built on a slice (§09) moves items only occasionally. Each one is "usually O(1), sometimes O(n), O(1) amortized".

Practitioner§07

Space complexity: memory counts too

In one sentenceBig-O also measures memory: how much extra space an algorithm needs as n grows. Often you can trade one for the other — spend O(n) memory to save O(n²) time.

Space complexity usually counts only the extra memory an algorithm uses on top of its input — sometimes called auxiliary space. Reading a list you were given costs nothing extra; building a second list of the same size costs O(n).

Three places extra memory comes from

1. Copies and new collections. Building a result of n items is O(n) space. So is a lookup table: the map in §01's fast hasDuplicate holds up to n emails. That's the trade it made — O(n) extra memory to go from O(n²) to O(n) time. Usually a great deal; occasionally (huge n, tight memory) not.

2. In place vs copying. An algorithm that rearranges the input inside its own memory is called in place and needs only O(1) extra space:

// O(n) extra space: a whole second slice.
func reversedCopy(xs []int) []int {
    out := make([]int, len(xs))
    for i, x := range xs {
        out[len(xs)-1-i] = x
    }
    return out
}

// O(1) extra space: swap the ends, walk inwards.
func reverseInPlace(xs []int) {
    for i, j := 0, len(xs)-1; i < j; i, j = i+1, j-1 {
        xs[i], xs[j] = xs[j], xs[i]
    }
}
// The standard library has the in-place one: slices.Reverse(xs)

Both are O(n) time. They differ in memory — and in behaviour: the in-place version changes data the caller might still be using. In JS, sort() and reverse() work in place, while the newer toSorted() and toReversed() return copies.

3. The call stack. Every function call that hasn't returned yet keeps a Stack frame on the Call stack (see Stack vs Heap). So recursion uses memory proportional to how deep it goes, even if it never builds a single collection.

Recursion depth: the hidden O(n)

Merge sort's tree (§04) is only log₂ n levels deep, so it never has more than about log n calls waiting at once. Quicksort's depth depends on its pivots (§05):

How deep does the call stack get? Quicksort on 16 items
random input (good splits)
sort(16)
sort(8)
sort(4)
sort(2)
sort(1)
depth ≈ log₂ n → O(log n) space
sorted input (worst splits)
sort(16)
sort(15)
sort(14)
sort(13)
sort(12)
sort(11)
sort(10)
sort(9)
sort(8)
sort(7)
sort(6)
sort(5)
sort(4)
sort(3)
sort(2)
sort(1)
depth = n → O(n) space
Each box is one call still waiting on the stack (one stack frame) at the deepest moment; sort(8) means a quicksort call working on 8 items. With balanced splits the stack is only 4 or 5 frames deep (log₂ 16 = 4 halvings, plus the call that reaches 1 item). On already-sorted input, the textbook first-element pivot splits off just one item each time, so the stack grows to n frames: O(n) extra space, and on JavaScript's default stack roughly 10,000 frames is enough to crash.

That's not a theoretical worry. Our textbook JavaScript quicksort from §05, given 10,000 already-sorted numbers, crashed before it could get slow:

RangeError: Maximum call stack size exceeded

On Node 22 with default settings, a simple recursive function gets roughly 10,000 calls deep before hitting that error (we saw about 9,600 on this machine; the exact limit varies with the Node version, the settings and how big each frame is). Go grows goroutine stacks on demand up to a 1 GB maximum, so the same Go quicksort didn't crash on 100,000 sorted items — it just took 2 seconds and used O(n) stack. Real quicksorts recurse into the smaller side first and loop over the bigger one, which caps the depth at O(log n) whatever the input.

Practitioner§08

Theory meets the stopwatch

In one sentenceBig-O predicts the shape of the curve. A stopwatch tells you the actual numbers — including effects Big-O deliberately ignores, like CPU caches and constant factors.

Benchmarking across n

A single timing tells you little about growth. Time the same code at several sizes, each double the last. In Go, a Benchmark with one sub-benchmark per size does exactly that:

search_test.go
// imports: fmt, math/rand/v2, slices, testing
var sink bool // results go here so the compiler can't skip the work

func BenchmarkContains(b *testing.B) {
    for _, n := range []int{1_000, 2_000, 4_000, 8_000} {
        s := make([]int, n)
        for i := range s {
            s[i] = rand.IntN(1 << 40)
        }
        b.Run(fmt.Sprint(n), func(b *testing.B) { // one sub-benchmark per size
            for b.Loop() { // runs the body as many times as needed to time it
                sink = slices.Contains(s, -1) // -1 is never there: the worst case
            }
        })
    }
}
$ go test -bench .
BenchmarkContains/1000-4     4101075     278.6 ns/op
BenchmarkContains/2000-4     2142190     548.7 ns/op
BenchmarkContains/4000-4     1000000      1064 ns/op
BenchmarkContains/8000-4      565812      2123 ns/op

Read it as a doubling test: n doubles each line, and the time goes 279 → 549 → 1,064 → 2,123 ns. ×2 each time: linear, O(n). If it went ×4 you'd have O(n²); ×1 (flat) would be O(1); "+ a few ns" would be O(log n).

Five classes, measured

We ran the same kind of benchmark for five operations, from a thousand to a million items. On log-log axes (both axes on a log scale), each class becomes a straight line whose steepness is the power of n: flat for O(1), slope 1 for O(n), slope 2 for O(n²).

Measured: five classes, one machine
Go 1.24 on this machine, best of several runs. One call each: a single lookup, one full scan, one full sort, one all-pairs check. Both axes are log scales. Hover or tap a point.
1 ns1 µs1 ms1 s1k4k16k64k256k1Mn (each step right = 2× more items)time per call
all pairs (nested loops), n = 64,000: 532 ms — 4.1× the time at n = 32,000. 4× per doubling — n². Twice the input, four times the time. 64k items took over half a second; a million would take ~2 minutes.

Flip to Time ÷ formula. This is how you fit the curve: divide each measurement by the formula you suspect (n, n log₂ n, n²…) and see whether the result stays flat. For the sort and the all-pairs loop it stays almost perfectly flat — the formula explains the growth completely. For the map and binary search it doesn't, and that's the interesting part.

When Big-O and the stopwatch disagree

"O(1)" isn't one fixed time. The map lookup went from about 5 ns at a thousand keys to about 40 ns at a million: same number of steps, but at a million keys the map no longer fits in the CPU's fast caches, so most steps wait for main memory. Big-O counts steps; it doesn't know that some steps cost 1 ns and others 100 ns. CPU Caches explains why, and how much.

For small n, constants win. Here's an O(n) linear scan of a []int against an O(log n) lookup in a binary search tree (one separately allocated node per key; see Trees & Tries), measured on this machine:

keyslinear scan, O(n)tree lookup, O(log n)
8~8 ns~9 ns
32~16 ns~16 ns
128~28 ns~25 ns
512~78 ns~33 ns
2,048~277 ns~45 ns

Up to about a hundred keys, the "worse" algorithm ties: scanning a few cache lines in order is extremely fast, while each tree step follows a Pointer to somewhere else in memory. Only as n grows does the better shape take over — and then it wins by more every time n doubles. (CPU Caches §10 has the same experiment against a map.)

Measuring in JavaScript

There's no built-in benchmark runner, but the same doubling test works with performance.now(). Do a warm-up run first, because the engine compiles hot functions to faster machine code only after they've run a while (JIT compiler):

for (const n of [1_000, 2_000, 4_000, 8_000]) {
  const xs = Array.from({ length: n }, (_, i) => i);
  work(xs); // warm-up: let the JIT optimize it
  const t = performance.now();
  for (let r = 0; r < 100; r++) work(xs);
  console.log(n, ((performance.now() - t) / 100).toFixed(3), "ms");
}

Whatever the language, measure at realistic sizes and several of them, run each more than once, and be suspicious of one-off numbers: this machine was shared with other work, and individual runs varied by up to 2×. For Go, Stack vs Heap §08 covers -benchmem, -count and comparing runs properly.

Practitioner§09

Fixing accidental quadratic code

In one sentenceNearly every accidental O(n²) is a loop with a hidden O(n) inside it. The fix is almost always the same: do the expensive part once, outside the loop, or switch to a structure that makes the inner step O(1).

1. Looking things up in a list, inside a loop

The most common one by far. For each item, search another list:

// Keep the users who are NOT banned (users: []User, banned: []int).

// O(n × m): slices.Contains scans banned for every user.
var allowed []User
for _, u := range users {
    if !slices.Contains(banned, u.ID) { // ← hidden loop
        allowed = append(allowed, u)
    }
}

// O(n + m): build a set once, then each check is O(1) on average.
set := make(map[int]struct{}, len(banned))
for _, id := range banned {
    set[id] = struct{}{}
}
var allowedFast []User
for _, u := range users {
    if _, ok := set[u.ID]; !ok {
        allowedFast = append(allowedFast, u)
    }
}
Matching 100,000 ids against 100,000 ids (Node 22, this machine)
filter + includes~12 s
Set, then filter + has~29 ms
The Set version builds its table in one pass, then does 100,000 cheap lookups. Same result, ~400× faster. The exact same shape in Go is the hasDuplicate example from §01.

When you need to keep more than presence — group orders by customer, count words — the same idea applies with a map from key to value. If both lists are already sorted, walking them side by side with two indices is also O(n + m), with no extra memory.

2. Building a string with += in Go

Go strings can't be changed in place. So s += "line\n" allocates a new string and copies all of s into it, every time: the k-th step copies about k lines, which adds up to O(n²) bytes copied.

// O(n²): each += copies everything built so far.
s := ""
for i := 0; i < n; i++ {
    s += "line\n"
}

// O(n): strings.Builder appends into a growing buffer (amortized, §06).
var b strings.Builder
for i := 0; i < n; i++ {
    b.WriteString("line\n")
}
s := b.String()

Measured with n = 100,000: Go's += took ~8.8 seconds; strings.Builder took ~0.7 ms. In Node 22, += with a million pieces took ~190 ms and join ~90 ms, because V8 represents concatenations as a rope instead of copying each time. That's an engine optimization, not a language guarantee — so in JS it's fine to use +=, but join is the safe habit for big outputs.

3. Using a JS array as a queue with shift()

arr.shift() removes the first item, and every other item has to move down one slot: O(n). Draining a big array with it is O(n²). Measured on Node 22: 100,000 shifts took ~0.7–1.2 s; reading the same items by moving an index took ~5 ms.

The fix: keep a head index, and only once in a while throw away the used-up front part:

// Queue is a FIFO queue on a slice. Pop moves a head index
// instead of shifting every item.
type Queue[T any] struct {
    items []T
    head  int // index of the next item to pop
}

func (q *Queue[T]) Push(v T) { q.items = append(q.items, v) }

func (q *Queue[T]) Pop() (T, bool) {
    var zero T
    if q.head == len(q.items) {
        return zero, false // empty
    }
    v := q.items[q.head]
    q.items[q.head] = zero // drop the reference so the GC can free it
    q.head++
    if q.head > 1024 && q.head*2 > len(q.items) { // mostly dead space: compact
        n := copy(q.items, q.items[q.head:])
        clear(q.items[n:])
        q.items = q.items[:n]
        q.head = 0
    }
    return v, true
}

Compaction copies only when at least half the slice is dead space, so each item is copied at most a few times over its life: O(1) amortized per operation, the same argument as §06. For a fixed-size queue, a Ring buffer avoids even that.

4. Copying inside a loop

Anything that builds a fresh copy is O(n), so doing it once per item is O(n²):

let result = [];
for (const row of rows) {
  result = [...result, transform(row)]; // ← copies all of result, every time
}
// fix: result.push(transform(row)) — or just rows.map(transform)

Measured on Node 22: 10,000 rows took ~110 ms with the spread and ~0.2 ms with push.

At 50,000 rows the spread version took about 10 seconds — roughly 100× the time for 5× the rows, when O(n²) predicts 25×. The number of item copies did grow exactly 25×; what changed is the cost of each copy, which we measured directly (total time ÷ items copied):

rowsitems copied (n²/2)timeper item copied
5,00012.5 million~28 ms~2.3 ns
10,00050 million~100 ms~2.0 ns
20,000200 million~1.2 s~5.8 ns
50,0001.25 billion~10 s~8.2 ns

The jump starts around 15,000 items. Around there an array's items take about 128 KB, and V8 stops storing it in its normal memory area for new objects and puts it in a separate "large object" area instead (we checked with Node's v8.getHeapSpaceStatistics(): 10,000-item arrays went to the normal area, 16,500-item ones to the large-object area). Creating each new array there is slower, and it's likely the same reason the shift() shortcut above stopped working at that size. Garbage collection — cleaning up all the discarded copies — also grew, from about 30 ms at 10,000 rows to about 1.6 s at 50,000 (measured with node --trace-gc). Lesson: Big-O gives the shape; the real cost per step can get worse as the data gets bigger, never better. The same trap in Go is slices.Insert(s, 0, x) in a loop (inserting at the front shifts everything) or append([]T{x}, s...).

5. Re-sorting or re-scanning on every step

for _, score := range incoming {
    scores = append(scores, score)
    slices.Sort(scores)                  // ← O(n log n) per item → O(n² log n) total
    fmt.Println("top:", scores[len(scores)-1])
}

If you only need the top, track the maximum as you go: O(1) per step. If you need the top k, or the smallest item over and over, a Priority queue (a heap) does each step in O(log n) — see Heaps & Priority Queues. If you need the sorted list only at the end, sort once, after the loop.

6. The database version: N+1 queries

The same shape with a network in the middle: load a list of n posts, then run one more query per post to fetch its author. Each query is a full round trip to the database and back (see Round-trip time), so the page does n + 1 round trips instead of 2. It's O(n) round trips — and one round trip (about 0.5 ms inside a data center) costs as much as hundreds of thousands of in-memory steps. N+1 Queries & ORMs covers how to spot it and the four standard fixes (a JOIN, a batched IN query, eager loading, DataLoader).

Staff-level§10

Staff-level: complexity in real systems

In one sentenceIn production, the questions are: which n does this request scale with, who controls that n, and what happens at the largest n we'll ever see?

A request's cost is the sum of its parts

Take an endpoint that returns one customer's orders. Its cost is roughly:

StepCostScales with
Parse the requestO(request size)the caller
Find the orders, with an index on customer_idO(log N + k)log of the whole table, plus k = this customer's orders
Find the orders, without an indexO(N)every order of every customer
Loop over the results, enrich each oneO(k) — or O(k) queries if it's N+1 (§09)this customer
Encode the JSON responseO(k)this customer

Add them and keep the biggest term (§02). With the index, the request is O(log N + k): it costs about the same whether the table holds a thousand orders or a billion. Without it, it's O(N): every customer's request gets slower as the whole business grows, even customers with two orders. (Indexes & B-Trees shows how the index gets that log N.)

That's the staff-level habit: a request's cost should scale with what the request asks for, not with the size of the system. When it scales with the system, it's a time bomb — the timer is your growth rate.

Tail latency lives at the big-n users

If cost is O(k) in a customer's data, most requests are cheap and a few are huge: the customer with 50,000 orders, the user following 2 million accounts. They don't move the average, but they set your p99 and your Tail latency, and they're often your most important customers.

So for every per-request loop, ask: what's the largest k anyone has, and what's the largest they could have? Then bound it: paginate, cap batch sizes, refuse inputs over a limit. A limit turns "O(n) with n unbounded" into "O(n) with n ≤ 1,000", which is effectively constant. The same thinking applies to amortized operations (§06): the average is O(1), but the one slow resize lands on somebody's request.

When the attacker chooses your worst case

§05 showed that an algorithm can be fast on typical input and slow on bad input. If outsiders control the input, assume they'll find the bad input. These are called algorithmic complexity attacks: a small request that makes your server do enormous work — a cheap denial of service.

Hash flooding. A hash map is O(1) on average and O(n) when keys collide (§05). In 2011, researchers showed that many web frameworks' maps could be flooded: send a form with a few thousand field names chosen to land in the same slot, and inserting them costs O(n²). The defense is a random seed mixed into the hash function, so attackers can't predict which keys collide. Go picks a random seed for every map, and V8 randomizes its string hashes per process. Your remaining job: cap how many keys a request can create (form fields, JSON object keys, query parameters). Hash Maps covers collisions in depth.

ReDoS (regular-expression denial of service). Most regex engines, including JavaScript's, use backtracking: on a failed match they go back and try another way to split the input. A pattern with a quantifier inside a quantifier, like (a+)+, can split n characters in about 2ⁿ ways — and on input that almost matches, it tries them all:

const re = /^(a+)+$/; // nested quantifiers: the classic ReDoS shape
for (const n of [20, 22, 24, 26, 28]) {
  const input = "a".repeat(n) + "!"; // almost matches, then fails at the end
  const t = performance.now();
  re.test(input);
  console.log(n, (performance.now() - t).toFixed(1), "ms");
}
One regex test on a short string (Node 22 vs Go 1.24, this machine)
JS, 24 a's + !~88 ms
JS, 26 a's + !~0.3 s
JS, 28 a's + !~1.3 s
Go regexp, 28 a's + !~1 µs
Go regexp, 1,000,000 a's + !~37 ms
Every 2 extra characters cost about 4× more time in JavaScript: exponential growth. Go's regexp package uses a different technique (from RE2) that guarantees time linear in the input, so it handled a million characters in ~37 ms.

At 40 characters the JavaScript version would run for over an hour, blocking Node's single thread the entire time. Real incidents have come from regexes for email validation, user agents and log parsing. Defenses: avoid nested quantifiers and overlapping alternatives ((a|a)*); limit input length before matching; and when users supply the pattern or the input is hostile, use a linear-time engine (Go's regexp, or a binding to RE2 in Node).

Choose data structures by the operations you need

Big-O's most practical use is picking the right structure up front. List the operations your code does most often, then pick the structure that makes those cheap:

You mostly need…UseCost
Look up / insert / delete by keyHash map (Go map, JS Map/Set)O(1) average
Items in sorted order, ranges ("between A and F")sorted slice (rarely changes) or balanced tree / B-tree (Trees & Tries)O(log n) find, O(k) to read a range
The smallest / largest item, over and overheap (Heaps & Priority Queues)O(log n) push/pop, O(1) peek
First in, first outqueue with a head index or ring buffer (§09)O(1) amortized
Get by position, append at the endslice / arrayO(1), O(1) amortized
Recently used items, with a size limithash map + linked list (LRU Cache)O(1)
How things connect (routes, dependencies)graph (Graphs)O(nodes + edges) to visit everything

Often you need two operations that no single structure does well — "look up by id" and "list in order". Then keep two structures in sync (a map plus a sorted slice, or a map plus a heap), paying a bit of memory for both operations being fast. An LRU cache is the classic case: a map for lookups plus a linked list that keeps the usage order.

Practitioner§11

Pitfalls gallery

In one sentenceMost Big-O mistakes aren't about maths. They're about not seeing a loop, or not asking how big n can get.
1

The loop you can't see

includes, indexOf, find, slices.Contains, strings.Contains, shift(), [...arr, x], slices.Insert at the front: each walks or copies a whole collection. Inside a loop over the same data, that's O(n²) (§04, §09).

Fix: build a Set/map once before the loop; append instead of copying; use a head index for queues.

2

Forgetting the second input

for each order, for each product is O(n × m). Someone calls it "O(n), because there are only 20 products" — until there are 20,000. Keep both letters, and write down what each one is.

Fix: name every size that can grow. If one really is bounded, write the bound in a comment and enforce it.

3

“Map lookups are O(1)”, with giant keys

A map lookup hashes the key, and hashing a string reads every byte of it: O(key length). Using a whole 1 MB JSON blob or a long URL as a key makes each "O(1)" lookup cost a million steps. Go hashes the key on every lookup; V8 caches a string's hash after computing it once, but a freshly built string still pays.

Fix: key by a short, stable id. If you need content-based keys, hash the content once and store the hash.

4

Optimizing the wrong n

Turning an O(n) scan of 10 config entries into a tree saves nanoseconds and adds bugs. Meanwhile the same request makes 3 database calls at ~0.5 ms each. Big-O compares how things grow; it doesn't say which part of today's request is slow (§08).

Fix: profile first. Use Big-O to ask "what happens at 100×?", and a profiler to ask "what's slow now?".

5

Quoting the average when the worst case is reachable

"Quicksort is O(n log n)" — except on sorted input with a naive pivot, where it's O(n²) and can overflow the stack (§05, §07). "Maps are O(1)" — except under hash flooding (§10). If users or attackers can pick the input, the worst case is the one that counts.

Fix: use standard library sorts and maps; cap input sizes; ask "who controls this input?".

6

Forgetting memory

A recursive function on a list or a deep tree uses O(depth) stack — about 10,000 frames is enough to crash default Node (§07). Chained map().filter().map() builds a whole new array at each step: O(n) time each, but also O(n) memory each, at the same moment.

Fix: convert deep recursion to a loop with an explicit stack; for huge data, process it in one pass or in chunks.

7

Benchmarking at one size

One timing at n = 100 says nothing about growth: §01's two versions were identical there. And at small n, constant factors and caches decide the winner (§08).

Fix: benchmark at several sizes, each double the last, up to the largest n production will see. ×2 per doubling is O(n); ×4 is O(n²).

8

Assuming amortized O(1) means every call is fast

append, push, map inserts and strings.Builder are O(1) amortized, but once in a while one call copies everything (§06). In a latency-sensitive loop, that one call is your spike.

Fix: preallocate when you know the size: make([]T, 0, n), make(map[K]V, n), b.Grow(n).

9

O(N) hiding in the database

A query that filters on a column without an index reads the whole table: O(N) in the table size, on every request. It's fast at launch and slow a year later, and no line of your Go or JS code looks wrong (§10).

Fix: check the query plan for the queries on your hot paths; see Indexes & B-Trees.

Practitioner§12

Check yourself

Predict the answer before you click. Q1 is covered in §02, Q2 in §09, Q3 in §03, Q4 in §08, Q5 in §06 and Q6 in §10.

QUESTION 1 / 6
Twice the input

A report runs an O(n²) algorithm. With 10,000 customers it takes 1 second. Roughly how long with 20,000?

QUESTION 2 / 6
Spread in a loop

rows has n items and transform is O(1). What's the complexity?

let result = [];
for (const row of rows) {
  result = [...result, transform(row)];
}
QUESTION 3 / 6
A billion sorted items

Binary search over 1,000,000,000 sorted numbers. At most how many checks?

QUESTION 4 / 6
The slow O(1)

Measured on this machine: a Go map lookup took ~5 ns with 1,000 keys and ~40 ns with 1,000,000 keys. Is the map broken?

QUESTION 5 / 6
Appending a million items

You append 1,000,000 ints to a nil Go slice, one at a time. About how many element copies does growing the slice cause, in total?

QUESTION 6 / 6
Two more characters

In Node, /^(a+)+$/.test("a".repeat(26) + "!") took ~0.3 s on this machine. What do you expect with 28 a's?

Answered 0 / 6
Foundations§13

Cheat sheet

📈
Big-O = growthHow work grows with n, ignoring constants and smaller terms. 3n² + 10n + 50 is O(n²).
✖️
Double n and watchO(1) same · O(log n) +1 step · O(n) ×2 · O(n log n) a bit over ×2 · O(n²) ×4 · O(2ⁿ) hopeless.
🔁
Read the loopsSequence adds, nesting multiplies, halving gives log n, recursion = work per level × levels.
🙈
Find the hidden loopsincludes, indexOf, Contains, shift, spread, Insert: O(n) each. In a loop, O(n²).
🎲
Say which caseWorst case is the promise; average (maps, quicksort) is the typical day. Attackers pick the worst.
🏦
Amortized O(1)Amortized = averaged over many calls: most are cheap, a rare one copies everything. Grow by a ratio (×2, ×1.5, ×1.25), never by a fixed amount. Preallocate when you know n.
🧠
Memory countsExtra space: copies, maps, and recursion depth. Trading O(n) memory for O(n²) → O(n) time is usually worth it.
⏱️
Measure at several nDoubling test; divide by the formula. For small n, constant factors win, and big data that no longer fits in the CPU cache makes each step slower than Big-O assumes.
🛡️
Bound every outside nReturn results in pages, cap input sizes, add a database index for hot queries, and use a regex engine that can't blow up (Go's regexp) for hostile input.

Everyday operations

GoCostJavaScriptCost
s[i], len(s), cap(s), s[i:j]O(1)arr[i], arr.lengthO(1)
append(s, x)O(1) amortizedarr.push(x), arr.pop()O(1) amortized
s = s[1:] (drop the first)O(1)arr.shift(), arr.unshift(x)O(n) (V8 shortcuts some small arrays)
slices.Insert, slices.Delete (not at the end)O(n)arr.splice(i, …)O(n)
slices.Contains, slices.IndexO(n)includes, indexOf, findO(n)
copy, slices.CloneO(n)slice(), [...arr], concatO(n)
slices.BinarySearch (sorted)O(log n)— (write it yourself)O(log n)
slices.Sort (pdqsort)O(n log n)sort(), toSorted() (TimSort)O(n log n)
m[k], m[k] = v, delete(m, k)O(1) averagemap.get/set/has/delete, set.has/addO(1) average
len(m)O(1)map.size, set.sizeO(1)
for k := range mO(n)Object.keys(obj)O(n)
s += t in a loopO(n²) totals += t in a loopfine in V8 (it builds a rope)
strings.Builder writesO(length written), amortizedparts.join("")O(total length)
strings.Contains(s, sub)~O(len(s))str.includes(sub)~O(str.length)
container/heap Push / PopO(log n)— (write a heap)O(log n)

Reading code in 10 seconds

You seeIt's probably
One loop over the inputO(n)
A loop inside a loop, both over the inputO(n²)
A loop inside a loop over different inputsO(n × m)
i *= 2, i /= 2, lo/hi closing inO(log n)
Split in half + recurse + linear mergeO(n log n)
Recursion that calls itself twice on n−1O(2ⁿ)
A library search/copy call inside a loopO(n²) — fix it (§09)
A database query inside a loopN+1 round trips (§09)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Data Structures & AlgorithmsHash Maps
Read →