JavaScript Coding Challenges
“Implement this from scratch” questions are an interview staple because they reveal whether you understand the building blocks — closures, recursion, promises, and the iteration protocols — rather than just the library that wraps them. Each challenge below comes with a clean, runnable solution and a short note on the idea that makes it work. Type them out, predict the output, and make sure you can rebuild each one without looking.
Implement debounce
Debounce delays a function until activity stops, so rapid calls collapse into a single one — perfect for search-as-you-type. The trick is a closure holding a timer that every call resets.
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const log = debounce((q) => console.log("search:", q), 300);
log("a"); log("ab"); log("abc"); // only "search: abc" fires, once
Implement throttle
Throttle guarantees at most one call per interval — ideal for scroll and resize handlers. A boolean gate blocks calls until the cooldown elapses.
function throttle(fn, limit) {
let waiting = false;
return function (...args) {
if (waiting) return;
fn.apply(this, args);
waiting = true;
setTimeout(() => (waiting = false), limit);
};
}
| Technique | Fires | Use case |
|---|---|---|
| Debounce | After the pause | Autocomplete, validation |
| Throttle | At a steady rate | Scroll, drag, resize |
Deep clone an object
A shallow copy shares nested references; a deep clone duplicates everything. Modern engines ship structuredClone, but interviewers usually want the recursive version that shows you understand the structure.
function deepClone(value) {
if (value === null || typeof value !== "object") return value;
if (value instanceof Date) return new Date(value);
if (Array.isArray(value)) return value.map(deepClone);
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, deepClone(v)]),
);
}
const original = { user: { name: "Ada" }, tags: [1, 2] };
const copy = deepClone(original);
copy.user.name = "Linus";
console.log(original.user.name);
Output:
Ada
structuredClone(obj)handles cycles, Maps, and Sets natively — reach for it in real code and reserve the hand-rolled version for interviews.
Flatten a nested array
Array.prototype.flat(Infinity) does this in one call, but the recursive reduce version is the canonical interview answer.
function flatten(arr) {
return arr.reduce(
(acc, item) =>
acc.concat(Array.isArray(item) ? flatten(item) : item),
[],
);
}
console.log(flatten([1, [2, [3, [4]], 5]]));
Output:
[ 1, 2, 3, 4, 5 ]
Implement curry
Currying collects arguments until there are enough to call the original function. Compare the count of received args against fn.length (its declared arity).
function curry(fn) {
return function curried(...args) {
return args.length >= fn.length
? fn.apply(this, args)
: (...rest) => curried.apply(this, [...args, ...rest]);
};
}
const add = curry((a, b, c) => a + b + c);
console.log(add(1)(2)(3), add(1, 2)(3), add(1, 2, 3));
Output:
6 6 6
Implement memoize
Memoization caches results by argument key so a pure function never recomputes the same answer.
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const slowSquare = memoize((n) => n * n);
console.log(slowSquare(8), slowSquare(8)); // second call is cached
Build a Promise from scratch
Reimplementing the core of a promise proves you understand the resolve/reject state machine and that then returns a new promise.
class MyPromise {
constructor(executor) {
this.callbacks = [];
this.state = "pending";
const resolve = (value) => {
if (this.state !== "pending") return;
this.state = "fulfilled";
this.value = value;
this.callbacks.forEach((cb) => cb(value));
};
executor(resolve, () => {});
}
then(onFulfilled) {
if (this.state === "fulfilled") onFulfilled(this.value);
else this.callbacks.push(onFulfilled);
return this;
}
}
new MyPromise((resolve) => setTimeout(() => resolve(42), 0))
.then((v) => console.log("resolved:", v));
Implement Promise.all
Promise.all resolves with an ordered array once every input settles, and rejects as soon as any one rejects.
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const results = [];
let remaining = promises.length;
if (remaining === 0) return resolve(results);
promises.forEach((p, i) => {
Promise.resolve(p).then((value) => {
results[i] = value;
if (--remaining === 0) resolve(results);
}, reject);
});
});
}
promiseAll([Promise.resolve(1), Promise.resolve(2), 3]).then(console.log);
Output:
[ 1, 2, 3 ]
Group an array by key
group-by turns a flat list into buckets — a reduce that pushes each item into an array keyed by some derived value.
function groupBy(arr, keyFn) {
return arr.reduce((acc, item) => {
const key = keyFn(item);
(acc[key] ??= []).push(item);
return acc;
}, {});
}
const people = [
{ name: "Ada", role: "dev" },
{ name: "Grace", role: "dev" },
{ name: "Alan", role: "ops" },
];
console.log(groupBy(people, (p) => p.role));
Output:
{ dev: [ { name: 'Ada', role: 'dev' }, { name: 'Grace', role: 'dev' } ],
ops: [ { name: 'Alan', role: 'ops' } ] }
Best Practices
- Reach for built-ins (
flat,structuredClone,Object.groupBy) in production; rebuild them only to demonstrate understanding. - Lean on closures for any utility that needs private state across calls (debounce, throttle, memoize).
- Use
Promise.resolve(value)to normalize inputs so your combinators accept both promises and plain values. - Pick a stable cache key strategy for memoization;
JSON.stringifyis fine for primitives but breaks on functions and circular data. - Always handle the empty-input edge case — an empty array should resolve
Promise.allimmediately, not hang. - Talk through your approach before coding; interviewers reward a clear plan over a silent scramble.