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.
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.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.Array.push plus an index (not shift, which is O(n)) or a linked list; a deque for sliding-window maximum.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).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.Map; collect completions with DFS; compare with a sorted array plus binary search for smaller data.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.Intl, structuredClone, Array.prototype.toSorted, Set methods, fuse.js for fuzzy search, @tanstack/virtual), then implement what the interviewer wants to see.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
}
<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]!
}
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.
(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.
walk, pathTo and bfs and use them to build breadcrumbs and a "shortest path" between two menu items on a nested navigation object.fuse.js; measure and describe when each wins.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)."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Nuxt drills
Fullstack Nuxt exercises in the shape interviewers use — a validated API route plus a page that consumes it, a protected area with session middleware, an error page, a dynamic SEO route, cached list endpoints with route rules, a plugin that provides a helper, shared state across components, and a mini layer — each with the specification to state, the solution core, and what is checked.
Take-home and pairing
How to score well on take-home assignments and pairing sessions — scoping and time-boxing, the README that explains decisions, tests that matter, commits that tell a story, avoiding over-engineering, the reviewer's rubric, code-review drills for spotting bugs in Vue and Nuxt snippets, refactoring under observation, and pairing etiquette.