Coding drills

JavaScript utilities

The utility-function drills that recur in senior frontend interviews — debounce and throttle, memoize, once, deep equal, deep clone and deep merge, groupBy and chunk, pick and omit, an event emitter, an LRU cache, retry — each with the edge cases that separate a working answer from a senior one, typed implementations and the built-ins that replace hand-written code in 2026.

These are the "implement X" questions. Interviewers use them because the interface is known and the edge cases are not: this, argument capture, cyclic references, Map and Date values, cancellation, memory. The senior answer knows which built-in already exists, writes the rest correctly, and says what it would test.

Know

  • Debounce vs throttle: debounce delays until calls stop (search input); throttle guarantees at most one call per interval (scroll, resize). Edge cases: last arguments win, this preserved, cancel/flush, leading and trailing options, timer cleanup on component unmount (VueUse's useDebounceFn handles the Vue lifecycle).
  • Memoize: cache keyed by arguments; single-argument with a Map (supports object keys by identity), multi-argument with a serialiser or a nested Map trie; WeakMap for object keys to avoid leaks; bounded size (LRU) for long-running apps; async memoize must cache the promise and evict on rejection.
  • Once: call at most once, return the first result; the async variant caches the promise.
  • Deep equal: same reference → true; different types → false; NaN equals NaN; arrays by index; Date by time; RegExp by source and flags; Map and Set by entries; plain objects by own enumerable keys (including symbol keys if required); cycles with a WeakMap of visited pairs; +0 vs -0 per requirement.
  • Deep clone: prefer structuredClone (handles cycles, Date, Map, Set, typed arrays; throws on functions and DOM nodes, drops prototypes and getters). A hand-written version is asked to see recursion, cycle handling with a WeakMap, and special types.
  • Deep merge: recursive merge of plain objects only; arrays replace (or concatenate, agree first); undefined does not overwrite; guard against prototype pollution by skipping __proto__, constructor and prototype keys; defu is the Nuxt-world library (defaults merging, right-to-left).
  • groupBy, chunk, uniqBy, partition, zip: Object.groupBy and Map.groupBy are built in (ES2024); chunk is a loop with slice; uniqBy is a Set of seen keys; Array.prototype.at, toSorted, toReversed, with, findLast are the modern immutable helpers.
  • pick / omit: typed with Pick<T, K> and Omit<T, K>; Object.fromEntries plus filter; note that omit preserves prototype-less copies only.
  • Event emitter: on, off, once, emit; store listeners in a Map<string, Set<Listener>>; return an unsubscribe function; copy the set before iterating so handlers can unsubscribe during emit; typed events with a generic map (mitt is the tiny library; Nuxt's hooks use hookable).
  • LRU cache: a Map preserves insertion order, so get deletes and re-inserts to mark recent, set evicts map.keys().next().value when over capacity; O(1) operations. Ask about TTL and size in bytes.
  • Retry with backoff: attempts, base delay, exponential factor, jitter, a predicate for retryable errors, AbortSignal support; covered in the async page.
  • Built-ins to name: structuredClone, Object.groupBy, Array.fromAsync, Promise.withResolvers, Set methods (union, intersection, difference), Iterator helpers (map, filter, take, toArray), Intl.* for formatting, crypto.randomUUID, AbortSignal.timeout and AbortSignal.any, Error cause, using declarations (ES2026 explicit resource management) for disposables.

How it works

Throttle with leading and trailing invocations:

export function throttle<T extends (...args: any[]) => void>(fn: T, wait: number, { leading = true, trailing = true } = {}) {
  let last = 0
  let timer: ReturnType<typeof setTimeout> | undefined
  let pending: Parameters<T> | undefined

  return function (this: unknown, ...args: Parameters<T>) {
    const now = Date.now()
    if (!last && !leading) last = now
    const remaining = wait - (now - last)
    if (remaining <= 0) {
      clearTimeout(timer); timer = undefined
      last = now
      fn.apply(this, args)
    }
    else if (trailing) {
      pending = args
      timer ??= setTimeout(() => {
        last = leading ? Date.now() : 0
        timer = undefined
        if (pending) fn.apply(this, pending)
        pending = undefined
      }, remaining)
    }
  }
}

Memoize with a WeakMap for object keys and a Map for primitives, plus an async variant:

export function memoize<A extends object | string | number, R>(fn: (arg: A) => R) {
  const objects = new WeakMap<object, R>()
  const primitives = new Map<string | number, R>()
  return (arg: A): R => {
    const store = typeof arg === 'object' ? objects : primitives
    if (store.has(arg as never)) return store.get(arg as never)!
    const result = fn(arg)
    store.set(arg as never, result)
    return result
  }
}

export function memoizeAsync<A extends string | number, R>(fn: (arg: A) => Promise<R>) {
  const cache = new Map<A, Promise<R>>()
  return (arg: A) => {
    const hit = cache.get(arg)
    if (hit) return hit
    const p = fn(arg).catch((err) => { cache.delete(arg); throw err }) // do not cache failures
    cache.set(arg, p)
    return p
  }
}

Deep equality with cycles, dates, maps and sets:

export function deepEqual(a: unknown, b: unknown, seen = new WeakMap<object, object>()): boolean {
  if (Object.is(a, b)) return true                                  // handles NaN; distinguishes +0/-0
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false
  if (seen.get(a) === b) return true                                // cycle already being compared
  seen.set(a, b)

  if (a instanceof Date) return a.getTime() === (b as Date).getTime()
  if (a instanceof RegExp) return a.source === (b as RegExp).source && a.flags === (b as RegExp).flags
  if (a instanceof Map) {
    const bm = b as Map<unknown, unknown>
    return a.size === bm.size && [...a].every(([k, v]) => bm.has(k) && deepEqual(v, bm.get(k), seen))
  }
  if (a instanceof Set) {
    const bs = b as Set<unknown>
    return a.size === bs.size && [...a].every(v => bs.has(v))       // by identity/SameValueZero, like Set itself
  }
  if (Array.isArray(a)) {
    const ba = b as unknown[]
    return a.length === ba.length && a.every((v, i) => deepEqual(v, ba[i], seen))
  }
  const ka = Reflect.ownKeys(a), kb = Reflect.ownKeys(b)
  return ka.length === kb.length && ka.every(k => Object.prototype.hasOwnProperty.call(b, k) && deepEqual((a as any)[k], (b as any)[k], seen))
}

Deep merge that cannot be used for prototype pollution:

const isPlainObject = (v: unknown): v is Record<string, unknown> => Object.prototype.toString.call(v) === '[object Object]' && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
const UNSAFE = new Set(['__proto__', 'constructor', 'prototype'])

export function deepMerge<T extends Record<string, unknown>>(target: T, ...sources: Partial<T>[]): T {
  const out: Record<string, unknown> = { ...target }
  for (const source of sources) {
    for (const key of Object.keys(source)) {
      if (UNSAFE.has(key)) continue
      const value = source[key]
      if (value === undefined) continue
      out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key] as Record<string, unknown>, value) : value // arrays replace
    }
  }
  return out as T
}

An LRU cache and a typed event emitter:

export class LRU<K, V> {
  #map = new Map<K, V>()
  constructor(private readonly capacity: number) {}
  get(key: K) {
    if (!this.#map.has(key)) return undefined
    const value = this.#map.get(key)!
    this.#map.delete(key); this.#map.set(key, value)                 // mark as most recent
    return value
  }
  set(key: K, value: V) {
    if (this.#map.has(key)) this.#map.delete(key)
    this.#map.set(key, value)
    if (this.#map.size > this.capacity) this.#map.delete(this.#map.keys().next().value!) // oldest
  }
}

type Listener<T> = (payload: T) => void
export function createEmitter<Events extends Record<string, unknown>>() {
  const listeners = new Map<keyof Events, Set<Listener<any>>>()
  return {
    on<K extends keyof Events>(event: K, fn: Listener<Events[K]>) {
      const set = listeners.get(event) ?? new Set()
      set.add(fn); listeners.set(event, set)
      return () => set.delete(fn)
    },
    once<K extends keyof Events>(event: K, fn: Listener<Events[K]>) {
      const off = this.on(event, (p) => { off(); fn(p) })
      return off
    },
    emit<K extends keyof Events>(event: K, payload: Events[K]) {
      for (const fn of [...(listeners.get(event) ?? [])]) fn(payload)  // copy: handlers may unsubscribe
    },
  }
}

The built-ins that replace hand-written helpers today:

const clone = structuredClone(state)                                  // cycles, Map, Set, Date; no functions
const byStatus = Object.groupBy(orders, o => o.status)                // Record<string, Order[]>
const asMap = Map.groupBy(orders, o => o.customerId)
const sorted = users.toSorted((a, b) => a.name.localeCompare(b.name)) // immutable
const both = new Set(a).intersection(new Set(b))
const firstTen = someIterable.values().filter(Boolean).take(10).toArray()
const { promise, resolve, reject } = Promise.withResolvers<string>()
await using conn = await pool.connect()                               // ES2026 disposal: conn[Symbol.asyncDispose]() at block end
Gotcha· Serialising arguments as a memo key

JSON.stringify(args) as a cache key breaks on key order, undefined, Date, cycles and big inputs, and it turns two different objects with the same shape into one entry. Use identity (WeakMap) for objects and a nested Map for multiple arguments, or accept a caller-provided key function.

Gotcha· The event emitter that skips a listener

Iterating the live Set while a handler unsubscribes itself or another handler skips or repeats calls. Copy the set before emitting, and document whether listeners added during emit run in the same emit.

Exercise

Exercise
  • Implement debounce with cancel, flush and a leading option; test it with Vitest fake timers, including this preservation.
  • Write deepEqual and test it against cycles, NaN, dates, maps and mismatched prototypes; then compare its behaviour with structuredClone round-tripping.
  • Implement an LRU with a TTL per entry; discuss where it should live in a Nuxt app (server module scope is a legitimate singleton, client per-tab).

Be able to say

Be able to say· Implement debounce. What edge cases do you handle?

"Each call clears and restarts the timer, so the function runs once after calls stop, with the last arguments and the original this. I expose cancel for unmount and flush to run immediately, and a leading option if the caller wants the first call to fire at once. I type it generically so the debounced function keeps its parameters. The tests, with fake timers: three calls inside the window produce one invocation with the last arguments; cancel prevents it; flush runs it immediately and clears the timer; and this is preserved. In a Vue component I would reach for VueUse's useDebounceFn, which also cancels on unmount."

Be able to say· How do you deep clone an object in 2026?

"structuredClone: it handles cycles, dates, maps, sets and typed arrays, and it is native and fast. Its limits are functions, DOM nodes and class instances, which it either rejects or turns into plain objects, and it drops getters. If an interviewer wants a hand-written version, the points to hit are recursion, a WeakMap of visited objects for cycles, special-casing date, map, set and arrays, and copying only own enumerable keys."

Your notes

Open in Notes

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