Coding drills

Algorithms at frontend depth

The algorithmic knowledge a senior frontend interview actually tests — complexity reasoning, hash maps and sets, two pointers and sliding windows, stacks and queues, trees and DOM traversal, recursion versus iteration, binary search, tries for autocomplete, light dynamic programming for fuzzy matching — with JavaScript implementations, the frontend situations each one shows up in, and how to talk about them.

Frontend algorithm questions are about the structures a UI actually manipulates: lists to deduplicate and group, trees to walk (DOM, menus, comments), strings to search and match, queues of events, caches to bound. You are expected to reason about complexity, pick the right structure, and write clean JavaScript; you are not expected to derive Dijkstra under pressure. When a question drifts towards pure algorithm puzzles, showing the pragmatic frontend framing is itself a senior signal.

Know

  • Complexity you must state: array includes/indexOf are O(n); Map/Set lookups are O(1) average; sorting is O(n log n); nested loops over the same list are O(n²), which is where "the table freezes with 10k rows" comes from. Space matters for memoisation and clones. Say the complexity before writing, and say when n is small enough not to care.
  • Hash maps and sets: counting, deduplication, lookups by id, grouping (Object.groupBy, Map.groupBy), two-sum, anagram grouping (sorted key), intersection and difference (Set.prototype.intersection, ES2025). Map keeps insertion order and takes object keys; WeakMap for metadata on objects without leaks.
  • Two pointers and sliding window: merging sorted lists (merging two sorted event streams), removing duplicates in place, the longest substring with unique characters, max sum of a window (moving averages in charts), rate-limit windows.
  • Stacks and queues: bracket matching and validating nesting (Markdown or HTML sanitiser), undo/redo (two stacks or an index into history), expression evaluation, BFS queues; a queue is a Array.push plus an index (not shift, which is O(n)) or a linked list; a deque for sliding-window maximum.
  • Trees: depth-first (recursive or with an explicit stack) versus breadth-first (queue); DOM traversal with TreeWalker or recursion; finding a path to a node (breadcrumbs), flattening nested comments with depth, computing subtree counts (folder sizes), lowest common ancestor (nearest shared parent for two elements), rendering trees with recursion in Vue (a recursive component referring to itself by name).
  • Recursion versus iteration: recursion is clearer for trees but stack-limited (~10k frames); convert to an explicit stack for deep or unknown structures; memoise overlapping subproblems; tail calls are not optimised in most engines.
  • Sorting: toSorted is stable (all engines since ES2019 stability guarantee), comparators must be consistent, Intl.Collator with numeric: true for human ordering ("file2" before "file10"), sort keys precomputed for expensive comparisons (Schwartzian transform), multi-key sort by chaining comparators.
  • Binary search: on sorted arrays and on answers (find the first index where a condition holds): finding the insertion point in a sorted list, the first visible row in a virtualised list given scroll offset with variable heights (prefix sums plus binary search), version bisecting.
  • Tries: prefix search for autocomplete and command palettes over a fixed vocabulary; each node a Map; collect completions with DFS; compare with a sorted array plus binary search for smaller data.
  • Light dynamic programming: edit distance (Levenshtein) for fuzzy search and "did you mean", longest common subsequence for diffs (how virtual DOM keyed children and text diff tools reason), memoised recursion for combinatorial UI (all valid filter combinations), coin change as a shape of problem, not a frontend need.
  • Graphs at UI depth: BFS for shortest path in a navigation graph or dependency resolution, topological sort for ordering dependent form fields or build steps, cycle detection for "you cannot make a folder its own parent".
  • Strings and unicode: for...of and Array.from iterate code points, .length counts UTF-16 units, Intl.Segmenter for graphemes (emoji), normalize('NFC') before comparing, localeCompare for sorting, String.prototype.at and isWellFormed.
  • When to stop: name the library or built-in that exists (Intl, structuredClone, Array.prototype.toSorted, Set methods, fuse.js for fuzzy search, @tanstack/virtual), then implement what the interviewer wants to see.

How it works

Hash maps and a sliding window, in the shape they appear in interviews:

// Group anagrams: O(n · k log k) with sorted keys, or O(n · k) with a 26-count key
export function groupAnagrams(words: string[]) {
  return [...Map.groupBy(words, w => [...w.normalize('NFC')].sort().join('')).values()]
}

// Two-sum with indices: one pass, O(n)
export function twoSum(nums: number[], target: number): [number, number] | null {
  const seen = new Map<number, number>()
  for (const [i, n] of nums.entries()) {
    const j = seen.get(target - n)
    if (j !== undefined) return [j, i]
    seen.set(n, i)
  }
  return null
}

// Longest substring without repeating characters: sliding window over code points
export function longestUnique(s: string) {
  const chars = [...s]
  const last = new Map<string, number>()
  let start = 0, best = 0
  for (let i = 0; i < chars.length; i++) {
    const prev = last.get(chars[i]!)
    if (prev !== undefined && prev >= start) start = prev + 1
    last.set(chars[i]!, i)
    best = Math.max(best, i - start + 1)
  }
  return best
}

Stacks and queues where the UI needs them:

// Balanced tags/brackets: a stack; also the skeleton of a tiny sanitiser's nesting check
export function balanced(input: string, pairs: Record<string, string> = { '(': ')', '[': ']', '{': '}' }) {
  const closers = new Set(Object.values(pairs))
  const stack: string[] = []
  for (const ch of input) {
    if (pairs[ch]) stack.push(pairs[ch])
    else if (closers.has(ch) && stack.pop() !== ch) return false
  }
  return stack.length === 0
}

// Undo/redo as an index into history; bounded to avoid unbounded memory
export function createHistory<T>(initial: T, limit = 100) {
  const states: T[] = [initial]
  let index = 0
  return {
    get current() { return states[index]! },
    push(next: T) { states.splice(index + 1); states.push(next); if (states.length > limit) states.shift(); index = states.length - 1 },
    undo() { if (index > 0) index--; return states[index]! },
    redo() { if (index < states.length - 1) index++; return states[index]! },
  }
}

// A queue with O(1) dequeue (Array#shift is O(n))
export class Queue<T> {
  #items: T[] = []; #head = 0
  enqueue(item: T) { this.#items.push(item) }
  dequeue() { const item = this.#items[this.#head]; if (item !== undefined) { this.#head++; if (this.#head > 1024 && this.#head * 2 > this.#items.length) { this.#items = this.#items.slice(this.#head); this.#head = 0 } } return item }
  get size() { return this.#items.length - this.#head }
}

Trees: DOM traversal, breadcrumbs, and a recursive Vue tree:

// Iterative DFS over any tree shape; yields nodes with depth, no recursion limit
export function* walk<T extends { children?: T[] }>(roots: T[]): Generator<{ node: T, depth: number }> {
  const stack = roots.map(node => ({ node, depth: 0 })).reverse()
  while (stack.length) {
    const entry = stack.pop()!
    yield entry
    const children = entry.node.children ?? []
    for (let i = children.length - 1; i >= 0; i--) stack.push({ node: children[i]!, depth: entry.depth + 1 })
  }
}

// Path from root to a node (breadcrumbs): DFS with backtracking
export function pathTo<T extends { id: string, children?: T[] }>(roots: T[], id: string): T[] | null {
  for (const root of roots) {
    if (root.id === id) return [root]
    const sub = pathTo(root.children ?? [], id)
    if (sub) return [root, ...sub]
  }
  return null
}

// BFS: level order, e.g. the shortest click path in a navigation graph
export function bfs<T extends { children?: T[] }>(root: T, visit: (node: T, level: number) => void) {
  const queue: [T, number][] = [[root, 0]]
  for (let i = 0; i < queue.length; i++) {           // index instead of shift
    const [node, level] = queue[i]!
    visit(node, level)
    for (const child of node.children ?? []) queue.push([child, level + 1])
  }
}

// DOM: all focusable descendants in tree order, via TreeWalker
export function focusables(root: Element) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
    acceptNode: el => (el as HTMLElement).matches('a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
  })
  const out: HTMLElement[] = []
  while (walker.nextNode()) out.push(walker.currentNode as HTMLElement)
  return out
}
TreeNode.vue
<script setup lang="ts">
defineProps<{ node: { id: string, label: string, children?: any[] }, depth?: number }>()
</script>

<template>
  <li role="treeitem" :aria-level="(depth ?? 0) + 1" :aria-expanded="node.children ? true : undefined">
    <span :style="{ paddingInlineStart: `${(depth ?? 0) * 16}px` }">{{ node.label }}</span>
    <ul v-if="node.children?.length" role="group">
      <TreeNode v-for="child in node.children" :key="child.id" :node="child" :depth="(depth ?? 0) + 1" />  <!-- recursive by filename -->
    </ul>
  </li>
</template>

Binary search for a virtual list with variable row heights, and a trie for autocomplete:

// prefix sums of row heights -> first visible row for a scroll offset, O(log n)
export function firstVisibleRow(offsets: number[] /* offsets[i] = top of row i, ascending */, scrollTop: number) {
  let lo = 0, hi = offsets.length - 1
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1
    if (offsets[mid]! <= scrollTop) lo = mid
    else hi = mid - 1
  }
  return lo
}

export class Trie {
  #root = new Map<string, any>()
  static END = Symbol('end')
  insert(word: string) {
    let node = this.#root
    for (const ch of word.toLowerCase()) node = node.get(ch) ?? node.set(ch, new Map()).get(ch)
    node.set(Trie.END, word)
  }
  complete(prefix: string, limit = 10): string[] {
    let node = this.#root
    for (const ch of prefix.toLowerCase()) { node = node.get(ch); if (!node) return [] }
    const out: string[] = []
    const stack = [node]
    while (stack.length && out.length < limit) {
      const current = stack.pop()!
      if (current.has(Trie.END)) out.push(current.get(Trie.END))
      for (const [key, child] of current) if (key !== Trie.END) stack.push(child)
    }
    return out
  }
}

Edit distance for "did you mean", bounded so it stays fast:

export function levenshtein(a: string, b: string, max = Infinity) {
  const s = [...a], t = [...b]
  if (Math.abs(s.length - t.length) > max) return max + 1
  let prev = Array.from({ length: t.length + 1 }, (_, j) => j)
  for (let i = 1; i <= s.length; i++) {
    const curr = [i]
    let rowMin = i
    for (let j = 1; j <= t.length; j++) {
      const cost = s[i - 1] === t[j - 1] ? 0 : 1
      curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost)
      rowMin = Math.min(rowMin, curr[j]!)
    }
    if (rowMin > max) return max + 1                 // early exit: nothing in this row can recover
    prev = curr
  }
  return prev[t.length]!
}
Gotcha· Array.shift in a loop

Using shift() as a queue makes BFS O(n²) because every shift moves the remaining elements. Iterate with an index or use a proper queue; interviewers notice.

Gotcha· Sorting with subtraction on strings or mixed types

(a, b) => a - b on strings yields NaN and an inconsistent comparator, which some engines handle by leaving the order undefined. Use localeCompare/Intl.Collator for strings and check types when columns can hold both.

Exercise

Exercise
  • Implement walk, pathTo and bfs and use them to build breadcrumbs and a "shortest path" between two menu items on a nested navigation object.
  • Build a command palette over 5,000 commands with a trie, then with fuse.js; measure and describe when each wins.
  • Write firstVisibleRow with prefix sums and wire it into a variable-height virtual list; test it at the boundaries (offset 0, last row, past the end).

Be able to say

Be able to say· How much algorithmic knowledge does a senior frontend engineer need?

"Enough to reason about complexity and choose structures: maps and sets for lookups and grouping, stacks and queues for nesting and breadth-first work, trees for the DOM and nested data, binary search on sorted data such as row offsets in a virtual list, a trie or a fuzzy matcher for autocomplete, and edit distance or diffing when the product needs it. In practice that is what stops a table from freezing at ten thousand rows or a tree from blowing the stack. I state the complexity before writing, prefer the built-in or a library when one exists, and implement by hand when the interviewer wants to see the reasoning."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.