Lesson overview · Free interview practice

Performance Optimization

Master advanced performance optimization techniques.

Topics in the full lesson
  • Introduction to Performance Optimization
  • Debouncing and Throttling
  • Memoization
  • Optimizing Loops
  • Minimizing DOM Interactions
  • Best Practices
  • Exercises

Practice Problems

Work through a question before revealing its explanation. These questions and answers are free; Premium adds the full lesson walkthrough, examples and implementation detail.

Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.

TheoryEasy

Batched DOM Mutations with `DocumentFragment`

Question

What is a DocumentFragment, and how does it improve performance when adding many DOM nodes at once?

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryEasy

Minimizing DOM Interactions: Reflow vs Repaint

Question

Why is minimizing DOM interactions important for performance? Distinguish reflow from repaint and show how layout thrashing happens.

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

TheoryMedium

Event Delegation

Question

What is event delegation, and what are the trade-offs vs attaching listeners to each child?

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingHard

Debounce vs Throttle

Implement and check your solution in the stated environment before revealing the answer.

Question

What is the difference between debouncing and throttling? Give a use case for each and implement throttle(fn, wait).

Optional advanced extension. First learn to choose debounce versus throttle. Building the wrapper adds closure state, timer ordering and callback-error behavior. Review those before coding.

Contract

  • Use leading-only throttle, matching the host lesson. The first call while idle invokes fn immediately and closes a gate. Calls while that gate is closed are dropped. There is no trailing callback.
  • fn must be callable or creation throws TypeError. wait must be a number that is an integer from 1 through 2,147,483,647 milliseconds, otherwise creation throws RangeError. Do not coerce strings. Zero is excluded.
  • Schedule the gate-release timer and close the gate before invoking fn. The gate opens when that timer callback actually executes. Timers may run late.
  • Forward the admitted call's full argument list and receiver. Do not save dropped calls for later.
  • Return undefined on both admitted and dropped calls. A thrown admitted callback error propagates synchronously. The release timer must still be pending, so another immediate call remains blocked.
  • Reentrant calls from inside fn are dropped because the gate is already closed.
  • At a nominal boundary, callback order decides the result. A call before the release callback is still dropped. A call after release is admitted and starts a fresh interval.
  • No trailing option, cancel, flush, clock argument or animation-frame API is part of this exercise.

With wait = 100 and calls at 0, 10, 20, 30 and 40 on an ideal controlled clock, throttle invokes only at 0. Releasing the gate at 100 does not itself invoke fn. A new call after that release invokes immediately. A trailing-only debounce with the same calls would instead run once at 140.

Use debounce for work that should wait for a pause, such as search input. Use this throttle for work where immediate periodic samples are useful and dropping the last sample is acceptable, such as a scroll-position readout.

The fixture below controls timer callback order without sleeping. It is supplied test infrastructure, not an extra public utility API.

Set up an attempt

Use Node.js 24.21.0. Work in a small local folder, save the files below exactly as named, then edit only the starter. The .mjs extension makes the JavaScript files ES modules. No package install, browser, network request or project framework is needed.

Run the visible checks from that folder:

node check.mjs

The supplied starter intentionally throws an Implement ... error. That first failure confirms that the checker reaches your code. Work toward the named success message at the end of the check, not toward hiding assertions.

These are finite checks of the stated cases, not a proof of all possible correctness or mastery. Be ready to explain the behavior as well as run it.

starter.mjs

export function throttle(fn, wait) {
  throw new Error('Implement throttle')
}

clock.mjs

import assert from 'node:assert/strict'

export function withClock(check) {
  const originalSet = globalThis.setTimeout
  const originalClear = globalThis.clearTimeout
  const jobs = new Map()
  let now = 0
  let sequence = 0

  globalThis.setTimeout = (callback, delay) => {
    const id = ++sequence
    jobs.set(id, { callback, due: now + delay })
    return id
  }
  globalThis.clearTimeout = (id) => jobs.delete(id)

  const clock = {
    get now() { return now },
    get pending() { return jobs.size },
    elapse(ms) { now += ms },
    advance(ms) {
      const target = now + ms
      while (true) {
        const next = [...jobs]
          .filter(([, job]) => job.due <= target)
          .sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0]
        if (!next) break
        const [id, job] = next
        now = Math.max(now, job.due)
        jobs.delete(id)
        job.callback()
      }
      now = target
    },
  }

  try {
    check(clock)
    assert.equal(clock.pending, 0, 'A check left timers pending')
  } finally {
    globalThis.setTimeout = originalSet
    globalThis.clearTimeout = originalClear
  }
}

check.mjs

import assert from 'node:assert/strict'
import { withClock } from './clock.mjs'
const { throttle } = await import(new URL(process.argv[2] ?? './starter.mjs', import.meta.url))

withClock((clock) => {
  const calls = []
  const a = { label: 'a' }
  const b = { label: 'b' }
  const run = throttle(function (...args) {
    calls.push([clock.now, this, args])
    return 42
  }, 100)
  assert.equal(run.call(a, 'first', 2), undefined)
  for (const value of [10, 20, 30, 40]) {
    clock.advance(10)
    assert.equal(run.call(b, value), undefined)
  }
  clock.advance(59)
  run.call(b, 'before boundary')
  assert.deepEqual(calls, [[0, a, ['first', 2]]])
  clock.advance(1)
  assert.equal(calls.length, 1)
  run.call(b, 'boundary')
  assert.deepEqual(calls[1], [100, b, ['boundary']])
  clock.advance(100)
  run()
  assert.deepEqual(calls[2], [200, undefined, []])
  clock.advance(1000)
  assert.equal(calls.length, 3)
})

withClock((clock) => {
  const seen = []
  const run = throttle((value) => seen.push([clock.now, value]), 10)
  run('first')
  clock.elapse(10)
  run('release has not run')
  assert.deepEqual(seen, [[0, 'first']])
  clock.advance(0)
  run('released')
  assert.deepEqual(seen, [[0, 'first'], [10, 'released']])
  clock.elapse(100)
  run('late release still pending')
  assert.equal(seen.length, 2)
  clock.advance(0)
  run('after late release')
  assert.deepEqual(seen[2], [110, 'after late release'])
  clock.advance(10)
})

withClock((clock) => {
  const marker = new Error('callback failed')
  let calls = 0
  const run = throttle(() => {
    calls += 1
    if (calls === 1) throw marker
  }, 5)
  assert.throws(run, (error) => error === marker)
  run()
  assert.equal(calls, 1)
  clock.advance(5)
  run()
  assert.equal(calls, 2)
  clock.advance(5)
  const seen = []
  const recursive = throttle((n) => {
    seen.push(n)
    if (n === 1) recursive(2)
  }, 5)
  recursive(1)
  assert.deepEqual(seen, [1])
  clock.advance(5)
  recursive(3)
  assert.deepEqual(seen, [1, 3])
  clock.advance(5)
})

withClock((clock) => {
  let count = 0
  const a = throttle(() => count += 1, 1)
  const b = throttle(() => count += 1, 1)
  a()
  b()
  assert.equal(count, 2)
  clock.advance(1)
  const max = throttle(() => count += 1, 2_147_483_647)
  max()
  clock.advance(2_147_483_647)
  assert.equal(count, 3)
})
for (const wait of [0, -1, 1.5, NaN, Infinity, '10', 2_147_483_648]) {
  assert.throws(() => throttle(() => {}, wait), RangeError)
}
assert.throws(() => throttle(null, 10), TypeError)
console.log('throttle checks passed')

Optional TypeScript attempt

Use this instead of the JavaScript starter if you want a typed implementation. Save starter.mts, types.mts and tsconfig.json below beside the same check.mjs. The @ts-expect-error lines are intentional negative type checks. Their guarded block is not executed.

With an existing TypeScript 5.9.3 compiler available, run both commands:

tsc -p tsconfig.json
node check.mjs ./starter.mts

The first command performs a strict no-emit type check. The second runs the behavioral checks using Node's type stripping. Stripping is not type checking. The unimplemented typed starter should type-check but fail the runtime check until you implement it.

The configuration uses standard library declarations only. No @types/node package is needed for these small fixtures. The timer examples use the shared timer API from the DOM declarations, not browser execution.

starter.mts

export function throttle<This, Args extends unknown[]>(
  fn: (this: This, ...args: Args) => unknown,
  wait: number,
): (this: This, ...args: Args) => void {
  throw new Error('Implement throttle')
}

types.mts

import { throttle } from './starter.mts'

type Context = { label: string }
const wrapped = throttle(function (this: Context, value: number, suffix: string) {
  return this.label + value + suffix
}, 10)
const context: Context = { label: 'value: ' }
const result: void = wrapped.call(context, 1, '!')
if (false) {
  // @ts-expect-error The callback needs a number first.
  wrapped.call(context, '1', '!')
  // @ts-expect-error The callback needs its receiver.
  wrapped.call({}, 1, '!')
  // @ts-expect-error A scheduled wrapper does not return the callback result.
  const value: string = wrapped.call(context, 1, '!')
}

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "lib": [
      "ES2022",
      "DOM"
    ],
    "types": []
  },
  "files": [
    "starter.mts",
    "types.mts"
  ]
}

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

CodingHard

Implement `memoize`

Implement and check your solution in the stated environment before revealing the answer.

Question

Implement memoize(fn) for both a single primitive argument and multiple arguments, including objects and arrays. One implementation should handle both cases.

Optional advanced extension. First understand why a pure computation can reuse a result. Then practice Map lookups before trying the argument-key tree. Required versus optional is about learning scope, not whether the question involves code.

Contract

  • fn must be callable or wrapper creation throws TypeError. Do not invoke it during wrapper creation.
  • The key is the receiver followed by the complete ordered argument list. Different receivers must not share entries. Empty arguments and one undefined argument are different keys.
  • Use Map key equality for each position. Primitives use SameValueZero: NaN matches itself and 0 matches -0. Numbers and strings remain distinct. A suitable target must treat the two signed zeros equivalently.
  • Objects, arrays, functions and symbols are keyed by identity. Separate but equal-looking objects must not share entries. Do not serialize keys, walk object properties, call function-valued arguments or inspect thenables.
  • On a miss, call fn once with the original receiver and arguments. Return and store that exact result, including undefined, other falsy values and object identity. On a hit, return it without calling fn.
  • A synchronous thrown error propagates unchanged and is not cached as a result. A later call with the same key tries again.
  • Returned promises are opaque cached values. The same promise is reused even if it later rejects. There is no awaiting, rejection eviction or asynchronous retry policy.
  • Use strong Map storage for this exercise. Entries last as long as the wrapper remains reachable. There is no size bound, TTL, eviction or invalidation API.
  • Stable inputs are the caller's responsibility. Mutating a keyed object or receiver does not invalidate its result. Mutating a cached result changes what a later caller sees. Do not recursively re-enter the same in-progress key.

Start with repeated square(5) calls. Then prove that (sameObject, sameArray) hits while (differentObject, sameArray) misses. Explain why serializing arguments would not satisfy this identity contract.

Set up an attempt

Use Node.js 24.21.0. Work in a small local folder, save the files below exactly as named, then edit only the starter. The .mjs extension makes the JavaScript files ES modules. No package install, browser, network request or project framework is needed.

Run the visible checks from that folder:

node check.mjs

The supplied starter intentionally throws an Implement ... error. That first failure confirms that the checker reaches your code. Work toward the named success message at the end of the check, not toward hiding assertions.

These are finite checks of the stated cases, not a proof of all possible correctness or mastery. Be ready to explain the behavior as well as run it.

starter.mjs

export function memoize(fn) {
  throw new Error('Implement memoize')
}

check.mjs

import assert from 'node:assert/strict'
const { memoize } = await import(new URL(process.argv[2] ?? './starter.mjs', import.meta.url))

let squares = 0
const square = memoize((n) => { squares += 1; return n * n })
assert.equal(square(5), 25)
assert.equal(square(5), 25)
assert.equal(square(6), 36)
assert.equal(squares, 2)
let calls = 0
const record = memoize(function (...args) {
  calls += 1
  return { receiver: this, args }
})
const shared = {}
const array = [1, 2]
const pair = record(shared, array)
assert.equal(record(shared, array), pair)
assert.notEqual(record({}, array), pair)
assert.notEqual(record(shared, [1, 2]), pair)
assert.notEqual(record(array, shared), pair)
assert.notEqual(record(shared), pair)
assert.notEqual(record(), record(undefined))
assert.notEqual(record(null), record(undefined))
assert.notEqual(record(1), record('1'))
assert.notEqual(record(1n), record(1))
assert.notEqual(record('a,b', 'c'), record('a', 'b,c'))
assert.notEqual(record(['a', 'b']), record('a', 'b'))
assert.equal(record(NaN), record(NaN))
assert.equal(record(0), record(-0))
const symbol = Symbol('key')
assert.equal(record(symbol), record(symbol))
assert.notEqual(record(Symbol('key')), record(symbol))
const opaque = () => { throw new Error('do not call a key') }
assert.equal(record(opaque), record(opaque))
const cycle = {}
cycle.self = cycle
assert.equal(record(cycle), record(cycle))
const getters = {
  get toJSON() { throw new Error('do not serialize') },
  get then() { throw new Error('do not inspect thenables') },
  get value() { throw new Error('do not inspect keys') },
}
assert.equal(record(getters), record(getters))
const opaqueResult = memoize(() => getters)
assert.equal(opaqueResult(), getters)
assert.equal(opaqueResult(), getters)
const a = { scale: 2 }
const b = { scale: 3 }
const method = memoize(function (x) { return this.scale * x })
assert.equal(method.call(a, 5), 10)
assert.equal(method.call(b, 5), 15)
assert.equal(record.call(a, 1).receiver, a)
assert.equal(record.call(undefined, 1).receiver, undefined)
assert.notEqual(record.call(a, 1), record.call(b, 1))

for (const result of [undefined, null, false, 0, '']) {
  let count = 0
  const run = memoize(() => { count += 1; return result })
  assert.equal(run(), result)
  assert.equal(run(), result)
  assert.equal(count, 1)
}
const marker = new Error('retry')
let attempts = 0
const retry = memoize(() => {
  attempts += 1
  if (attempts === 1) throw marker
  return 7
})
assert.throws(retry, (error) => error === marker)
assert.equal(retry(), 7)
assert.equal(retry(), 7)
assert.equal(attempts, 2)
const promise = Promise.resolve(9)
const asyncValue = memoize(() => promise)
assert.equal(asyncValue(), promise)
assert.equal(asyncValue(), promise)
assert.equal(await asyncValue(), 9)
const rejected = Promise.reject(marker)
rejected.catch(() => {})
const asyncFailure = memoize(() => rejected)
assert.equal(asyncFailure(), rejected)
await assert.rejects(asyncFailure(), (error) => error === marker)
assert.equal(asyncFailure(), rejected)
const mutable = { value: 1 }
const read = memoize((item) => item.value)
assert.equal(read(mutable), 1)
mutable.value = 2
assert.equal(read(mutable), 1)
const fib = memoize((n) => n < 2 ? n : fib(n - 1) + fib(n - 2))
assert.equal(fib(40), 102334155)
assert.throws(() => memoize(null), TypeError)
assert.ok(calls > 0)
console.log('memoize checks passed')

Optional TypeScript attempt

Use this instead of the JavaScript starter if you want a typed implementation. Save starter.mts, types.mts and tsconfig.json below beside the same check.mjs. The @ts-expect-error lines are intentional negative type checks. Their guarded block is not executed.

With an existing TypeScript 5.9.3 compiler available, run both commands:

tsc -p tsconfig.json
node check.mjs ./starter.mts

The first command performs a strict no-emit type check. The second runs the behavioral checks using Node's type stripping. Stripping is not type checking. The unimplemented typed starter should type-check but fail the runtime check until you implement it.

The configuration uses standard library declarations only. No @types/node package is needed for these small fixtures.

starter.mts

export function memoize<This, Args extends unknown[], Result>(
  fn: (this: This, ...args: Args) => Result,
): (this: This, ...args: Args) => Result {
  throw new Error('Implement memoize')
}

types.mts

import { memoize } from './starter.mts'

type Context = { scale: number }
const multiply = memoize(function (this: Context, a: number, b: number) {
  return this.scale * a * b
})
const result: number = multiply.call({ scale: 2 }, 3, 4)
if (result !== 24) throw new Error('Typed usage failed')
const describe = memoize((item: { name: string }) => item.name)
const name: string = describe({ name: 'Ada' })
if (name !== 'Ada') throw new Error('Typed identity input failed')
if (false) {
  // @ts-expect-error Arguments retain their types.
  multiply.call({ scale: 2 }, '3', 4)
  // @ts-expect-error Receiver requirements are retained.
  multiply.call({}, 3, 4)
  // @ts-expect-error Return type remains number.
  const text: string = multiply.call({ scale: 2 }, 3, 4)
}

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "lib": [
      "ES2022",
      "DOM"
    ],
    "types": []
  },
  "files": [
    "starter.mts",
    "types.mts"
  ]
}

Take a moment to think about this before revealing the answer

Explain your reasoning or try an implementation before comparing answers.

Explore the full Performance Optimization material

Premium includes the complete lessons and implementation references. Free practice questions remain available without a subscription.

All course tracks & premium content
From basics to advanced masterclasses
Built for JS/TS developers like you
Real-world tips & common pitfalls
Upgrade to Premium