JAVASCRIPT / LOOPS
Nested loops and their performance cost
Predict how many times a nested loop's body runs, spot hidden inner scans, and cut n² work with hoisting, j = i + 1, and Set lookups.
What you will learn
- Compute a nested loop's cost as outer × inner body runs, not outer + inner
- Spot hidden inner loops: includes, indexOf, find, or filter called inside a loop
- Swap an inner scan for a Set or Map lookup to turn n² work into about n
- Hoist inner-invariant work up a level, and use j = i + 1 for unordered pairs
Understanding Nested loops and their performance cost
A nested loop is not two loops running side by side. The inner loop is part of the outer loop's body, so it is set up, run to completion, and thrown away once per outer pass. With three rows and four columns the inner loop starts three times while its body runs twelve times, because the number of (row, column) combinations is 3 × 4, not 3 + 4. That product is the number to reason about when you ask what a nested loop costs.
Because counts multiply, cost tracks the product of the bounds, and when both bounds come from the same input the work grows as n². Doubling the input quadruples the work; a third level makes it n³, so an eight-fold larger input means 512 times the work. The version that actually bites people is the inner loop they never typed: includes, indexOf, find, filter, and a lookup by scanning all walk an array from the start, so calling one inside a loop is a second loop wearing a method name.
Two fixes exist and they are not interchangeable. Hoisting work that does not depend on the inner variable up one level, or starting the inner index at i + 1 when a pair (a, b) means the same as (b, a), strips out a constant factor: twelve calls become three, thirty-six comparisons become fifteen. Building a Set or Map once before the outer loop changes something deeper, because each lookup stops depending on array length at all, so n² collapses to roughly n. That is the difference between a page that stalls at large input and one that does not.
const rows = ['a', 'b', 'c'];
const cols = [1, 2, 3, 4];
let innerStarts = 0; // how often the inner loop is set up
let bodyRuns = 0; // how often the innermost body runs
const pairs = [];
for (let i = 0; i < rows.length; i++) {
innerStarts++;
for (let j = 0; j < cols.length; j++) {
bodyRuns++;
pairs.push(rows[i] + cols[j]);
}
}
console.log(pairs.join(' '));
console.log('inner loop started', innerStarts, 'times');
console.log('body ran', bodyRuns, 'times =', rows.length, '*', cols.length);Nesting multiplies iteration counts, so the innermost body runs outer × inner times, and that product, not the number of loops you typed, is the cost.
Worked examples
Quadratic pair search versus a Set pass
Counts the comparisons a nested duplicate search makes against the visits a single pass with a Set needs.
const ids = [4, 7, 4, 9, 7, 1];
let comparisons = 0;
const nestedDupes = [];
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
comparisons++;
if (ids[i] === ids[j] && !nestedDupes.includes(ids[i])) {
nestedDupes.push(ids[i]);
}
}
}
console.log('nested:', nestedDupes.join(','), 'in', comparisons, 'comparisons');
let visits = 0;
const seen = new Set();
const setDupes = new Set();
for (let i = 0; i < ids.length; i++) {
visits++;
if (seen.has(ids[i])) setDupes.add(ids[i]);
seen.add(ids[i]);
}
console.log('set:', [...setDupes].join(','), 'in', visits, 'visits');
let big = 0;
for (let i = 0; i < 1000; i++) {
for (let j = i + 1; j < 1000; j++) {
big++;
}
}
console.log('same nested search over 1000 ids:', big, 'comparisons');Example explained
Line 1j = i + 1 starts the inner loop past i, so each unordered pair is compared once: 6 * 5 / 2 = 15 instead of 36.
Line 2The Set pass touches each id exactly once, so visits equals ids.length and no comparison count depends on position.
Line 3nestedDupes.includes(...) in the inner body is itself a scan, so the nested version hides work the counter never records.
Line 4At 1000 ids the pair count is 499500 while the Set pass is still 1000 visits: halving a quadratic loop does not change its shape.
Hoisting a call out of the inner body
Shows that work depending only on the outer index is repeated outer × inner times until you move it up a level.
let calls = 0;
function normalize(name) {
calls++;
return name.trim().toLowerCase();
}
const names = [' Ada ', ' Grace', 'Alan '];
const queries = ['ada', 'alan', 'lin', 'grace'];
let hits = 0;
for (let i = 0; i < names.length; i++) {
for (let j = 0; j < queries.length; j++) {
if (normalize(names[i]) === queries[j]) hits++;
}
}
console.log('inside inner loop: hits', hits, 'normalize calls', calls);
calls = 0;
hits = 0;
for (let i = 0; i < names.length; i++) {
const name = normalize(names[i]);
for (let j = 0; j < queries.length; j++) {
if (name === queries[j]) hits++;
}
}
console.log('hoisted one level: hits', hits, 'normalize calls', calls);Example explained
Line 1normalize(names[i]) uses only i, but sitting in the innermost body makes it run names.length * queries.length = 12 times.
Line 2Declaring const name between the two loops runs it once per outer pass, giving 3 calls and the same 3 hits.
Line 3This is a constant-factor win: the code still performs 3 × 4 = 12 comparisons, so the growth curve is untouched.
Watching n² grow
Counts innermost body runs at four input sizes to show that doubling n quadruples the work.
const sizes = [10, 20, 40, 80];
for (let s = 0; s < sizes.length; s++) {
const n = sizes[s];
let steps = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
steps++;
}
}
console.log('n =', n, 'body runs =', steps);
}Example explained
Line 1Each line is exactly n * n, because the inner loop restarts n times and each restart runs n iterations.
Line 2n grows by 2x per line but steps grow by 4x: 100, 400, 1600, 6400.
Line 3Extrapolating the same shape, n = 100 is 10,000 steps and finishes instantly, while n = 100000 is 10 billion and will not.
Line 4The outer loop over sizes is technically a third level, but sizes.length is fixed at 4, so it only multiplies the total by 4 rather than by n.
Important notes
Nesting is not a defect by itself. A 3 × 4 grid is 12 steps and an 8 × 8 board is 64; the cost only matters when the bounds grow with the input.
Timings from performance.now() jump around because of JIT warm-up and garbage collection, while a counter in the innermost body is reproducible and reveals the growth rate, which is what you need to compare two shapes.
Common mistakes
Letting both levels share one counter, so the inner loop assigns to the outer i instead of declaring its own j: the inner loop leaves i at its own bound, and the outer loop then either quits after a single pass or resets to the same index forever.
Treating arr.includes(x) or arr.indexOf(x) inside a loop as one step: it scans from index 0, so a 5,000-item lookup inside a 5,000-item loop is up to 25 million comparisons and the page stops responding.
Building the lookup structure in the wrong place, such as new Set(other) inside the outer loop: it is rebuilt on every pass, so it costs as much as the scan it was supposed to replace.
Try it yourself
Change, predict, then run
In the browser console, create const ids = Array.from({length: 2000}, (_, i) => i % 1000), predict the two numbers first, then log the comparison count from a nested duplicate search using j = i + 1 next to the visit count from one pass with a Set.
Open the JavaScript workspaceCheck your understanding
A loop over 1,000 orders calls knownCustomerIds.includes(order.customerId), where knownCustomerIds is an array of 5,000 ids. What is the cost, and what changes it?
- Up to about 5,000,000 element comparisons; building a Set from knownCustomerIds before the loop makes each check a single lookup
- About 6,000 comparisons, since includes is one operation; nothing needs changing
- About 1,000 comparisons, because the engine caches the scan once knownCustomerIds stops changing
- Up to about 5,000,000 comparisons; wrapping the includes call in a helper function called from the loop removes the nested work
Show answer
includes walks the array element by element, so 1,000 orders × up to 5,000 ids is about 5,000,000 comparisons, and a Set built once before the loop replaces each scan with a hash lookup that ignores size. Option 2 is the classic trap of counting includes as a single step when it is really the inner loop; option 4 spots the cost but only relocates the same scan, since moving code into a function does not reduce how often it runs.