Algorithmic complexity describes how the number of steps or the memory grows as the input grows. It is written in Big O notation: O(n) means roughly as many steps as elements, O(n²) means every element is compared with every other, O(log n) means each step discards half of the data. On 50 test records the difference is invisible; on 100,000 it turns milliseconds into seconds, and the usual fix is the right data structure — a Set or Map instead of searching an array.
After this you will be able to
- Estimate the complexity of a simple algorithm: O(1), O(log n), O(n), O(n log n), O(n²)
- Spot a nested loop hidden in includes, indexOf or find and replace it with a Set or Map lookup
- Write a binary search and check it with tests for edge cases
- Explain what Array.prototype.sort does and does not guarantee
Know beforehand
Verified with
Node.js 24.18 LTS (V8 13.6)node:test
Checked against docs: October 11, 2026
Contents
What an algorithm is and why complexity matters
An algorithm is a sequence of steps that solves a problem and gives the same result every time it is repeated. The MDN glossary adds the point that matters for this lesson: the efficiency of algorithms is compared through algorithmic complexity, or Big O notation.
A typical story: a function that finds duplicate orders worked perfectly on 50 test records, and after importing three years of data the report page started taking tens of seconds to open. The code did not change — the amount of data did. Estimating complexity lets you see such a problem before your users do.
- Time complexity — how the number of operations grows as the number of elements n grows
- Space complexity — how much extra memory the algorithm needs
- Usually the worst case is estimated: the element is missing, the array is reversed, all records are unique
Big O in plain terms: how the step count grows
Big O describes the shape of growth, not time in milliseconds. Constants and minor terms are dropped: a loop that does 3 things per element and a loop that does one are both O(n). What matters is what happens to the step count when there is 10 or 1,000 times more data.
Let's check it and count comparisons instead of time — that way the result is the same on any computer:
// We count steps — the number of comparisons — not milliseconds
const range = (n) => Array.from({ length: n }, (_, i) => i);
// O(n): in the worst case we look at every element
function linearSteps(list, target) {
let steps = 0;
for (const value of list) {
steps++;
if (value === target) break;
}
return steps;
}
// O(n²): every element is compared with every element
function pairSteps(n) {
let steps = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) steps++;
}
return steps;
}
// O(log n): each step discards half of the sorted array
function binarySteps(sorted, target) {
let low = 0;
let high = sorted.length - 1;
let steps = 0;
while (low <= high) {
steps++;
const middle = Math.floor((low + high) / 2);
if (sorted[middle] === target) break;
if (sorted[middle] < target) low = middle + 1;
else high = middle - 1;
}
return steps;
}
const sizes = [10, 1000, 100000];
console.log(sizes.map((n) => linearSteps(range(n), -1))); // [ 10, 1000, 100000 ]
console.log(sizes.map((n) => binarySteps(range(n), -1))); // [ 3, 9, 16 ]
console.log(sizes.slice(0, 2).map((n) => pairSteps(n))); // [ 100, 1000000 ]node steps.mjs. We search for the missing value -1, which is the worst case for linear search.10
- O(log n)
- ≈ 3
- O(n)
- 10
- O(n log n)
- ≈ 33
- O(n²)
- 100
1,000
- O(log n)
- ≈ 10
- O(n)
- 1,000
- O(n log n)
- ≈ 10,000
- O(n²)
- 1,000,000
100,000
- O(log n)
- ≈ 17
- O(n)
- 100,000
- O(n log n)
- ≈ 1.7 million
- O(n²)
- 10 billion
Common complexity classes with examples
O(1)
- How it grows
- Independent of n
- JavaScript example
- Reading
array[i]andarray.length, arithmetic on a single value
O(log n)
- How it grows
- Slowly: doubling n adds one step
- JavaScript example
- Binary search in a sorted array
O(n)
- How it grows
- In proportion to n
- JavaScript example
for...of,map,filter,find,includes,indexOf
O(n log n)
- How it grows
- Slightly worse than linear: n times a logarithm
- JavaScript example
- Comparison sorting — TimSort in V8, see below
O(n²)
- How it grows
- Quadratically: doubling n means four times the work
- JavaScript example
- A nested "each with each" loop;
includesinsidefilter
Map and Set come with a nuance. The specification does not name a specific complexity; it requires element access to be sublinear on average, that is, faster than O(n). MDN explains that internally this may be a hash table with O(1) or a search tree with O(log n). So the accurate statement is "set.has is on average faster than scanning", not a promise of O(1) under all conditions. For Set, MDN says plainly that has is on average faster than Array.prototype.includes on an array of the same length.
Search: linear and binary
Linear search checks elements one by one — O(n) — and works on any array. Binary search needs a sorted array, but each step looks at the middle of the remaining range and discards half of it. For 100,000 elements that means no more than 17 comparisons instead of 100,000.
/**
* Binary search in an array sorted in ascending order.
* Returns the index of the element found or -1. Complexity: O(log n).
*/
export function binarySearch(sorted, target) {
let low = 0;
let high = sorted.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const value = sorted[middle];
if (value === target) return middle;
if (value < target) low = middle + 1; // the target is to the right of the middle
else high = middle - 1; // the target is to the left of the middle
}
return -1;
}Binary search is easy to get off by one: writing low < high instead of low <= high, or forgetting the + 1. So we write tests right away with Node.js's built-in node:test module: edge cases plus a cross-check against indexOf on arrays of different lengths.
import { test } from "node:test";
import assert from "node:assert/strict";
import { binarySearch } from "./binary-search.mjs";
const prices = [490, 900, 1200, 1500, 2300, 4100, 7800];
test("finds the first, middle and last element", () => {
assert.equal(binarySearch(prices, 490), 0);
assert.equal(binarySearch(prices, 1500), 3);
assert.equal(binarySearch(prices, 7800), 6);
});
test("returns -1 when the element is missing", () => {
assert.equal(binarySearch(prices, 100), -1); // smaller than all
assert.equal(binarySearch(prices, 1000), -1); // between neighbours
assert.equal(binarySearch(prices, 9999), -1); // larger than all
});
test("empty array and single-element array", () => {
assert.equal(binarySearch([], 5), -1);
assert.equal(binarySearch([5], 5), 0);
assert.equal(binarySearch([5], 7), -1);
});
test("matches indexOf on arrays of different lengths", () => {
for (let n = 0; n <= 64; n++) {
const evens = Array.from({ length: n }, (_, i) => i * 2); // 0, 2, 4, …
for (let target = -1; target <= n * 2; target++) {
assert.equal(binarySearch(evens, target), evens.indexOf(target), `n=${n}, target=${target}`);
}
}
});node --test binary-search.test.mjs → 4 tests, pass 4, fail 0. Replace low <= high with low < high and 3 of the 4 fail.Sorting: what Array.prototype.sort does
Sorting is the most common "heavy" operation in application code. What the MDN documentation says you need to know:
sortsorts the array in place and returns the same array;toSortedreturns a copy- without a compare function, elements are converted to strings and compared by UTF-16 code units, so
100comes before25 - since ECMAScript 2019, sorting must be stable: equal elements keep their original order
- time and space complexity are not guaranteed by the specification — they depend on the implementation
- the compare function must be consistent: always the same answer for the same pair, otherwise the behavior is not well-defined
// 1. Without a compare function, elements are compared as strings
console.log([25, 100, 3].toSorted()); // [ 100, 25, 3 ]
console.log([25, 100, 3].toSorted((a, b) => a - b)); // [ 3, 25, 100 ]
// 2. Strings by UTF-16 code units: "é" lands after "z"
const words = ["zebra", "éclair", "apple"];
console.log(words.toSorted()); // [ 'apple', 'zebra', 'éclair' ]
console.log(words.toSorted((a, b) => a.localeCompare(b, "en"))); // [ 'apple', 'éclair', 'zebra' ]
// 3. Stability (ES2019+): equal elements keep their original order
const orders = [
{ id: 1, status: "new" },
{ id: 2, status: "paid" },
{ id: 3, status: "new" },
{ id: 4, status: "paid" },
];
const byStatus = orders.toSorted((a, b) => a.status.localeCompare(b.status));
console.log(byStatus.map((order) => order.id)); // [ 1, 3, 2, 4 ]
// 4. How many times the comparator runs is a V8 implementation detail, not a spec guarantee
function countComparisons(list) {
let calls = 0;
list.toSorted((a, b) => {
calls++;
return a - b;
});
return calls;
}
let seed = 1;
const random = () => (seed = (seed * 48271) % 2147483647); // reproducible "random" numbers
const shuffled = Array.from({ length: 1000 }, random);
const alreadySorted = shuffled.toSorted((a, b) => a - b);
console.log(countComparisons(alreadySorted)); // 999
console.log(countComparisons(shuffled)); // 8630node sort.mjs. The comparison counts in the last two lines come from V8 13.6 (Node.js 24.18); another engine may differ.What happens inside a specific engine is described on the V8 blog: since V8 7.0 (2018), Array.prototype.sort uses TimSort. It is stable, handles already sorted sequences in O(n) and stays within O(n log n) in the worst case; for merging it needs a temporary array of up to n/2 elements. The example above confirms it: 999 comparisons for 1,000 already sorted numbers and 8,630 for shuffled ones, where n·log₂n ≈ 9,966.
Time versus memory: indexes and caches
Almost every search speed-up is paid for in memory. An index is an extra structure that stores the path to data by key: you build it once in O(n), and every later lookup avoids a scan. Here is a Map of orders by number:
const orders = Array.from({ length: 50000 }, (_, i) => ({ id: 100000 + i, total: (i % 90) * 100 }));
const requested = [100007, 149999, 123456, 999999];
// No index: every request is a find over the whole array, O(n) per request
let scanned = 0;
const viaFind = requested.map((id) =>
orders.find((order) => {
scanned++;
return order.id === id;
}),
);
console.log(scanned); // 123465 — elements scanned for 4 requests
// With an index: one O(n) pass to build it, then get is on average faster than O(n)
const byId = new Map(orders.map((order) => [order.id, order]));
const viaMap = requested.map((id) => byId.get(id));
console.log(byId.size); // 50000 — the index takes memory
console.log(viaMap.map((order) => order?.total)); // [ 700, 4900, 5600, undefined ]
console.log(viaFind.every((order, i) => order === viaMap[i])); // truenode index.mjs. Four requests via find scanned 123,465 elements; via the index, each request is one Map access.Search by scanning
- No extra memory
- O(n) for every request
- Fine for one or two lookups or small arrays
- Data is always current — no index to update
Index (Map, Set)
- Extra memory for each element
- O(n) once to build, then on average faster than O(n)
- Pays off with many requests
- The index must be updated when the data changes
The same logic applies to a whole system: a database index speeds up reads at the cost of disk space and slower writes, and a cache keeps a ready result so it does not have to be recomputed — at the cost of memory and the risk of showing stale data.
Practice: removing O(n²) from duplicate search
The task: an export contains 100,000 order numbers, some of which repeat, and we need to find the repeats. A common solution is filter with indexOf: an element is a duplicate if its first occurrence comes earlier. It is short and correct, but indexOf inside filter is exactly the hidden nested loop.
// 100,000 order numbers; every thousandth one repeats an earlier number
const ids = Array.from({ length: 100_000 }, (_, i) => (i % 1000 === 999 ? i - 500 : i));
// O(n²): indexOf inside filter — a nested loop, just hidden
function duplicatesNaive(list) {
return list.filter((value, index) => list.indexOf(value) !== index);
}
// O(n): a single pass with a Set check
function duplicatesWithSet(list) {
const seen = new Set();
const duplicates = [];
for (const value of list) {
if (seen.has(value)) duplicates.push(value);
else seen.add(value);
}
return duplicates;
}
function measure(label, fn) {
const start = performance.now();
const result = fn(ids);
console.log(`${label}: ${(performance.now() - start).toFixed(1)} ms`);
return result;
}
const naive = measure("filter + indexOf", duplicatesNaive);
const fast = measure("Set", duplicatesWithSet);
console.log(naive.length, fast.length); // 100 100
console.log(naive.every((value, i) => value === fast[i])); // trueWe measure time with performance.now(): MDN describes it as a timestamp in milliseconds with a fractional part, taken from a monotonic clock that does not jump when the system time is adjusted. In Node.js, the performance object is available globally. Here is one run on the author's machine:
$ node duplicates.mjs
filter + indexOf: 7438.3 ms
Set: 24.1 ms
100 100
trueEstimate
filtervisits n elements, andindexOfinside it up to n more. That is O(n²): billions of comparisons for 100,000 elements.Pick a structure
We need a fast "seen it before?" check — that is a Set.
hasandaddare on average faster than scanning.Rewrite
One pass: first time we meet a value —
add; again — push it to the duplicates. That is O(n) time and O(n) memory.Verify
Compare the results of both solutions: the same number and order of duplicates. Only then compare the timing.
How this was verified2 · passed1 · partialchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.