How to buildOctober 11, 20267 min read
Programming FundamentalsStep 7 of 15

Algorithms and complexity: Big O without fear

In short

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.

FundamentalsLevel: IntermediateAlgorithms

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

Verified with

Node.js 24.18 LTS (V8 13.6)node:test

Checked against docs: October 11, 2026

Contents
  1. 01What an algorithm is and why complexity matters
  2. 02Big O in plain terms: how the step count grows
  3. 03Common complexity classes with examples
  4. 04Search: linear and binary
  5. 05Sorting: what Array.prototype.sort does
  6. 06Time versus memory: indexes and caches
  7. 07Practice: removing O(n²) from duplicate search

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:

steps.mjs
// 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 ]
Run: 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
Step counts for different n (rounded, base-2 logarithm)

Common complexity classes with examples

O(1)

How it grows
Independent of n
JavaScript example
Reading array[i] and array.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; includes inside filter

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.mjs
/**
 * 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.

binary-search.test.mjs
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}`);
    }
  }
});
Run: 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:

  • sort sorts the array in place and returns the same array; toSorted returns a copy
  • without a compare function, elements are converted to strings and compared by UTF-16 code units, so 100 comes before 25
  • 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
sort.mjs
// 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)); // 8630
Run: node 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:

index.mjs
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])); // true
Run: node 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.

duplicates.mjs
// 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])); // true
The first solution takes several seconds on 100,000 elements — that is expected and intended.

We 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:

terminal
$ node duplicates.mjs
filter + indexOf: 7438.3 ms
Set: 24.1 ms
100 100
true
An illustration, not a benchmark: Intel Core i7-14700, Windows 11, Node.js 24.18. Across three consecutive runs the first solution took 7.3–7.9 s and the second 14–23 ms. Your numbers will differ; the order of magnitude of the gap will not.
  1. Estimate

    filter visits n elements, and indexOf inside it up to n more. That is O(n²): billions of comparisons for 100,000 elements.

  2. Pick a structure

    We need a fast "seen it before?" check — that is a Set. has and add are on average faster than scanning.

  3. 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.

  4. 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.