Algorithms You Write
The algorithms a frontend engineer actually writes are few, and each is a scan replaced by a lookup, a batch, or a bound. This part is debounce and throttle as timers, virtualisation as prefix sums and a binary search, list diffing with keys and the LIS trick, LRU and LFU with O(1) eviction, search with inverted indexes and ranking, sorting at UI scale, graph traversal for dependencies and trees, and scheduling with a heap.
Debounce, throttle, and coalescing
"Search fires on every keystroke and the list stutters. Debounce or throttle, and what is the difference under the hood?"
Both are one timer and a closure. Debounce resets the timer on every call and fires once the calls stop; throttle fires at most once per interval and delivers the last call at the end. The complexity is O(1) per call for both; the choice is about what the user is doing: debounce for "when they stop" (typing a query), throttle for "while they do it" (scrolling, dragging). For visual work, the throttle interval should be the frame: coalesce to one call per requestAnimationFrame.
// debounce: run after the input goes quiet for `wait` ms. one timer, reset on every call
function debounce(fn, wait) {
let timer = null, lastArgs
const run = () => { timer = null; fn(...lastArgs) }
const d = (...args) => { lastArgs = args; clearTimeout(timer); timer = setTimeout(run, wait) } // O(1) per call; one pending timer
d.cancel = () => { clearTimeout(timer); timer = null }
d.flush = () => { if (timer) { clearTimeout(timer); run() } }
return d
}
// cost model: the closure holds lastArgs until it fires (retention of the last payload). cancel on unmount.
// throttle: run at most once per `wait` ms, with the trailing call delivered. a timestamp and one timer
function throttle(fn, wait) {
let last = 0, timer = null, lastArgs
return (...args) => {
lastArgs = args; const now = Date.now(); const remaining = wait - (now - last)
if (remaining <= 0) { clearTimeout(timer); timer = null; last = now; fn(...args) } // leading edge
else if (!timer) timer = setTimeout(() => { last = Date.now(); timer = null; fn(...lastArgs) }, remaining) // trailing edge
}
}
// rAF throttle for visual work: coalesce to one call per frame; the browser course part 6 has why
function rafThrottle(fn) { let id = null, a; return (...args) => { a = args; if (id === null) id = requestAnimationFrame(() => { id = null; fn(...a) }) } }
// which: debounce for "when they stop" (search-as-you-type, resize end, autosave); throttle for "while they do it" (scroll position, drag, progress)- Retention: the closure holds the last arguments until it fires. For a large payload (a file, a DOM node) that is retention of the payload for
waitms, and forever if the component unmounts without cancelling. Exposecancel; call it on unmount. - Leading and trailing edges: debounce normally fires on the trailing edge (after quiet). A leading-edge option fires immediately on the first call and then suppresses: right for "submit" buttons. Throttle with both edges gives immediate feedback plus a final state.
- Timers are host-scheduled (the browser course, part 6): clamped to 4 ms when nested, throttled to 1 s in background tabs, coarsened in some privacy modes. A 16 ms throttle via
setTimeoutis not frame-aligned;requestAnimationFrameis. - Async work inside: a debounced search that awaits a fetch can resolve out of order (an older query's response arriving after a newer one). Pair the debounce with cancellation (
AbortControllerper call, abort the previous) or a sequence number that drops stale responses. - Scheduler-aware variants: React's
useDeferredValueandstartTransitionare debounce-like at the render level (part 5): the input updates immediately, the expensive derived render is deferred and interruptible. Thescheduler.postTaskAPI gives priority-based deferral at the platform level.
Virtualisation and windowing
A list of 100,000 rows cannot be 100,000 DOM nodes (the browser course's layout and paint costs scale with nodes). Virtualisation renders the thirty that are visible and positions them inside a container of the full height. The algorithm is a prefix-sum array and a binary search; the engineering is in measurement and scroll compensation.
- Fixed heights:
start = floor(scrollTop / rowHeight),end = start + ceil(viewportHeight / rowHeight). O(1). Total heightn × rowHeight. - Variable known heights: prefix sums
offsets[i](O(n) once; a Float64Array).startby binary search forscrollTop(O(log n));endby walking forward untiloffsets[i] >= scrollTop + viewportHeight(O(visible)). Total heightoffsets[n]. - Overscan: render a few extra rows above and below so fast scrolling does not show blank space before the next render commits. 3 to 10 rows; more on slow devices.
- Positioning: each rendered row absolutely at
offsets[i], or the whole window translated byoffsets[start]with rows in flow. Translation is one compositor-friendly transform; absolute positioning is simpler to reason about. - Keys: rows keyed by item id so the diff between windows (chapter 3) touches only the rows that entered or left.
- Estimate each height (an average, or a per-type guess); build offsets from estimates.
- Render the window; measure rendered rows in one batch (
getBoundingClientRectafter the commit, or aResizeObserver); correct the heights array; rebuild offsets (O(n), fine) or update a Fenwick tree (O(log n) per change). - Compensate: if a row above the viewport was mis-estimated by Δ, the content under the cursor shifted by Δ; adjust
scrollTopby Δ in the same frame so the visible content stays put. Missing this is the jitter in bad virtual lists. - Anchor: scroll anchoring (the browser does it for normal content) does not apply to absolutely positioned rows; you are the anchor.
- Under ~500 simple rows: the DOM can hold them;
content-visibility: autoon each row lets the browser skip rendering offscreen ones with no JavaScript. - Find-in-page and accessibility: virtualised rows are not in the DOM, so Ctrl+F and screen readers cannot see them. Provide search and announce counts.
- Variable, unmeasurable heights with frequent changes: the measure-correct-compensate loop can thrash. Fix heights per row type instead.
Diffing lists: LCS, keys, and the LIS trick
Every framework, every list sync, and every "apply this update to that collection" is a diff between two sequences. The optimal diff is a longest-common-subsequence problem and costs O(n × m); nobody pays that in a UI. With stable keys, the diff becomes two linear passes plus an O(k log k) move computation, and that is what the reconcilers do.
- Index the target by key:
Map<key, index>. O(m). - Walk the source: for each item, look up its key. Absent → delete. Present → record the target index. O(n). Target keys never seen → insert.
- Moves: the kept items, in source order, have target indices. The longest increasing subsequence of those indices is the largest set that can stay in place; everything else moves. LIS by patience sorting is O(k log k). React uses a cheaper heuristic (track the highest target index placed so far; any item with a lower index moves) that is O(n) and occasionally makes one extra move.
- Apply: deletions, then moves and insertions in target order, so that each operation's reference node ("insert before X") already exists.
- Stable: the same logical item gets the same key across renders. An id from the data. Not an index; not
Math.random(); not a key that changes when the item is edited. - Unique within the list: duplicates make the index lookup ambiguous; frameworks warn and then behave unpredictably.
- Index keys are position pairing: fine for a static list that only appends; wrong for anything that inserts, removes or reorders, because every subsequent position "changes" and component state attached to those positions is lost or misattributed.
- Composite keys (
`${type}:${id}`) when ids collide across types; the string concatenation is one cons node per key (the JS course, part 6), fine at list sizes.
- Text and file diffs: Myers' algorithm, O((n + m) × d) where d is the edit distance: fast when the sequences are similar, which they usually are. git, and every code review tool.
- DOM morphing: libraries that diff a new HTML string against the live DOM (morphdom, idiomorph, Turbo) use keys where ids exist and position heuristics otherwise.
- Your own syncs: reconciling a fetched list with a local one, applying server deltas, keeping two models aligned. The same three steps; the Map is the whole trick.
Caches: LRU, LFU, and bounds
A cache without a bound is a leak (the JS course, part 13). A bound needs an eviction policy, and the policy needs a data structure that makes eviction O(1), or the cache's bookkeeping costs more than the recomputation it saves.
- LRU with a Map: insertion order is recency;
getdeletes and re-sets;setat capacity deletes the first key. Ten lines, O(1) amortised, the right default. The deletes leave holes that the Map's rehash compacts (the JS course, part 12). - LRU with a hash and a doubly linked list: the textbook version; O(1) without holes; better constants under heavy churn; forty lines.
- LFU: counts per entry; a map from frequency to a list of entries; a minimum-frequency pointer. O(1) for all operations with the right structure; naive versions (scan for the minimum) are O(n) per eviction.
- TTL: a timestamp per entry checked on read; expired entries removed lazily on access or by a periodic sweep. Combine with LRU for a bound on both age and size.
- Size-aware: a size function per entry (bytes of an image, length of a string) and a byte budget instead of an entry count. Eviction loops until under budget.
- Recency (LRU) for the usual UI case: what the user just looked at, they will look at again. Viewed records, rendered thumbnails, fetched pages, computed layouts.
- Frequency (LFU) when a few keys are hot forever and a scan of cold keys should not evict them. Rare in a UI; common in servers.
- Size one is a real cache: memoised selectors (reselect) keep the last input and output; a size-one LRU is a single equality check.
- Key by object identity with a WeakMap when the cached value should die with its key (per-node layout caches, per-request derived data). No eviction policy needed; the collector is the policy.
what a bound costs per operation: LRU (Map) get: 2 hash ops (delete + set) · set: 2 to 3 · evict: 1 → ~50 to 100 ns LRU (list) get: 1 hash op + 4 pointer writes · evict: 1 + 2 → ~30 to 60 ns LFU (O(1)) get: 2 hash ops + relink · evict: 1 → ~80 to 150 ns "scan for oldest" evict: O(n) → the quadratic in a cache any of the first three is fine. the fourth is what an object-with-timestamps gives you by default.
Search and ranking
Search-as-you-type over an in-memory collection is three problems: finding candidates (filtering), ordering them (ranking), and doing both within a keystroke. The naive linear filter is fine to about ten thousand short records with a debounce; past that, an index turns O(n) per query into O(hits).
// search over 50,000 records, three tiers of effort
// 1. linear filter: O(n × query) per keystroke. fine to ~10k short records; debounce it
const hits = records.filter(r => r.name.toLowerCase().includes(q))
// 2. inverted index: token → Set of record ids. build O(total tokens); query O(hits) per token, intersected
const index = new Map() // 'ade' → Set{12, 977, …}
for (const r of records) for (const t of tokenize(r.name)) (index.get(t) ?? index.set(t, new Set()).get(t)).add(r.id)
const hits = intersect(tokenize(q).map(t => index.get(t) ?? new Set())) // smallest set first
// 3. prefix search: a trie over tokens, or a sorted token array with two binary searches for the range [q, q + '')
const tokens = allTokens.sort() // once
const lo = lowerBound(tokens, q), hi = lowerBound(tokens, q + '') // O(log n) each; tokens[lo..hi) share the prefix
// ranking: score = Σ (field weight × match quality): exact > prefix > substring > fuzzy; recency and popularity as tie-breakers
// fuzzy (part 9): bitap for short patterns, edit distance ≤ 2 via a precomputed deletion index (SymSpell) for speed
// libraries: MiniSearch, FlexSearch, Fuse.js (fuzzy, slower), Lunr. all build one of the above- Linear filter:
includesper record per query. O(n × |record|). Up to ~10k records of a few words: single-digit milliseconds; debounced, invisible. The right first version. - Inverted index: token → set of record ids. Build once per data change in O(total tokens); query by intersecting the sets for the query's tokens, smallest set first. O(hits) per query. The structure under every search library.
- Prefix search: a trie (O(prefix length) to find the node, then enumerate), or a sorted token array with two binary searches giving the range of tokens sharing the prefix (O(log n) each; no extra structure beyond a sort). The sorted array is usually enough and far smaller than a trie.
- Substring search: harder: an inverted index of n-grams (every 3-character window → ids) handles "contains" queries at the cost of index size. Or accept linear scan over the candidate set from a coarser index.
- Fuzzy: typo tolerance. Edit distance per candidate is O(|a| × |b|); too slow across a whole collection. Candidate generation first (n-grams, or a deletion index like SymSpell), then exact scoring on the candidates. Part 9 has the string algorithms.
- Match quality: exact match > prefix > word-boundary > substring > fuzzy, with field weights (title over body). A score per hit; sort by score (a partial sort or a heap when only the top 20 are shown).
- Tie-breakers: recency, popularity, the user's own history (a small LRU of selected items boosts them).
- Highlighting: record the match positions during scoring; do not re-search the text to highlight it.
- Stability: keep results stable as the user types (the same item should not jump around); a stable sort with a deterministic tie-breaker.
Sorting at UI scale
// sorting at UI scale: Array.prototype.sort is TimSort (stable, O(n log n), adaptive: near-sorted input is ~O(n))
rows.sort((a, b) => a.price - b.price) // 100k rows: ~15 ms. 1k rows: ~0.1 ms
// the comparator runs n log n times: ~1.7M calls for 100k. keep it allocation-free and monomorphic:
rows.sort((a, b) => a.name.localeCompare(b.name)) // localeCompare per call: ~10× slower than a numeric compare
const collator = new Intl.Collator('en', { sensitivity: 'base' })
rows.sort((a, b) => collator.compare(a.name, b.name)) // one collator; ~3× faster than localeCompare per call
// precompute the sort key once when it is expensive (a normalised string, a parsed date), then compare keys:
const keyed = rows.map(r => [normalise(r.name), r]); keyed.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)
// multi-column: compare the first field, then the next on ties. stable sort means: sort by the secondary first, then the primary also works
// when NOT to sort: on every keystroke over 100k (sort once, filter the sorted array: filter preserves order); when only top-k is shown (a heap)
// toSorted() returns a copy (ES2023); sort() is in place and returns the same array (a mutation React will not see)Array.prototype.sortis TimSort (V8 since 2018): stable, O(n log n) worst case, O(n) on already-sorted or nearly-sorted input (it detects runs). Implemented in Torque with fast paths for packed Smi and double arrays with the default comparator.- The comparator is called ~n log n times. For 100,000 rows, about 1.7 million calls. A comparator that allocates (builds a string, creates a Date) or calls into ICU (
localeCompare) dominates the sort. - Default comparator converts to strings and compares UTF-16 units:
[10, 9, 1].sort()is[1, 10, 9]. Always pass a comparator for numbers. toSorted,toReversed,with: copying variants; the right ones for immutable state (a sorted-in-place array is the same reference and React will not re-render).
- Precompute keys (decorate-sort-undecorate) when the key is expensive: normalise the string once, parse the date once, then sort pairs by the precomputed key. O(n) key computations instead of O(n log n).
- Intl.Collator once, reused, instead of
localeCompareper call: several times faster; same results. - Multi-column: a comparator that checks fields in order; or sort by the secondary key first and then the primary (stability makes this correct), which is sometimes clearer.
- Top-k: when only the first page is shown, a min-heap of size k over the stream is O(n log k) and no full sort; for k = 20 over 100k that is ~5× less work and no 100k-element copy.
- Sort once, filter many: filtering preserves order; keep the master array sorted and filter per keystroke instead of sorting per keystroke.
- Typed arrays:
Float64Array.prototype.sortwith no comparator is a numeric sort in native code; for pure numeric columns, sort an index array by the typed column.
localeCompare, ~50 ms with a Collator, ~20 ms with precomputed keys. Past 100k, sort in a worker and transfer the index array back.Graphs: dependencies, trees, and traversal
// graph traversal in a UI: dependencies (build graphs, form field dependencies, derived state), layout (trees), routing
// adjacency as Map<node, Set<node>>; BFS with a queue (shortest path in hops), DFS with a stack or recursion (reachability, ordering)
function topoSort(deps) { // Kahn's algorithm: O(V + E). detects cycles
const indeg = new Map(), order = []
for (const [n, outs] of deps) { indeg.set(n, indeg.get(n) ?? 0); for (const m of outs) indeg.set(m, (indeg.get(m) ?? 0) + 1) }
const q = [...indeg].filter(([, d]) => d === 0).map(([n]) => n)
while (q.length) { const n = q.shift(); order.push(n); for (const m of deps.get(n) ?? []) { indeg.set(m, indeg.get(m) - 1); if (indeg.get(m) === 0) q.push(m) } }
if (order.length !== indeg.size) throw new Error('cycle')
return order
}
// where: computed/derived state (recompute in topological order so each node sees fresh inputs: signals do this incrementally),
// module graphs (part 8's tree shaking is reachability from entries), component trees (DFS = render order), undo graphs
// q.shift() is O(n) on arrays; for large graphs use an index pointer (let head = 0; q[head++]) to keep BFS O(V + E)- Derived state: computed values that depend on other computed values form a DAG. Recompute in topological order (Kahn's algorithm, O(V + E)) so each node sees fresh inputs exactly once. Signal libraries do this incrementally: a change marks dependents dirty along the edges and recomputes lazily on read, in dependency order.
- Component trees: a tree is a graph; render is a depth-first traversal; React's fiber (part 7) is a linked representation (child, sibling, return pointers) that makes the traversal resumable without a recursion stack.
- Module graphs: imports are edges; evaluation order is a post-order DFS (the JS course, part 11); tree shaking is reachability from entry points (part 8).
- Form dependencies: field B's options depend on field A's value; validation of C depends on A and B. A DAG; a cycle is a bug to detect at definition time.
- Routing and navigation: a route tree; matching is a trie walk over path segments.
- Layout constraints: in constraint-based layouts (Cassowary, as in Apple's Auto Layout), a graph solved iteratively; in CSS, grid and flex are not general graphs but their algorithms (part 3) have a dependency structure (track sizes depend on item sizes depend on track sizes) resolved by fixed passes.
- BFS with a queue: shortest path in edges; level-order; "everything within k hops". Use an index pointer, not
shift(), to keep it O(V + E). - DFS with recursion or an explicit stack: reachability, ordering (pre-order for render, post-order for evaluation), cycle detection (a node on the current stack reached again).
- Topological sort: Kahn's (BFS over in-degrees) or DFS post-order reversed. Both O(V + E); both detect cycles.
- Dijkstra with a heap (chapter 8): weighted shortest paths. Routing on a map, cost-based layout, "cheapest path through a state graph".
- Union-find: connected components with near-O(1) union and find; for "are these two nodes connected" over many queries (grouping selections, merging regions).
Scheduling with priority queues
A scheduler has work items with priorities or deadlines and must always run the most urgent one next, while letting new items arrive at any time. That is a priority queue, and the structure that makes it O(log n) per operation with no pointers is a binary heap in an array.
- Layout: an array; index 0 is the minimum; children of
iat2i+1and2i+2; parent at(i-1)>>1. The tree is always complete, so no pointers and no balancing. - push: append, then sift up (swap with the parent while smaller). O(log n).
- pop: take the root, move the last element to the root, sift down (swap with the smaller child while larger). O(log n).
- peek: the root. O(1).
- Numeric keys: a Float64Array for the keys and a parallel array for the payloads is cache-friendly and allocation-free; for ~30 lines you have a scheduler core.
- React's scheduler package: a
taskQueueheap by expiration time and atimerQueueheap by start time. The work loop pops the most urgent task, runs its callback until the 5 ms slice is used (shouldYield), and re-queues a continuation if the callback returned one. Priorities map to expiration offsets (Immediate: −1, UserBlocking: 250 ms, Normal: 5 s, Low: 10 s, Idle: never), so a starved task eventually becomes the most urgent. Part 5 puts the lane model on top. - Node's timers: a priority queue of timer lists keyed by duration, each list a linked list in insertion order: O(1) insert for the common case of many timers with the same duration; the queue of lists is a heap.
- The browser's task sources: not a single heap; per-source queues with host-defined priorities and the Prioritized Task Scheduling API (
scheduler.postTaskwith user-blocking, user-visible, background) exposed to pages. Part 5. - Your own: a request queue that runs the most recently requested image first (LIFO-ish priority), a job queue with deadlines, an animation system picking the next keyframe time, a game loop's event list.
- Deadline scheduling (EDF): a min-heap by deadline; always run the earliest deadline; optimal for single-processor feasibility.
- Aging: raise the priority of waiting items over time so nothing starves; React's expiration times are aging in disguise.
- Cooperative yielding: the scheduler cannot pre-empt; tasks must return to the loop. The slice length (5 ms in React, up to 50 ms before the long-task threshold) is the trade between throughput and responsiveness.
Reading your own algorithms: tools
| Question | Tool | What it shows |
|---|---|---|
| Is this loop quadratic? | A scaling test at n, 2n, 4n (part 0) | ×2 linear, ×4 quadratic |
| How often does the handler run? | console.count in the handler, or the Performance panel's event count | Whether a debounce or throttle is needed and what interval |
| Is the virtual list measuring correctly? | Rendering → Layout Shift Regions; a log of estimate vs measured per row | Jitter sources; estimate quality |
| Is the diff touching only what changed? | React DevTools Profiler (components rendered, why); Rendering → Paint flashing | Whole-list re-renders from missing or unstable keys |
| Is the cache bounded and hitting? | Hit/miss counters; heap snapshot comparison (JS course part 5) | Hit rate; growth over time |
| Is the search index worth it? | Time per query at the real collection size, linear vs indexed, in a worker vs main | The crossover for your data |
| Is the comparator the sort's cost? | CPU profile during a sort; count comparator calls | localeCompare or allocation inside the comparator |
| Is the derived-state graph recomputing too much? | Instrument compute functions with counters per change | Nodes recomputed per change versus nodes actually affected |
| Is the scheduler starving something? | Long tasks in the Performance panel; a log of queue depth and wait times | Slice length and priority mapping problems |