Implement It from Scratch: 5 Classic Front-End Interview Coding Problems
In front-end technical screening rounds and live coding sessions, interviewers frequently ask candidates to implement fundamental utility functions from scratch without relying on third-party libraries like Lodash.
These "implement from scratch" problems are designed to probe your mastery of core JavaScript mechanics: closures, timer lifecycles, prototype inheritance, reference handling, recursive data structures, and asynchronous promise coordination. Writing these utilities under live observation reveals whether you write clean, defensive, and production-ready code.
This guide provides complete, production-grade implementations for 5 classic front-end coding interview problems, targeting developers with 0 to 5 years of experience.
| # | Problem | Level |
|---|---|---|
| 1 | Implement debounce with immediate (leading-edge) execution and cancellation | Junior |
| 2 | Implement throttle with leading and trailing execution options | Mid |
| 3 | Implement Promise.all with fail-fast rejection and in-order resolution | Mid |
| 4 | Implement a recursive deepClone supporting circular references and special objects | Mid |
| 5 | Implement an EventEmitter (Pub/Sub) with on, emit, off, and once | Senior |
1. Implement debounce with Leading Edge and Cancellation
Requirements
Create a higher-order function debounce(fn, wait, options) that delays invoking fn until after wait milliseconds have elapsed since the last time the debounced function was invoked. Support an optional leading flag to invoke fn immediately on the first trigger, and expose a .cancel() method to abort pending executions.
Clarifying Questions to Ask the Interviewer:
thisand arguments: Should the originalthiscontext and argument list be forwarded tofn? (Yes, preservethiscontext).- Immediate execution: If
leading: true, should it execute again after the delay if called multiple times? (Yes, standard debounce with leading trigger). - Cancellation: Should the returned function allow clearing scheduled timeouts? (Yes, expose a
cancelproperty).
Approach
Use a closure to hold the active timerId. When invoked:
- If
leading: trueand no timer is currently active, invokefnimmediately. - Clear any existing
timerIdviaclearTimeout. - Schedule a new timer to execute
fnafterwaitmilliseconds (unless it was already executed vialeadingand no subsequent calls occurred). - Expose a
cancel()function directly on the returned function object.
Implementation:
function debounce(fn, wait = 0, { leading = false } = {}) {
let timerId = null;
function debounced(...args) {
const isInvokingLeading = leading && !timerId;
if (timerId) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
timerId = null;
if (!leading) {
fn.apply(this, args);
}
}, wait);
if (isInvokingLeading) {
fn.apply(this, args);
}
}
debounced.cancel = function () {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
return debounced;
}
Edge Cases Handled:
- Calling the debounced function with rapid bursts preserves the latest call arguments.
- The
thiscontext is properly bound viafn.apply(this, args). - Invoking
cancel()prevents memory leaks or state updates after component unmounting.
Complexity:
- Time Complexity: $O(1)$ overhead per call.
- Space Complexity: $O(1)$ memory retention in the closure.
Usage Example:
const log = debounce((msg) => console.log(msg), 100);
log('Hello');
log('World'); // Only 'World' logs after 100ms
Likely Follow-ups:
- How would you return a Promise from the debounced call? — Return a new Promise whose
resolveandrejectcallbacks are stored in the closure and settled whenfnexecutes.
2. Implement throttle with Leading and Trailing Execution
Requirements
Create a function throttle(fn, wait, options) that limits the execution of fn to at most once per wait milliseconds. Support optional configuration flags { leading = true, trailing = true } and provide a .cancel() method.
Clarifying Questions to Ask the Interviewer:
- Leading vs Trailing: Should the function fire on the initial call (
leading) and also fire the final call after the wait period if invoked during the cooldown (trailing)? (Yes, support configurable leading and trailing). - Return value: Is returning a cached value necessary, or can it be void? (Void callback execution is standard).
Approach
Track both the timerId and the most recent arguments and this context (lastArgs, lastThis).
- If no timer is running:
- If
leading: true, executefnimmediately. - Start a timer for
waitmilliseconds.
- If
- If a timer is already running:
- Stash the incoming arguments in
lastArgs.
- Stash the incoming arguments in
- When the timer expires:
- Reset
timerId = null. - If
trailing: trueandlastArgsexists, executefnwithlastArgs, clearlastArgs, and restart the throttle window.
- Reset
Implementation:
function throttle(fn, wait = 0, { leading = true, trailing = true } = {}) {
let timerId = null;
let lastArgs = null;
let lastThis = null;
function throttled(...args) {
if (!timerId) {
if (leading) {
fn.apply(this, args);
}
timerId = setTimeout(() => {
timerId = null;
if (trailing && lastArgs) {
fn.apply(lastThis, lastArgs);
lastArgs = null;
lastThis = null;
}
}, wait);
} else {
lastArgs = args;
lastThis = this;
}
}
throttled.cancel = function () {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
lastArgs = null;
lastThis = null;
};
return throttled;
}
Edge Cases Handled:
- Invoking
throttlewithleading: falsesuppresses the immediate burst, firing only on the trailing edge. - Trailing executions preserve the exact arguments of the last call during the cooldown window.
Complexity:
- Time Complexity: $O(1)$ per call.
- Space Complexity: $O(1)$ memory footprint.
Usage Example:
const onScroll = throttle((pos) => console.log('Position:', pos), 200);
window.addEventListener('scroll', () => onScroll(window.scrollY));
Likely Follow-ups:
- What is the difference between
throttleanddebouncein practical UI scenarios? — Usedebouncefor discrete event completion (e.g. search autocomplete, window resize settling) andthrottlefor continuous progress streams (e.g. infinite scroll, drag coordinates).
3. Implement Promise.all from Scratch
Requirements
Implement a function promiseAll(iterable) that replicates the behavior of Promise.all(). It accepts an iterable of promises (or non-promise values) and returns a single Promise that resolves to an array of resolved values in the original input order, or rejects immediately upon the first rejection (fail-fast).
Clarifying Questions to Ask the Interviewer:
- Non-promise values: Can the array contain primitive values (e.g., numbers, strings)? (Yes, wrap all inputs in
Promise.resolve()). - Empty input: How should an empty iterable behave? (Resolves immediately with an empty array
[]). - Order guarantee: Should results preserve input order even if a later promise resolves first? (Yes, output order must strictly match input order).
Approach
- Return a new
new Promise((resolve, reject) => { ... }). - Validate that input is iterable; reject with
TypeErrorif invalid. - Convert the iterable to an array using
Array.from(iterable). If length is 0, resolve immediately with[]. - Allocate an output array of length $N$ and maintain a
completedCount = 0counter. - Iterate through items using
.forEach((item, index) => ...):- Wrap each item with
Promise.resolve(item). - On resolution: place
valueatresults[index](preserving order) and incrementcompletedCount. - When
completedCount === items.length, callresolve(results). - On rejection: call
reject(error)immediately (fail-fast).
- Wrap each item with
Implementation:
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
if (!iterable || typeof iterable[Symbol.iterator] !== 'function') {
return reject(new TypeError('Argument must be iterable'));
}
const items = Array.from(iterable);
if (items.length === 0) {
return resolve([]);
}
const results = new Array(items.length);
let completedCount = 0;
items.forEach((item, index) => {
Promise.resolve(item).then(
(value) => {
results[index] = value;
completedCount += 1;
if (completedCount === items.length) {
resolve(results);
}
},
(error) => {
reject(error); // Fail fast
}
);
});
});
}
Edge Cases Handled:
- Array containing mixed primitives and promises:
[1, Promise.resolve(2), 'three']. - Faster subsequent promises do not corrupt output ordering because values are indexed directly:
results[index] = value. - Zero items (
[]) resolves synchronously in the next microtask without hanging.
Complexity:
- Time Complexity: $O(N)$ where $N$ is the number of promises.
- Space Complexity: $O(N)$ to store the results array.
Usage Example:
promiseAll([
Promise.resolve(10),
new Promise((r) => setTimeout(() => r(20), 50)),
30
]).then((values) => console.log(values)); // Output: [10, 20, 30]
Likely Follow-ups:
- How does
Promise.allSettleddiffer fromPromise.all? —Promise.allSettlednever fails fast; it waits for all promises to settle and returns objects indicating status:{ status: 'fulfilled', value }or{ status: 'rejected', reason }.
4. Implement a Recursive deepClone with Circular References
Requirements
Write a function deepClone(value) that creates a complete, independent deep clone of an arbitrary JavaScript value. It must handle nested objects, arrays, primitive types, Date objects, RegExp instances, Map, Set, and prevent infinite loops when encountering circular references.
Clarifying Questions to Ask the Interviewer:
- Circular references: Can the object graph reference itself? (Yes, handle circular structures gracefully).
- Special types: Should
Date,RegExp,Map, andSetbe preserved as instances? (Yes, clone their internal values). - Prototype preservation: Should the prototype of custom classes or objects be maintained? (Yes, use
Object.create(Object.getPrototypeOf(value))).
Approach
- Base case: If
valueis null or not an object (typeof value !== 'object'), return it directly (primitives are immutable). - Use a
WeakMappassed down recursively to store mapped object references (map.set(value, clone)). If the current object was already visited, return its cached clone to avoid infinite recursion. - Check instance types:
Date: Returnnew Date(value.getTime()).RegExp: Returnnew RegExp(value.source, value.flags).Map: Instantiate a newMapand recursively clone keys and values.Set: Instantiate a newSetand recursively clone elements.
- Construct target container:
Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value)). - Iterate over all own properties (including Symbol keys) using
Reflect.ownKeys(value)and recursively clone each value.
Implementation:
function deepClone(value, map = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value; // Primitives and functions
}
// Handle circular references
if (map.has(value)) {
return map.get(value);
}
// Special object types
if (value instanceof Date) {
return new Date(value.getTime());
}
if (value instanceof RegExp) {
return new RegExp(value.source, value.flags);
}
if (value instanceof Map) {
const copy = new Map();
map.set(value, copy);
value.forEach((v, k) => {
copy.set(deepClone(k, map), deepClone(v, map));
});
return copy;
}
if (value instanceof Set) {
const copy = new Set();
map.set(value, copy);
value.forEach((v) => {
copy.add(deepClone(v, map));
});
return copy;
}
// Handle plain objects and arrays while preserving prototypes
const copy = Array.isArray(value)
? []
: Object.create(Object.getPrototypeOf(value));
map.set(value, copy);
for (const key of Reflect.ownKeys(value)) {
copy[key] = deepClone(value[key], map);
}
return copy;
}
Edge Cases Handled:
- Circular references (
obj.self = obj) do not trigger a stack overflow. - Symbol property keys are preserved via
Reflect.ownKeys(). - Prototype chains on custom constructor instances are preserved.
Complexity:
- Time Complexity: $O(N)$ where $N$ is the total count of properties and nested objects.
- Space Complexity: $O(N)$ to store cloned copies and the
WeakMaprecursion tracker.
Usage Example:
const original = { name: 'Alpha', date: new Date(), meta: { count: 1 } };
original.self = original; // Circular reference
const clone = deepClone(original);
console.log(clone !== original && clone.self === clone); // Output: true
Likely Follow-ups:
- Why not use
JSON.parse(JSON.stringify(obj))? — It silently drops functions,undefined, andSymbolkeys, throws on circular references, and convertsDateinstances into plain ISO strings.
5. Implement an EventEmitter (Pub/Sub Pattern)
Requirements
Build an EventEmitter class with four core methods:
on(eventName, listener): Registers a callback for the event; returns an unsubscribe function.emit(eventName, ...args): Synchronously invokes all listeners registered for the event, passing arguments.off(eventName, listener): Removes a specific listener.once(eventName, listener): Registers a listener that executes at most once and then automatically removes itself.
Clarifying Questions to Ask the Interviewer:
- Listener uniqueness: Can the same listener function be added multiple times? (Use a
Setor handle duplicates gracefully). - Execution order: Should listeners execute synchronously in the order they were registered? (Yes, preserve FIFO order).
- Unsubscribe convenience: Should
on()return an unbind function? (Yes, standard modern pattern).
Approach
- Use a
Mapwhere each key is an event name string and the value is aSetof callback functions. on(event, listener): Addlistenerto the set; return a closure that callsthis.off(event, listener).emit(event, ...args): Clone the listener list (to prevent mutation during iteration if a listener callsoff) and execute each withargs.off(event, listener): Remove the listener from the set; clean up empty sets from the map.once(event, listener): Create a wrapper callback that unsubscribes itself upon invocation, then callslistener(...args).
Implementation:
class EventEmitter {
constructor() {
this.events = new Map();
}
on(eventName, listener) {
if (typeof listener !== 'function') {
throw new TypeError('Listener must be a function');
}
if (!this.events.has(eventName)) {
this.events.set(eventName, new Set());
}
this.events.get(eventName).add(listener);
// Return an unsubscribe helper
return () => this.off(eventName, listener);
}
emit(eventName, ...args) {
if (!this.events.has(eventName)) {
return false;
}
// Clone listeners to avoid mutation bugs during iteration
const listeners = Array.from(this.events.get(eventName));
listeners.forEach((listener) => {
listener.apply(this, args);
});
return true;
}
off(eventName, listener) {
if (!this.events.has(eventName)) {
return this;
}
const listeners = this.events.get(eventName);
listeners.delete(listener);
if (listeners.size === 0) {
this.events.delete(eventName);
}
return this;
}
once(eventName, listener) {
const onceWrapper = (...args) => {
this.off(eventName, onceWrapper);
listener.apply(this, args);
};
return this.on(eventName, onceWrapper);
}
}
Edge Cases Handled:
- Mutating listeners inside a callback (e.g. calling
offinsideemit) does not cause skipped executions because listeners are copied viaArray.from()before iteration. - Memory leak prevention: when all listeners for an event are removed, the event key is deleted from the
Map. oncecorrectly cleans itself up even ifoffis called before the event ever fires.
Complexity:
- Time Complexity:
on: $O(1)$emit: $O(K)$ where $K$ is the number of listeners for that event.off: $O(1)$ usingSet.delete().
- Space Complexity: $O(N)$ where $N$ is total registered callbacks.
Usage Example:
const emitter = new EventEmitter();
const unsubscribe = emitter.on('login', (user) => console.log(`Welcome ${user}!`));
emitter.emit('login', 'Alice'); // Output: 'Welcome Alice!'
unsubscribe();
emitter.emit('login', 'Alice'); // No output
Likely Follow-ups:
- How would you handle asynchronous listeners? — Modify
emitto execute listeners withPromise.all(listeners.map(fn => fn(...args)))if async coordination is required.
Preparation Checklist
- Write
debounceandthrottlefrom memory without referencing external documentation in under 10 minutes. - Practice explaining why
Promise.allrequires tracking a completed count instead of comparingresults.length. - Diagram how
WeakMaptracks circular object references in recursive functions. - Implement an
EventEmitterthat supportsonce()and snapshot iteration to prevent mutation bugs. - Memorize the exact signature differences between
leadingandtrailingthrottle timers. - Review how to preserve
thiscontext usingfn.apply(this, args)across all utility closures.
Conclusion
Live coding interviews test whether you write resilient, production-ready code under pressure. By mastering the fundamental patterns behind debounce, throttle, Promise.all, deepClone, and EventEmitter—and articulating edge cases like cancellation, fail-fast mechanics, and circular references—you will demonstrate senior-level fluency in core JavaScript.