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.
Theory questions
Batched DOM Mutations with `DocumentFragment`
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.
Minimizing DOM Interactions: Reflow vs Repaint
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.
Event Delegation
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.
Coding practice
Debounce vs Throttle
Implement and check your solution in the stated environment before revealing the answer.
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
fnimmediately and closes a gate. Calls while that gate is closed are dropped. There is no trailing callback. fnmust be callable or creation throwsTypeError.waitmust be a number that is an integer from 1 through 2,147,483,647 milliseconds, otherwise creation throwsRangeError. 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
undefinedon 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
fnare 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.
Implement `memoize`
Implement and check your solution in the stated environment before revealing the answer.
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
fnmust be callable or wrapper creation throwsTypeError. 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
undefinedargument are different keys. - Use
Mapkey equality for each position. Primitives use SameValueZero:NaNmatches itself and0matches-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
fnonce with the original receiver and arguments. Return and store that exact result, includingundefined, other falsy values and object identity. On a hit, return it without callingfn. - 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
Mapstorage 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.