A frontend coding example is defined by what gets graded: a returned value (utility implementations like reduce, debounce and deepClone), rendered DOM (UI builds like an accordion or a filterable todo list), or the ordering and timing of side effects (a Promise.all polyfill, retry with backoff, a cancellable search). The eight examples below each come with the five assertions a hidden test suite runs and the documented edge case that fails most candidates:
[].reduce(fn)throws a TypeError,[1, 2, , 4].reduce((a, b) => a + b)is 7 because holes are skipped,Promise.all([])is already fulfilled the moment it returns, andstructuredClonethrows a DataCloneError on a function.
What counts as a coding example in a frontend interview
Classify a problem by the artifact you are graded on. There are three:
A returned value means the test calls your function and compares what comes back. These are utility implementations: polyfills of built-ins, small library functions, data transforms. Debounce belongs here even though it involves timers, because what you hand back is a function, and the grader calls that function and counts invocations. No DOM is involved.
Rendered DOM means the test mounts something, fires events, and queries the resulting tree. These are UI builds: accordions, tabs, forms, lists. An autocomplete is a UI build even though it fetches, because the assertion reads the option list on screen.
Ordering and timing of side effects means the test cares about when things happen and in what sequence: which request was aborted, in what order results arrive, how long a retry waited. These are async coordination problems, and they are graded with fake timers and call-order assertions rather than snapshots.
The fourth thing you will face is a language-agnostic algorithm question, and this guide does not cover it. Carlos Arguelles's widely shared question is the type specimen: two log files of (timestamp, page id, customer id) for consecutive days, find the customers who visited on both days and viewed at least two unique pages overall. Nothing about that is frontend. It could be answered in any language, and the general lists already cover this ground well. CoderPad's interview question library advertises questions for more than 42 languages and frameworks, indexed by technology. Forage's list of 45 coding interview questions splits into programming questions, conceptual questions about data structures, and behavioural ones. Both are useful for that fourth category, and CoderPad's framework pages go further than that: its React page publishes worked answers with full solution code, a navbar rendering a links prop, a todo list, fix-the-code exercises. What neither publishes is the assertion list, the specific expect() calls a hidden suite runs against your answer. For hash-map counting and two-pointer work in a JavaScript context, the JavaScript coding interview guide and the language-agnostic algorithm set go into it.
On sizing: a 25 to 45 minute slot realistically fits one utility plus its follow-ups, or one UI build with two features, or one async orchestration problem. Not more. If you have practised only 60-minute marathons you have practised the wrong thing.
Utility implementations: reduce, debounce, deepClone
Example 1: implement Array.prototype.reduce
The prompt, as an interviewer says it. "Write myReduce(array, callback, initialValue) that behaves like the built-in. Don't call Array.prototype.reduce inside it."
The five assertions a grader runs.
- With an initial value,
myReduce([1, 2, 3], (a, b) => a + b, 0)is6. - Without an initial value, the first element becomes the accumulator, so the same sum is
6and the callback runs twice, not three times. myReduce([], fn)throws a TypeError;myReduce([], fn, 0)returns0and never calls the callback.- The callback receives
(accumulator, currentValue, currentIndex, array), with the fourth argument being the original array. - Holes in a sparse array are skipped, and an explicit
undefinedis not.
The edge case that fails candidates. Assertions 3 and 5 together. Most people write let acc = initialValue ?? array[0], which breaks when the caller genuinely passes undefined or null as the initial value, and returns undefined instead of throwing on an empty array.
console.log([1, 2, , 4].reduce((a, b) => a + b));
console.log([1, 2, undefined, 4].reduce((a, b) => a + b));
try {
[].reduce((a, b) => a + b);
} catch (e) {
console.log(e.name);
}
7
NaN
TypeError
The callback is invoked only for indexes with assigned values, so the hole in [1, 2, , 4] is passed over entirely and the sum is 7. Slot two holds a real undefined, so the callback runs and 1 + 2 + undefined poisons the accumulator.
The reference approach. Use rest parameters to tell "no initial value" apart from "an initial value that happens to be undefined", and use the in operator to detect holes.
function myReduce(array, callback, ...rest) {
const len = array.length;
let i = 0;
let acc;
if (rest.length > 0) {
acc = rest[0];
} else {
while (i < len && !(i in array)) i++;
if (i >= len) throw new TypeError('myReduce: empty array with no initial value');
acc = array[i++];
}
for (; i < len; i++) {
if (i in array) acc = callback(acc, array[i], i, array);
}
return acc;
}
console.log(myReduce([1, 2, , 4], (a, b) => a + b));
try {
myReduce([], (a, b) => a + b);
} catch (e) {
console.log(e.name);
}
7
TypeError
const len = array.length on the first line is not a style choice. Array iterative methods memorize the length before the loop starts, so elements appended during iteration are never visited, while changes to existing elements that have not been reached yet are observed. Say that out loud when you write the line.
const nums = [1, 2, 3];
const total = nums.reduce((acc, v) => {
nums.push(v * 10);
return acc + v;
}, 0);
console.log(total);
console.log(nums.join(','));
6
1,2,3,10,20,30
A common follow-up in the same slot is sort. Without a comparator, sort converts elements to strings and compares UTF-16 code unit sequences, which is why [1, 30, 4, 21, 100000].sort() gives [1, 100000, 21, 30, 4]. It sorts in place and returns a reference to the same array, all undefined elements go to the end without the comparator ever seeing them, and the sort has been guaranteed stable since ES2019.
Example 2: debounce
The prompt. "Write debounce(fn, wait). Rapid calls collapse into one, which runs after wait milliseconds of quiet."
The five assertions. All of them run under jest.useFakeTimers().
- Calling the debounced function once and advancing to
wait - 1gives zero invocations; advancing one more millisecond gives exactly one. - Three calls inside the window produce one invocation, and it receives the arguments of the last call.
thisand all arguments are forwarded, soobj.debouncedMethod('a')seesobjasthis.cancel()drops the pending invocation, so advancing pastwaitcalls nothing.flush()invokes the pending call immediately, before the clock advances.
test('collapses rapid calls into one', () => {
const spy = jest.fn();
const debounced = debounce(spy, 100);
debounced('a');
debounced('b');
jest.advanceTimersByTime(99);
expect(spy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('b');
});
The edge case that fails candidates. Writing const debounced = (...args) => {...}. An arrow function has no own this, so assertion 3 fails and you cannot forward the receiver. The other one is forgetting that lodash's option defaults are leading: false and trailing: true, so the plain version fires at the end, not the start. If both are true, lodash invokes on the trailing edge only when the debounced function was called more than once during the wait.
The reference approach.
function debounce(fn, wait) {
let timer = null;
let pending = null;
function debounced(...args) {
pending = { context: this, args };
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const { context, args: lastArgs } = pending;
pending = null;
fn.apply(context, lastArgs);
}, wait);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
pending = null;
};
debounced.flush = () => {
if (!timer) return;
clearTimeout(timer);
timer = null;
const { context, args } = pending;
pending = null;
fn.apply(context, args);
};
return debounced;
}
Lodash also documents a maxWait option, the ceiling on how long func may be delayed. Mention it as the extension you would add and move on.
Example 3: deepClone
The prompt. "Write deepClone(value) handling nested objects and arrays, Dates, Maps, Sets and circular references."
Why they ask when structuredClone exists. Because it does a specific job with specific gaps. It deep-clones structured-cloneable values and preserves circular references, and has been available across every major browser since March 2022 (Baseline: widely available). It throws a DataCloneError on Function objects and on DOM nodes, does not duplicate property descriptors (getters and setters are flattened to plain values), does not walk or duplicate the prototype chain, and does not preserve a RegExp's lastIndex.
const source = { a: 1, get b() { return this.a + 1; } };
const copy = structuredClone(source);
source.a = 10;
console.log(source.b, copy.b);
try {
structuredClone({ format: (n) => n.toFixed(2) });
} catch (e) {
console.log(e.name);
}
11 2
DataCloneError
copy.b is a frozen number, not a live getter. That single behaviour is the reason the question exists.
The five assertions.
expect(clone).toEqual(source)passes whileexpect(clone.nested).not.toBe(source.nested)also passes.- Mutating
clone.nested.xleavessource.nested.xuntouched. - Given
const a = {}; a.self = a;, the clone terminates andclone.self === clone. clone.date instanceof Date,clone.map.get('k')andclone.set.has(1)all behave, rather than arriving as empty objects.- Primitives and functions come back per whatever rule you stated (returning functions by reference is the usual choice; say so before you write it).
The edge case. Assertion 3. Without a WeakMap from source object to clone, a circular reference recurses until the stack dies. Create the empty target first, record it in the map, then fill it.
UI builds: an accordion and a filterable todo list
Both are graded on rendered DOM, so write the assertions with role-based queries. Testing Library's recommended priority is getByRole first, then getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, and getByTestId only when nothing else works. The principle is that tests should resemble how users interact with your code. A grader who reaches for getByRole and cannot find your button has found a real accessibility bug, not a test problem.
Example 4: an accordion
The prompt. "Build an accordion from a list of { id, title, content }. Clicking a header shows its panel."
Ask before you type. Can more than one panel be open at once? That question decides your entire state model: single-open is openId: string | null, multi-open is a Set of ids. Second question: can an open panel be collapsed by clicking its own header again? The ARIA Authoring Practices accordion pattern sets aria-disabled="true" on a header button precisely when its open panel cannot be collapsed, so the answer changes your markup.
What the APG requires, and what an interviewer notices silently: each header title sits in an element with role button, wrapped in a heading element with an appropriate aria-level. The button carries aria-expanded, true when its panel is visible. The button's aria-controls points at the panel's id. Enter or Space toggles the panel. Arrow-key navigation is not in the pattern at all, whose keyboard section lists only Enter or Space, Tab and Shift + Tab, so do not lose time on it.
The five assertions.
- On mount, every header button has
aria-expanded="false"and no panel text is in the document. - Clicking "Shipping" sets that button's
aria-expandedto"true"and renders its panel text. - In single-open mode, clicking "Returns" next sets "Shipping" back to
"false". getAllByRole('heading', { level: 3 })has one entry per section, each containing its own button, and every button'saria-controlsvalue matches its panel element'sid.- Focusing a header and pressing Enter toggles it, without a mouse click.
test('opening a second section closes the first', async () => {
render(<Accordion sections={sections} />);
const shipping = screen.getByRole('button', { name: 'Shipping' });
await userEvent.click(shipping);
expect(shipping).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(screen.getByRole('button', { name: 'Returns' }));
expect(shipping).toHaveAttribute('aria-expanded', 'false');
});
The edge case. Assertion 5, and the fix is to use a real <button>. A <div onClick> is not focusable and does not respond to Enter or Space, so you would have to reimplement tabIndex and key handling by hand. The second trap is keying open state by array index: reorder or filter the sections and the wrong panel opens. Practise this shape on FAQ Disclosure.
Example 5: a filterable todo list
The prompt. "Render a todo list with add, toggle complete, and All / Active / Completed filters."
Derived state versus stored state is the whole question. Store two things: the todos array and the current filter string. Compute the visible list during render. Candidates who store a third visibleTodos array spend the rest of the interview keeping it in sync, and the grader's assertion 5 is designed to catch exactly that staleness.
The reference approach. Two pieces of state, one list derived during render, and keys that are ids rather than array positions.
const FILTERS = {
all: () => true,
active: (todo) => !todo.done,
completed: (todo) => todo.done,
};
function TodoList() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [text, setText] = useState('');
const visible = todos.filter(FILTERS[filter]);
function add(event) {
event.preventDefault();
const title = text.trim();
if (!title) return;
setTodos((prev) => [...prev, { id: crypto.randomUUID(), title, done: false }]);
setText('');
}
function toggle(id) {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo))
);
}
return (
<>
<form onSubmit={add}>
<input value={text} onChange={(event) => setText(event.target.value)} />
</form>
<ul>
{visible.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
{todo.title}
</label>
</li>
))}
</ul>
{Object.keys(FILTERS).map((name) => (
<button key={name} onClick={() => setFilter(name)}>{name}</button>
))}
</>
);
}
The five assertions.
- Typing into
getByRole('textbox')and submitting adds onelistitemand clears the input. - Submitting an empty or whitespace-only value adds nothing.
getByRole('checkbox', { name: 'Buy milk' })toggles that item's completed state, which requires the checkbox to be labelled by the todo text.- Clicking the "Completed" filter leaves only the completed items, asserted with
getAllByRole('listitem'). - Toggling an item while the "Active" filter is on removes that row and leaves every other row's state intact.
The edge case. Assertion 5 combined with editing. If a row is in edit mode and you track that as editingIndex, switching the filter changes which todo sits at that index, so you are now editing a different item. Track it as editingId. The same reasoning applies to React keys and their equivalents elsewhere: key by id, never by index, or the browser keeps input DOM state attached to the wrong row after a filter change. The fetch-and-filter variant of this is Job Board.
Async coordination: Promise.all, retry with backoff, cancellable search
Example 6: implement Promise.all
The prompt. "Write promiseAll(iterable) without calling Promise.all."
The five assertions.
- Results come back in input order even when the second promise settles first.
- It rejects immediately with the reason of the first promise to reject, without waiting for the others.
- Non-promise values pass through, so
promiseAll([1337, 'hi'])fulfills with[1337, 'hi']. promiseAll([])fulfills with[], and it is already fulfilled at the moment it is returned.- The input array is not mutated and every result slot is filled.
The edge case. Assertion 1. results.push(value) inside each .then records completion order, not input order. Assign by index instead, and track completion with a counter, because checking results.length === input.length gives a false positive when index 0 is still pending and index 1 has landed.
Assertion 4 is the one nobody expects to be testable. Promise.all resolves synchronously if and only if the iterable passed is empty, so Promise.all([]) is fulfilled when it is handed back while Promise.all([1337, 'hi']) is still pending. The .then callback still runs in a microtask, just the earliest one available:
Promise.all([]).then(() => console.log('empty settles first'));
Promise.all([1337, 'hi']).then(() => console.log('two values settle later'));
Promise.resolve().then(() => console.log('one microtask tick'));
empty settles first
one microtask tick
two values settle later
The two plain values each need a microtask to be wrapped and observed, which pushes their combinator behind a Promise.resolve() queued after it. If your polyfill logs in a different order, it is resolving the empty case asynchronously.
Expect a follow-up naming the siblings. Promise.allSettled waits for every input promise to settle and reports both fulfilled and rejected results, so it never rejects. Promise.any rejects with an AggregateError containing all the rejection reasons only when every input rejects. Orchestration patterns of this shape are drilled in async parallel / series.
Example 7: retry with exponential backoff
The prompt. "Write retry(fn, { retries: 3, baseDelay: 100, signal }) that re-runs a failing async function with exponentially growing delays and rejects with the last error." The signal usually arrives as the follow-up rather than in the opening sentence; assume it from the start and assertion 5 costs you nothing.
The five assertions, all under fake timers.
- A function that succeeds first time is called once and no timer is scheduled.
- A function that fails twice then succeeds resolves with the value, having been called three times.
- The delay sequence is asserted at its boundaries: after the first failure, advancing 99ms retries nothing and advancing 1 more retries once. Then 200ms. Then 400ms.
- When retries are exhausted, the promise rejects with the last error, asserted by message, not merely "it rejected".
- Aborting mid-wait stops the schedule, so no further calls happen after the abort.
The edge case that fails candidates. Assertion 3, and it is a microtask problem wearing a timer costume. The next setTimeout is scheduled inside the rejection handler, so it does not exist yet when advanceTimersByTime(100) returns, and the 200ms and 400ms boundaries never arrive. Flush the pending promise callbacks between advances, which is what await jest.advanceTimersByTimeAsync(100) does for you. The second trap is the off-by-one: retries: 3 is one initial call plus three retries, four invocations in total, and the "last error" of assertion 4 is the fourth one's.
The reference approach. One promise, one attempt counter, and an abort listener that clears the pending timer so assertion 5 has something to observe.
function retry(fn, { retries = 3, baseDelay = 100, signal } = {}) {
return new Promise((resolve, reject) => {
let attempt = 0;
let timer = null;
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
const settle = (done) => (value) => {
signal?.removeEventListener('abort', onAbort);
done(value);
};
const run = () => {
if (signal?.aborted) return onAbort();
Promise.resolve()
.then(fn)
.then(settle(resolve), (error) => {
if (attempt >= retries || signal?.aborted) return settle(reject)(error);
const delay = baseDelay * 2 ** attempt;
attempt += 1;
timer = setTimeout(run, delay);
});
};
run();
});
}
Example 8: a cancellable search
This one is the fetch-orchestration layer underneath an autocomplete, not the component. The grader never renders anything: it inspects which requests were aborted and what the caller received.
The prompt. "Write createSearch(fetcher) returning a search(query) that cancels the in-flight request when a newer query arrives, so a slow early response can never overwrite a fast later one."
The reference approach. One AbortController per call to cancel the network work, plus a monotonic request id to guard against a fetcher that ignores the signal.
function createSearch(fetcher) {
let controller = null;
let latest = 0;
return function search(query) {
controller?.abort();
controller = new AbortController();
const { signal } = controller;
const id = ++latest;
return fetcher(query, { signal }).then((results) => {
if (id !== latest) throw signal.reason;
return results;
});
};
}
The five assertions.
- A single search resolves with the fetcher's value.
- A second search issued before the first settles leaves the first signal with
aborted === true. - The stale response never reaches the caller, even when the first fetch resolves after the abort.
- An aborted request rejects with a
DOMExceptionwhosenameisAbortError, and your wrapper swallows that specific error instead of surfacing it as a failure. - Listeners and controllers are cleaned up, so nothing is left subscribed after the last search settles.
The edge case. Assertion 3, because abort is not a time machine. A response that already resolved before you called abort() still arrives at your .then. Guard with a monotonically increasing request id and drop any result whose id is not the latest, in addition to aborting.
Two details separate a good answer here. AbortSignal.timeout() rejects with a TimeoutError DOMException rather than an AbortError, which is how you tell "the server was too slow" from "the user typed another character", and those deserve different UI. AbortSignal.any() returns a signal that aborts when any of the given signals abort, which is how you combine a per-request timeout with the user's cancellation. Wire it to a UI on Basic Autocomplete.
The assertions that decide pass or fail
Any prompt converts into five tests. Write them before the implementation, in this order: the happy path, the empty or zero case, the documented throw, the timing case, and the cleanup case. Not every problem fills all five, and saying "there's no throw to assert here, so I'll test the boundary twice" is itself a good signal.
| Test slot | reduce polyfill | debounce | cancellable search |
|---|---|---|---|
| Happy path | [1, 2, 3] sums to 6 | one invocation after the wait | resolves with the fetcher's value |
| Empty or zero | [] with an initial value returns it, callback never runs | zero calls means zero invocations and no pending timer | an empty query issues no request |
| Documented throw | [] with no initial value throws a TypeError | none; assert cancel() leaves nothing pending instead | aborted fetch rejects with AbortError |
| Timing | none; it is synchronous | nothing at wait - 1, one call at wait | the second search aborts the first before it settles |
| Cleanup | the input array is not mutated | flush() after cancel() invokes nothing | listeners removed, controller released |
The timer APIs worth knowing by name: jest.useFakeTimers() to switch, jest.advanceTimersByTime(msToRun) to move the clock a known amount, jest.runAllTimers() to drain everything, jest.runOnlyPendingTimers() for recursive timers, jest.clearAllTimers() to discard, and jest.useRealTimers() in afterEach so one test's fake clock does not leak into the next. For DOM assertions, start at getByRole and treat getByTestId as the admission that nothing else worked. Building the harness yourself is its own good exercise: see Test Runner.
Green tests do not mean you passed the interview. Arguelles, who describes himself as a Senior Principal Engineer at Amazon and says he has conducted over 1,000 interviews, writes that "the conversation is much more important to me than the actual lines of code my candidate writes on the whiteboard". On his two-log-file problem, around 80% of candidates first reach for the O(n²) nested loop, and the strong answer is a Map from customer id to a Set of page ids. Reaching for the slow version is not disqualifying. Reaching for it silently, and never noticing, is. Narrate the trade-off, state the complexity, and say which assertion you are about to satisfy. A full 35-minute walkthrough shows what that sounds like end to end.
The same example in React, Vue, Angular and vanilla JS
Everything in this article that is a rule about JavaScript transfers unchanged across all four. The TypeError on empty-array reduce, holes being skipped, the string comparator in sort, Promise.all's input ordering and its synchronously fulfilled empty case, structuredClone's DataCloneError, the 4ms clamp: none of those care what renders your markup. Neither does the state model. Single-open versus multi-open is a decision about data, and it is the same decision in a Vue ref as in a React useState.
What genuinely changes is smaller than it looks, and it is mostly cleanup. Where derived state lives differs: computed during render in React, a computed in Vue, a computed signal or getter in Angular. Teardown differs: React returns a cleanup function from the effect, Vue uses onUnmounted, Angular tears down in ngOnDestroy. And listener removal in vanilla is one line, because addEventListener accepts a signal option that removes the listener when the associated controller's abort() is called, so a single controller.abort() detaches every listener you registered with it. The once option is the other one worth remembering: the listener fires at most once and removes itself.
That is why solving one problem twice in two frameworks teaches more than two problems once. The second pass isolates the framework-shaped part, because everything else is already decided, and the framework-shaped part is what you will be asked to defend. Every UIReady question is solvable in React, Vue, Angular or vanilla TypeScript from the same prompt and the same tests, which makes the comparison direct. If your loop is framework-specific, React interview questions and Angular interview questions narrow it down.
How to rehearse these under a timer
Read the prompt and write down the ambiguities first, one line each. Single-open or multi? Leading edge or trailing? What happens to the in-flight request? In a real interview you ask these out loud; alone, write them and answer them yourself, because the habit is what you are building.
Then write the five assertions. Before any implementation. Five minutes, no more. This is the step nobody practises and the one that changes the outcome, because a failing test tells you what to write next and a blank file does not.
Now set 25 minutes and narrate as you type, out loud, in an empty room. Talking while coding is a separate skill from coding, and it degrades under stress unless rehearsed.
At the 10-minute mark, if nothing passes, stop optimising and write the dumbest version that satisfies assertion 1. Nested loops are fine. Say what you would change and why, then keep going. A working slow answer with a stated improvement beats an elegant half-answer that runs nothing.
Only after the timer expires should you read a solution.
Rehearse in a workspace that runs the tests, not a text box. UIReady's catalogue is 660+ questions, 470+ of them free, each in a Sandpack workspace with description, code, tests and preview panes. Sandpack is a component toolkit for live-running code editing experiences powered by the online bundler used on CodeSandbox, and its SandpackTests component is a thin wrapper around Jest that runs tests in the browser, picking up .test.js(x), .spec.js(x), .test.ts(x) and .spec.ts(x) files and supporting describe, it and expect including expect.extend. It runs in a browser environment, so console output and object rendering are more limited than a terminal Jest run; treat it as the assertion loop rather than a full local setup. If you want the whole catalogue permanently, UIReady Premium's lifetime option covers it.