Full-Stack Questions
Live Coding and Take-Home Rounds
A 45-minute routine, a take-home checklist and six full-stack tasks with tested code.
On this page
In one line: a live-coding round and a take-home are not graded on "does it run". They are graded on how you work: you ask first, build the smallest working slice, test it, handle the sad paths and explain your choices. Practise a fixed routine, not clever tricks.
Lesson 17 (JavaScript coding utilities) covers small helpers such as debounce and Promise.all. A full-stack round is different. You get a small feature (an API, a list, a form) and you must build it end to end, with a real framework, in 45 to 60 minutes. Or you get it as a take-home: a few hours at home, reviewed later.
This lesson gives you the scoring rules, a 45-minute routine, a take-home checklist and six tasks that come up again and again. Where a rule is common practice and not a fact, I say so.
What I ran. Every code sample was run (and re-run while this lesson was reviewed): the Express API with Node's built-in test runner (Express 5.3, Zod 4.6, Node 22), and the React tasks with Vitest, jsdom and React Testing Library (React 19). The tests passed. Types are left out on purpose, so the code is short enough to type live.
Words used in this lesson
- A live-coding round is an interview where you write code while one or two engineers watch and talk with you. A take-home is a task you do alone and send back, usually with a time limit that is honour-based.
- A vertical slice is the thinnest piece of a feature that works from the top to the bottom (for example: one endpoint that creates a todo and stores it). A horizontal layer is one tier built for the whole feature (all the routes, then all the validation). Interviews reward slices.
- A race condition is a bug where the result depends on which of two things finishes first. In UI code it usually means an old response arrives after a new one.
- An
AbortControlleris an object that creates asignal. When you callcontroller.abort(), the signal tells a runningfetch(or any code that listens to it) to stop. Think of it as a "cancel" button for a request. - Debounce means "wait until the calls stop for N ms, then run once". Throttle means "run at most once every N ms". Difference in one line: debounce waits for quiet, throttle keeps a steady pace.
- Validation is checking that input has the right shape and values before you use it. A schema is a written description of that shape; Zod is a library that lets you write the schema in code and gives you a typed result.
- A controlled input is a form field whose value lives in React state, so React is the single source of truth.
- Idempotent means doing the same request many times has the same effect as doing it once.
- A rate limiter decides if a caller may do something right now. A job queue keeps work in a line and runs a few jobs at a time.
How these rounds are scored
Interview teams usually score a few things. The exact form changes from company to company, but this is the common pattern (this is common practice, not a standard):
| What they look at | What a strong signal looks like | What a weak signal looks like |
|---|---|---|
| Problem solving | Asks questions, splits the task, builds a small slice first | Starts typing at once, builds everything at the same time |
| Working code | The main path works and you can run it | Beautiful code that never ran |
| Correctness on edges | Empty input, errors, double clicks, slow network | Only the happy path |
| Code quality | Clear names, small functions, no repeated blocks | One 120-line function, names like data2 |
| Communication | Explains the plan, says trade-offs, accepts hints calmly | Silence, or defending a bug |
| Testing | One or two tests that prove the claim | "I would test it" and no test |
| Judgement | Says what you left out and why | Pretends it is finished |
A candidate with less code and a clear story usually beats one with more code and no story. A half-finished solution with a working core, a test and a list of "next steps" scores better than a full solution that crashes.
A 45-minute routine for a live round
Use the same five steps every time. The times are a guide, not a rule.
| Minutes | Step | What you do and say |
|---|---|---|
| 0 to 5 | Clarify | Repeat the task in your own words. Ask 3 to 5 questions: inputs, limits, error cases, what "done" means, which libraries are allowed. Write the answers as comments. |
| 5 to 10 | Plan | Name the pieces (route, schema, store; or component, hook, fetch function). Draw the data shape. Say the order you will build in. |
| 10 to 30 | Build the smallest slice | Make the main path work first. Run it. Then add one thing at a time: validation, errors, loading state. |
| 30 to 40 | Test | Write two or three tests, or a quick manual check, for the main path and the worst edge case. Run them. |
| 40 to 45 | Talk | Say what you built, what you cut, what breaks at scale and what you would do next. |
Good questions to ask in the first five minutes:
- "Is the data in memory, or should I use a real database?" (Almost always: memory.)
- "Can I use a library for validation, or should I write it?"
- "Should I focus on the server, the UI, or both?"
- "What should happen when the request fails?"
- "Do you want tests, and which tool is set up?"
- "Are AI assistants or web search allowed?" Follow the answer exactly. Rules differ between companies.
Do not spend 20 minutes on setup. If the editor has no project, say "I will keep this in one file and split it later". Do not argue about folder structure in a 45-minute round.
A take-home checklist
A take-home is read by a person who has 15 minutes and many submissions. Make their job easy.
| Item | What to do |
|---|---|
| README | Four short parts: how to run, how to test, what you built, what you decided and what you cut. Put the run command at the top. |
| It must run in one command | npm install && npm test && npm start. Test it on a clean folder before you send it. |
| Structure | Few files, clear names. Separate "what the app does" (logic) from "how it is shown" (routes, components). Do not copy a big template. |
| Tests | Test the core logic and the main flow. Ten good tests beat fifty weak ones. Tests must pass. |
| Error handling | Validate input, return clear errors, show loading and error states in the UI. One place shapes every error. |
| Commits | Small commits with clear messages show how you work. Do not commit node_modules or secrets. |
| What to cut | Authentication, a real database, pixel-perfect design, deployment: cut them unless asked, and say so in the README. |
| Time-box | If they say "3 hours", spend about that. Write the real time in the README. Going much over does not impress, and it may hide weak planning. |
Sections for the README "decisions" part (examples): "I stored todos in memory because the task allowed it; the store is behind one module, so swapping in MongoDB changes one file." "I used offset pagination because the list is small; for big lists I would use a cursor."
Interview questions
(Must know) How is a live-coding round scored, and what is the biggest mistake?
I treat it as a conversation that has code in it. The interviewer writes notes under headings like problem solving, working code, edge cases, quality and communication. Nobody is only checking the final output.
The biggest mistake I see is going silent and typing. The second is building everything at once, so nothing runs when time ends. The third is skipping questions and solving the wrong problem for 20 minutes.
What I do instead: I ask questions first, build the smallest working slice, run it often, and say my reasons out loud. If I am not sure, I say "I will pick X for now because Y, and we can change it". An interviewer cannot reward thinking they cannot hear.
Follow-up: what if my solution has a bug I cannot find? I stop typing and narrate. "The output is 3, I expected 2. The list is filtered before the sort, so I will print the list between the two steps." A calm, systematic search is a strong signal. Guessing quickly in silence is a weak one. See Debugging, Lesson 0 for a fuller method.
(Must know) Walk me through your 45-minute routine.
Five steps: clarify, plan, build the smallest slice, test, talk.
- Clarify (5 min). I repeat the task and ask about inputs, limits, error cases and what "done" means. I write the answers at the top of the file as comments.
- Plan (5 min). I name the pieces and the data shape. For an API: routes, one schema per body, a store. For a UI: component, hook or function for data, and the states (idle, loading, error, empty, done).
- Build the smallest slice (20 min). I get one path working end to end, and I run it. Then I add one thing at a time.
- Test (10 min). Two or three tests: the happy path, and the one edge case most likely to break.
- Talk (5 min). I say what I built, what I left out, and what I would do with more time.
The key habit is running the code after every small step. If it breaks, I know which ten lines broke it.
(Senior signal) The interviewer changes the requirements half-way. What do you do?
I treat it as a good sign: they want to see how my code and I handle change. I do three things. First, I repeat the new rule to be sure I understood it. Second, I say which part of my code is affected: "The sort is a pure function, so only that function changes." Third, I change it and re-run my test.
This is where early structure pays off. If my filter, sort and paging logic is a plain function (as in Task 4 below), a new rule is a five-line change. If it is mixed into a component, it is a rewrite. I never say "that was not in the first spec". I may ask: "Do you want me to keep the old behaviour too?"
(Must know) What do you put in the README of a take-home?
Four things, in this order: how to run it and test it (the exact commands), what I built (two or three lines), decisions and trade-offs, and what I cut or would do next.
Example of a decision line: "Todos live in memory behind createApp(). To use MongoDB, I would replace the Map with a repository with the same four methods." Example of a cut line: "No auth, because the brief did not ask for it. I would add a session cookie and check the owner in each route."
I also write the real time I spent. Reviewers read the README before the code. A clear README tells them where to look and that I know my own limits.
(Senior signal) How do you time-box a take-home and decide what to cut?
I write the plan in the first ten minutes: the list of features, sorted by value. I build in that order and stop when the time is up. The order is usually: core flow, validation and error handling, tests, README, then polish.
I cut in this order: deployment, authentication (if not asked), visual polish, extra features, abstractions I do not need yet. I never cut tests for the core logic, input validation, or the README. I would rather send a smaller app that is clean and tested than a bigger one that is fragile. And I write the cuts into the README, so the reviewer sees a decision, not a gap.
If the brief says "do not spend more than 3 hours", I respect it. Many teams say they can tell, and over-building suggests I cannot prioritise.
(Must know) Task 1: build a todo CRUD API in Express with validation and tests.
The task. POST /todos, GET /todos (filter by done, with limit and offset), GET/PATCH/DELETE /todos/:id. In memory. Validate input. One error format. Tests.
Plan I say out loud. "I will export createApp() so tests can start a fresh app. I will validate with Zod in a small middleware. All errors go to one error handler. I will return 201 with a Location header on create, 204 on delete, 404 for a missing todo and 422 for invalid values."
import express from 'express';import { z } from 'zod';const createSchema = z.strictObject({ title: z.string().trim().min(1).max(100),});const patchSchema = z .strictObject({ title: z.string().trim().min(1).max(100).optional(), done: z.boolean().optional(), }) .refine((v) => Object.keys(v).length > 0, { message: 'Send at least one field' });const listSchema = z.object({ done: z.enum(['true', 'false']).optional(), limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0),});class HttpError extends Error { constructor(status, message, details) { super(message); this.status = status; this.details = details; }}// Middleware factory: validate one part of the request and replace it with the parsed value.const validate = (schema, part = 'body') => (req, _res, next) => { const result = schema.safeParse(req[part]); if (!result.success) { const details = result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })); return next(new HttpError(422, 'Validation failed', details)); } // In Express 5 req.query is a getter, so store the parsed value somewhere else. req.valid = { ...req.valid, [part]: result.data }; next();};export function createApp() { const app = express(); const todos = new Map(); let nextId = 1; app.use(express.json({ limit: '10kb' })); app.get('/todos', validate(listSchema, 'query'), (req, res) => { const { done, limit, offset } = req.valid.query; let items = [...todos.values()]; if (done) items = items.filter((t) => String(t.done) === done); res.json({ items: items.slice(offset, offset + limit), total: items.length }); }); app.post('/todos', validate(createSchema), (req, res) => { const todo = { id: nextId++, title: req.valid.body.title, done: false }; todos.set(todo.id, todo); res.status(201).location(`/todos/${todo.id}`).json(todo); }); const findTodo = (req) => { const todo = todos.get(Number(req.params.id)); if (!todo) throw new HttpError(404, 'Todo not found'); return todo; }; app.get('/todos/:id', (req, res) => res.json(findTodo(req))); app.patch('/todos/:id', validate(patchSchema), (req, res) => { const todo = Object.assign(findTodo(req), req.valid.body); res.json(todo); }); app.delete('/todos/:id', (req, res) => { findTodo(req); todos.delete(Number(req.params.id)); res.status(204).end(); }); app.use((_req, _res, next) => next(new HttpError(404, 'Route not found'))); // Error handler: four arguments. One place that shapes every error. app.use((err, _req, res, _next) => { if (err.type === 'entity.parse.failed') err = new HttpError(400, 'Invalid JSON'); if (err.type === 'entity.too.large') err = new HttpError(413, 'Body too large'); const status = err.status ?? 500; if (status === 500) console.error(err); // never leak internals to the client res.status(status).json({ error: { message: status === 500 ? 'Internal error' : err.message, details: err.details }, }); }); return app;}Notes on the code:
createApp()returns a new app each time, so every test starts with an empty store. This is the cheapest way to make an API testable.z.strictObjectrejects unknown keys. Without it, a client could send{ "done": true, "admin": true }and the extra field might leak into a database (called mass assignment).- The validation middleware stores the parsed value in
req.valid. It does not overwritereq.query, because in Express 5req.queryis a read-only getter (I ran this: assigning to it throws aTypeError). - Query values are strings.
z.coerce.number()turns"20"into20, then.int().min(1).max(100)protects the server fromlimit=1000000. express.json({ limit: '10kb' })stops huge bodies. The error handler maps the parser's own errors to 400 and 413.
The tests use Node's built-in runner and fetch, so no extra package is needed:
test('validation errors are 422 with details', async () => { let res = await call('POST', '/todos', { title: '' }); assert.equal(res.status, 422); assert.equal((await res.json()).error.details[0].path, 'title'); res = await call('POST', '/todos', { title: 'x', admin: true }); // unknown key assert.equal(res.status, 422);});(call is a four-line helper around fetch; before starts the app on port 0, which means "any free port".) Three tests passed: the full create-read-update-delete flow, validation, and the parse, not-found and too-large errors.
Common mistakes.
- Putting validation inside every handler, so the rules are copied six times.
- Returning 200 for everything, or 500 for a bad body.
- Different error shapes from different routes.
- Letting
PATCHaccept an empty body or unknown keys. - No
limitcap on a list endpoint. - Using a
forloop over an array for lookups when aMapgives O(1). - Sending
err.messageof a real crash to the client.
What to say out loud. "I picked 422 for valid JSON with bad values and 400 for broken JSON. Some teams use 400 for both; the important point is to be consistent. The store is a Map, so it resets when the process restarts. Ids use a counter, which is fine in memory but I would use UUIDs or database ids in real life."
Extensions the interviewer asks next.
- Add a database. Move the
Mapbehind four functions (list,get,create,update). With MongoDB or Prisma, only that module changes. See Prisma, Lesson 8. - Add auth. A middleware that sets
req.user, plus an owner check in each route (todo.ownerId === req.user.id). See Auth, Lesson 22. - Make
POSTsafe to retry. Accept anIdempotency-Keyheader and store the first response under that key. See Backend, Lesson 2. - Cursor pagination instead of offset. See Design problems, Lesson 3.
- Add rate limiting. See Task 6 and Design problems, Lesson 2.
(Trap) In Express, what happens when an async route handler throws? Is it different in Express 4?
In Express 5, a rejected promise (or a thrown error) from an async handler is passed to the error handler automatically, as if you had called next(err). I ran this on Express 5.3: an async route that throws new Error('async boom') reached my four-argument error handler and returned my JSON error.
In Express 4, this did not happen. A thrown error in a sync handler was caught, but a rejection inside an async handler was not. The request hung until the client timed out, and Node could report an unhandled rejection. People wrapped handlers with asyncHandler(fn) or used the express-async-errors package.
So: on Express 5 I just write async (req, res) => { ... }. On Express 4 I wrap it. In an interview I say which version I am assuming, because the answer to "why did my request hang?" depends on it. The error handler must have four parameters (err, req, res, next), or Express will not treat it as an error handler.
(Must know) Task 2: build an infinite list in React with IntersectionObserver and request cancel.
The task. Show a list. When the user scrolls near the bottom, load the next page. Show loading, error with retry, and an end-of-list message. No duplicate requests. Cancel on unmount.
An IntersectionObserver is a browser object that calls your function when an element enters or leaves the visible area. We watch a tiny empty div (a sentinel) placed after the last item. When it becomes visible, the user is near the end. (Lesson 17, Q29 has the plain-DOM version; this is the React version with the traps.)
Plan I say out loud. "The state is the list and a status: idle, loading, error or done. The cursor and a 'loading' flag go in refs, because I must read them right now inside the observer callback. A stale state value would let two requests start."
import { useCallback, useEffect, useRef, useState } from 'react';// fetchPage(cursor, signal) -> Promise<{ items: string[], nextCursor: string | null }>export function InfiniteList({ fetchPage }) { const [items, setItems] = useState([]); const [status, setStatus] = useState('idle'); // idle | loading | error | done const cursorRef = useRef(null); const loadingRef = useRef(false); // a ref, not state: it must be readable right now const doneRef = useRef(false); const controllerRef = useRef(null); const sentinelRef = useRef(null); const loadMore = useCallback(async () => { if (loadingRef.current || doneRef.current) return; loadingRef.current = true; setStatus('loading'); const controller = new AbortController(); controllerRef.current = controller; try { const page = await fetchPage(cursorRef.current, controller.signal); if (controller.signal.aborted) return; cursorRef.current = page.nextCursor; setItems((prev) => [...prev, ...page.items]); doneRef.current = page.nextCursor == null; setStatus(doneRef.current ? 'done' : 'idle'); } catch (err) { if (controller.signal.aborted || err.name === 'AbortError') return; setStatus('error'); } finally { if (!controller.signal.aborted) loadingRef.current = false; } }, [fetchPage]); // Abort the in-flight request when the component goes away. useEffect( () => () => { controllerRef.current?.abort(); loadingRef.current = false; // so a remount (StrictMode) is not stuck }, [], ); // Watch the sentinel. A NEW observer reports the current state at once, // so a short page that leaves the sentinel visible loads the next page. useEffect(() => { const node = sentinelRef.current; if (!node || status === 'error' || status === 'done') return; const observer = new IntersectionObserver( (entries) => entries.some((e) => e.isIntersecting) && loadMore(), { rootMargin: '200px' }, ); observer.observe(node); return () => observer.disconnect(); }, [loadMore, items.length, status]); return ( <div> <ul>{items.map((text) => <li key={text}>{text}</li>)}</ul> {status === 'loading' && <p role="status">Loading...</p>} {status === 'error' && ( <p role="alert"> Could not load. <button onClick={loadMore}>Retry</button> </p> )} {status === 'done' && <p>End of list</p>} <div ref={sentinelRef} aria-hidden="true" /> </div> );}Tests use a fake IntersectionObserver (jsdom does not have one). The fake can report "visible" the moment observe() is called:
test('keeps loading while the sentinel stays visible, then stops (also in StrictMode)', async () => { const fetchPage = vi.fn(async (cursor) => pages[cursor]); render(<StrictMode><InfiniteList fetchPage={fetchPage} /></StrictMode>); await screen.findByText('End of list'); expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([null, 'p2', 'p3']); // no duplicates});Four tests passed: loading until done in StrictMode, not loading when the sentinel is hidden, abort on unmount, and error then retry.
Common mistakes.
- Using
loadingstate as the guard. State updates are not instant, so the callback can fire twice and load the same page twice. A ref is updated at once. - Using the page number in state and forgetting it moves after each await. A cursor from the server is safer.
- Listening to the
scrollevent and measuring heights by hand on every frame. - Not cleaning up the observer.
- Using the index as the
key. - No error state, so one failed request ends the scrolling for ever.
What to say out loud. "I use a ref for the in-flight flag because the observer callback must see the current value. I abort on unmount so a late response does not call setState on a removed component. rootMargin: '200px' starts the load before the user hits the very bottom."
Extensions.
- Thousands of items make the page slow. Use windowing (virtualization): render only the rows near the viewport. Mention a library such as TanStack Virtual, and that the sentinel then sits in the virtual list.
- Use TanStack Query. Its
useInfiniteQueryhandles pages, cache, retry and cancel. See State libraries, Lesson 25. In a real project I would use it; in an interview I may be asked to build it by hand to show I understand it. - Keep the scroll position when the user goes back. Cache the pages and restore them.
- The server returns duplicates when new items arrive. Use cursor pagination and de-duplicate by id.
(Trap) Your infinite list works, then stops after the first page, or loads the first page twice in dev. Why?
Two classic traps in the code above.
Trap 1: the sentinel stays visible, and nothing fires again. An IntersectionObserver calls you when the visibility changes. If page one is short and the sentinel is still on screen after it loads, there is no change, so no callback, and the list stops. My fix: the effect depends on items.length, so after each page a new observer is created. A new observer reports the current state at once. The first test above proves it: the fake reports "visible" and the list loads three pages one after another.
Trap 2: React StrictMode runs effects twice in development. It mounts, unmounts and mounts again to find bugs. The first request is aborted by my cleanup. If the cleanup did not reset the "loading" ref, the second mount would think a request is still running and do nothing: a stuck list. My cleanup sets loadingRef.current = false, and the finally block only resets the flag when its own request was not aborted, so an old request cannot unlock a newer one.
How I would explain the habit: "Every effect must be able to start, stop and start again, and every flag it sets must be reset when it stops."
(Must know) Task 3: build a search box with debounce and race-safe fetch.
The task. An input. After the user stops typing for 300 ms, call search(query). Show loading, results, "no results" and errors. An old slow response must never replace a newer one.
Plan I say out loud. "Two steps. First, a useDebouncedValue hook so the query only changes after a pause. Second, an effect that fetches when the debounced term changes, and in its cleanup cancels the old request and ignores its result."
import { useEffect, useState } from 'react';export function useDebouncedValue(value, delay) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); // a new keystroke cancels the old timer }, [value, delay]); return debounced;}// search(query, signal) -> Promise<string[]>export function useSearch(query, search, delay = 300) { const term = useDebouncedValue(query.trim(), delay); const [state, setState] = useState({ status: 'idle', results: [], error: null }); useEffect(() => { if (term === '') { setState({ status: 'idle', results: [], error: null }); return; } const controller = new AbortController(); // stops the old request on the network let ignore = false; // protects us even if the fetcher forgets the signal setState((s) => ({ ...s, status: 'loading', error: null })); // keep old results while loading search(term, controller.signal).then( (results) => { if (!ignore) setState({ status: 'success', results, error: null }); }, (error) => { if (!ignore && error.name !== 'AbortError') setState({ status: 'error', results: [], error }); }, ); return () => { ignore = true; controller.abort(); }; }, [term, search]); return state;}export function SearchBox({ search }) { const [query, setQuery] = useState(''); const { status, results, error } = useSearch(query, search); return ( <div> <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} /> {status === 'loading' && <p role="status">Searching...</p>} {status === 'error' && <p role="alert">{error.message}</p>} {status === 'success' && results.length === 0 && <p>No results</p>} <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul> </div> );}The tests use fake timers, and a fake backend where the test decides when each request finishes. The best one gives a fetcher that ignores the abort signal on purpose, to prove the ignore flag protects us:
type('ab'); await wait(300);type('abc'); await wait(300);expect(calls.map((c) => c.q)).toEqual(['ab', 'abc']);await act(async () => calls[1].resolve(['abc result']));await act(async () => calls[0].resolve(['ab result'])); // arrives lateexpect(screen.getByText('abc result')).toBeTruthy();expect(screen.queryByText('ab result')).toBeNull();All three tests passed: one request after a pause, no stale overwrite, and abort plus error plus clear.
Common mistakes.
- Debouncing inside the component body:
debounce(fn, 300)creates a new debounced function on each render, so the timer is never shared. - Fetching on every keystroke and trusting the order of responses.
- Forgetting the empty query (it sends a useless request).
- Treating
AbortErroras a real error and showing "Something went wrong". - Passing a
searchfunction that is recreated on every render, which makes the effect run again and again. Keep it stable, or define it outside the component. - Clearing the old results at the start of each load, which makes the list flash. I keep them until new ones arrive.
What to say out loud. "Debounce reduces how many requests I send. Abort and ignore fix a different problem: wrong order. I need both."
Extensions.
- Cache results per query so going back to a word is instant. In a real app: TanStack Query. See State libraries, Lesson 25.
- Keyboard navigation and accessibility (combobox pattern, arrow keys). See Frontend depth, Lesson 6.
- Highlight the matched text.
- Do it on the server side: typo tolerance with a search engine. See Meilisearch, Lesson 12. The system design view is in Design problems, Lesson 6.
(Trap) What is the difference between debounce, AbortController and the ignore flag? Why use all three?
They solve three different problems.
| Tool | Problem it solves | What it does |
|---|---|---|
| Debounce | Too many requests | Waits until the user pauses before sending |
AbortController | Wasted work | Tells the network layer to cancel the old request, so the browser and server can stop early |
ignore flag | Wrong order of results | Makes sure a result from an old effect run is never written to state |
Difference in one line: abort stops the request, ignore guards the state.
Why ignore when I already abort? Because abort is a request to cancel. A response may already be on its way, the fetcher may not pass the signal to fetch, or the server may have finished. The flag is a cheap safety net that works even then. React's own docs show the ignore flag pattern for this race. React 19 does not remove the problem: if you fetch in an effect, you must handle it, or use a library that does.
(Must know) Task 4: build a data table with sort, filter and pagination.
The task. Given an array of rows and columns, show a table. Click a header to sort (ascending, then descending, then none). A text box filters. Show 5 rows per page with Previous and Next.
Plan I say out loud. "I put the logic in one pure function, queryRows, that takes the rows and the options and returns the visible rows. The component only holds the options in state. The result is derived, not stored."
import { useMemo, useState } from 'react';const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });// Pure logic first: easy to test without React.export function queryRows(rows, { filter, sortKey, sortDir, page, pageSize }) { const needle = filter.trim().toLowerCase(); let out = needle ? rows.filter((r) => Object.values(r).some((v) => String(v).toLowerCase().includes(needle))) : rows; if (sortKey && sortDir) { const sign = sortDir === 'asc' ? 1 : -1; out = [...out].sort((a, b) => { const x = a[sortKey], y = b[sortKey]; const cmp = typeof x === 'number' && typeof y === 'number' ? x - y : collator.compare(String(x), String(y)); return sign * cmp; // Array.prototype.sort is stable, so ties keep their order }); } const pageCount = Math.max(1, Math.ceil(out.length / pageSize)); const safePage = Math.min(page, pageCount - 1); return { rows: out.slice(safePage * pageSize, (safePage + 1) * pageSize), total: out.length, pageCount, page: safePage };}export function DataTable({ rows, columns, pageSize = 5 }) { const [filter, setFilter] = useState(''); const [sort, setSort] = useState({ key: null, dir: null }); const [page, setPage] = useState(0); const view = useMemo( () => queryRows(rows, { filter, sortKey: sort.key, sortDir: sort.dir, page, pageSize }), [rows, filter, sort, page, pageSize], ); const toggleSort = (key) => { setSort((s) => (s.key !== key ? { key, dir: 'asc' } : s.dir === 'asc' ? { key, dir: 'desc' } : { key: null, dir: null })); setPage(0); }; return ( <div> <input aria-label="Filter" value={filter} onChange={(e) => { setFilter(e.target.value); setPage(0); }} /> <table> <thead> <tr> {columns.map((c) => ( <th key={c.key} aria-sort={sort.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}> <button onClick={() => toggleSort(c.key)}>{c.label}</button> </th> ))} </tr> </thead> <tbody> {view.rows.map((r) => ( <tr key={r.id}>{columns.map((c) => <td key={c.key}>{r[c.key]}</td>)}</tr> ))} </tbody> </table> {view.total === 0 && <p>No matching rows</p>} <nav aria-label="Pagination"> <button disabled={view.page === 0} onClick={() => setPage(view.page - 1)}>Previous</button> <span>Page {view.page + 1} of {view.pageCount}</span> <button disabled={view.page >= view.pageCount - 1} onClick={() => setPage(view.page + 1)}>Next</button> </nav> </div> );}The tests check the pure function first (cheap and fast), then the clicks:
expect(names(queryRows(rows, { ...base, sortKey: 'name', sortDir: 'asc', pageSize: 10 }))) .toEqual(['Apple', 'item 1', 'Item 2', 'Item 10', 'Pear']);Three tests passed: natural sort without changing the input, filter and page clamp, and the full UI flow.
Common mistakes.
rows.sort(...)directly on the prop.sortchanges the array in place, so you mutate props and state. Copy first ([...out].sort). Newer JavaScript hastoSorted, which returns a copy.- Sorting with
a > bon strings:"Item 10"comes before"Item 2", and uppercase comes before lowercase.Intl.Collatorwithnumeric: truegives natural order. - Storing the filtered list in state with
useEffect, so there is one extra render and a moment when the data is wrong. Derive it. - Not resetting to page 1 when the filter or sort changes, so you land on an empty page 4.
- Using the array index as the row
keyafter sorting. Use a stable id. - No empty state.
What to say out loud. "useMemo here remembers a value: the computed rows, until one of its inputs changes. It is not needed for correctness, only to avoid repeating work for big arrays. aria-sort on the header tells screen readers which column is sorted."
Extensions.
- The data is large (100,000 rows) or lives on a server. Move sort, filter and paging to the server: send
sort,q,limitandcursoras query parameters, and let the database do the work with an index. The component keeps the same state and only the function changes. For rendering many rows, virtualize. - Debounce the filter when it calls the server (reuse Task 3).
- Keep the state in the URL (
?q=apple&sort=price&page=2) so the page can be shared and the Back button works. See Frontend depth, Lesson 3 and Next.js, Lesson 5. - Multi-column sort, column resize, row selection.
(Senior signal) When should sorting and filtering move from the browser to the server?
When the data does not fit comfortably in the browser, or when the user is not allowed to have all of it. A simple rule I use: a few hundred rows, browser; thousands, think; tens of thousands or more, server. The numbers depend on row size and device, so I measure.
Reasons to go server-side: the download is too big, the sort is slow on phones, the data changes often, the user may only see some rows (permissions), or the total count is huge.
What changes: the table state (filter, sort, page) is sent as query parameters; the server runs an indexed query; the UI shows a loading state per page and must handle the race condition from Task 3 (a fast click on Next, Next). Also, the total count of a filtered query can be slow, so some products show "page 3" with no total. Offset pagination slows down deep pages, so cursor pagination is the next step (Design problems, Lesson 3).
The nice part: because I kept the logic in a pure function with the same options object, the shape of the state does not change. Only the place where queryRows runs moves.
(Must know) Task 5: build a multi-step form with validation and a saved draft.
The task. Three steps: account (name, email), plan (plan, seats), review and submit. Validate each step before Next. Keep the draft if the user reloads. Handle a failed submit.
Plan I say out loud. "One state object holds all values and the current step, so the draft is one JSON blob. One Zod schema per step, plus the combined schema at submit time. Errors show after the user tries to move on, and a field's error clears when they edit it."
import { useEffect, useState } from 'react';import { z } from 'zod';const KEY = 'signup-draft-v1'; // version the key: old drafts with an old shape must not crash the new form// One schema per step. The final check is all of them together.const steps = [ z.object({ name: z.string().trim().min(2, 'Enter at least 2 characters'), email: z.email('Enter a valid email'), }), z.object({ plan: z.enum(['free', 'pro'], 'Choose a plan'), seats: z.coerce.number('Enter a number').int().min(1, 'At least 1 seat').max(50, 'At most 50 seats'), }),];const fullSchema = steps[0].and(steps[1]);const empty = { name: '', email: '', plan: '', seats: '1' };function loadDraft() { try { const raw = JSON.parse(localStorage.getItem(KEY)); if (raw && typeof raw === 'object') return { values: { ...empty, ...raw.values }, step: Math.min(raw.step | 0, 2) }; } catch { /* corrupt JSON or storage blocked: start fresh */ } return { values: empty, step: 0 };}function fieldErrors(error) { const out = {}; for (const issue of error.issues) out[issue.path[0]] ??= issue.message; // first message per field return out;}export function Wizard({ onSubmit }) { const [{ values, step }, setDraft] = useState(loadDraft); const [errors, setErrors] = useState({}); const [submit, setSubmit] = useState({ busy: false, error: null, done: false }); useEffect(() => { try { localStorage.setItem(KEY, JSON.stringify({ values, step })); } catch { /* ignore */ } }, [values, step]); const set = (field) => (e) => { setDraft((d) => ({ ...d, values: { ...d.values, [field]: e.target.value } })); setErrors((prev) => ({ ...prev, [field]: undefined })); // clear this error when the user fixes it }; const next = () => { const result = steps[step].safeParse(values); if (!result.success) return setErrors(fieldErrors(result.error)); setErrors({}); setDraft((d) => ({ ...d, step: d.step + 1 })); }; const back = () => setDraft((d) => ({ ...d, step: d.step - 1 })); const send = async () => { if (submit.busy) return; // double-click guard const result = fullSchema.safeParse(values); if (!result.success) return setErrors(fieldErrors(result.error)); // trust nothing, check again setSubmit({ busy: true, error: null, done: false }); try { await onSubmit(result.data); // typed data: seats is a number now try { localStorage.removeItem(KEY); } catch { /* ignore */ } setSubmit({ busy: false, error: null, done: true }); } catch (err) { setSubmit({ busy: false, error: err.message, done: false }); // keep the draft so nothing is lost } }; if (submit.done) return <p role="status">Thanks, you are signed up.</p>; const field = (name, label, props) => ( <div> <label htmlFor={name}>{label}</label> <input id={name} value={values[name]} onChange={set(name)} aria-invalid={!!errors[name]} aria-describedby={errors[name] ? `${name}-err` : undefined} {...props} /> {errors[name] && <p id={`${name}-err`} role="alert">{errors[name]}</p>} </div> ); return ( <form onSubmit={(e) => e.preventDefault()} noValidate> <p>Step {step + 1} of 3</p> {step === 0 && <>{field('name', 'Name')}{field('email', 'Email', { type: 'email' })}</>} {step === 1 && ( <> <label htmlFor="plan">Plan</label> <select id="plan" value={values.plan} onChange={set('plan')}> <option value="">Choose...</option><option value="free">Free</option><option value="pro">Pro</option> </select> {errors.plan && <p role="alert">{errors.plan}</p>} {field('seats', 'Seats', { inputMode: 'numeric' })} </> )} {step === 2 && <dl><dt>Name</dt><dd>{values.name}</dd><dt>Email</dt><dd>{values.email}</dd><dt>Plan</dt><dd>{values.plan}</dd><dt>Seats</dt><dd>{values.seats}</dd></dl>} {submit.error && <p role="alert">{submit.error}</p>} {step > 0 && <button type="button" onClick={back}>Back</button>} {step < 2 && <button type="button" onClick={next}>Next</button>} {step === 2 && <button type="button" onClick={send} disabled={submit.busy}>{submit.busy ? 'Sending...' : 'Submit'}</button>} </form> );}Key tests (four passed):
test('corrupt draft falls back to an empty form', () => { localStorage.setItem('signup-draft-v1', '{not json'); render(<Wizard onSubmit={vi.fn()} />); expect(screen.getByText('Step 1 of 3')).toBeTruthy();});The other three: errors block Next and clear when fixed; a draft survives an unmount and remount; and the submit sends typed data (seats is the number 3, not "3"), keeps the draft on failure and clears it on success.
Common mistakes.
- Validating only at the end, so the user finds a mistake from step 1 on step 3.
- Only checking the current step on submit. Always check everything before you send, because a user can edit the saved draft.
- Showing every error while the user has not typed yet.
- Reading
localStoragewithouttry/catch. It can throw (private mode, blocked storage) andJSON.parsecan throw on bad text. - Saving the draft with no version. After you change the form, an old draft has the wrong shape.
- Clearing the draft before the request succeeds.
- Allowing a double click on Submit.
- Putting each field in its own
useStateand then joining them in a hurry.
What to say out loud. "I keep numbers as text in the input and convert them when I validate, because an input always gives a string. Zod's coerce.number() does that, and I send the parsed result, not the raw state."
Extensions.
- Use React Hook Form with Zod. Fewer re-renders and less code for large forms. In an interview I start with plain state, then say what the library adds.
- Save the draft on the server so it follows the user across devices: a
PUT /drafts/:idwith a debounce (Task 3). - Async validation (is the email taken?). Debounce it, abort old calls, and still check on the server at submit.
- Warn before leaving with unsaved changes (
beforeunload). - Accessibility: labels tied to inputs,
aria-invalid,role="alert"for errors, and move focus to the first error. See Frontend depth, Lesson 6.
(Trap) What can go wrong with a saved draft in localStorage?
Five things, from most to least common.
- It can throw. Private mode, a full disk or blocked site data can make
getItemorsetItemthrow. Wrap every call intry/catchand make the form work without storage. - The text can be broken.
JSON.parsethrows on bad JSON. Catch it and start fresh (the test above does this). - The shape can be old. After a release, an old draft misses new fields or has removed ones. I version the key (
signup-draft-v1) and merge with defaults ({ ...empty, ...raw.values }). - Secrets do not belong there.
localStorageis readable by any script on the page. I never save passwords, card numbers or tokens in a draft. - Server rendering. In Next.js, the server has no
localStorage. If a client component reads it during the first render, the server HTML and the client HTML can differ, which causes a hydration warning. The usual fix is to read it in an effect after mount (and show the empty form first), or to render the form only on the client. See Next.js, Lesson 5. (My sample reads it in the initial state, which is right for a client-only app such as Vite.)
Also, it is per browser and per origin. It is a convenience, not storage you can rely on. Anything that must not be lost belongs on the server.
(Must know) Task 6: build a small job queue in Node with a concurrency limit and retries.
The task. queue.add(fn) returns a promise. Run at most N jobs at the same time. Run them in the order added. If a job throws, retry with a delay. onIdle() resolves when everything is done.
A job queue keeps work in a line and starts jobs when a slot is free. A concurrency limit is the number of jobs allowed to run at the same time. (Lesson 17, Q17 shows the promise-pool version; this one adds retries and an idle signal, which is what interviewers ask for.)
Plan I say out loud. "A waiting array, a running counter, and a pump function that starts jobs while there is a free slot. Each job's promise settles with its own result. When one job finishes, I call pump again. Backoff doubles the delay each time and adds a little random jitter."
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));export class JobQueue { #waiting = []; // jobs not started yet (FIFO) #running = 0; #idleWaiters = []; constructor({ concurrency = 2, retries = 2, baseDelay = 100, wait = sleep } = {}) { Object.assign(this, { concurrency, retries, baseDelay, wait }); } get size() { return this.#waiting.length; } get pending() { return this.#running; } // add(job) returns a promise for the job's own result. add(job) { return new Promise((resolve, reject) => { this.#waiting.push({ job, resolve, reject }); this.#pump(); }); } #pump() { while (this.#running < this.concurrency && this.#waiting.length) { const item = this.#waiting.shift(); this.#running++; this.#run(item).finally(() => { this.#running--; this.#pump(); if (this.#running === 0 && this.#waiting.length === 0) { this.#idleWaiters.splice(0).forEach((r) => r()); } }); } } async #run({ job, resolve, reject }) { for (let attempt = 0; ; attempt++) { try { return resolve(await job({ attempt })); } catch (err) { if (attempt >= this.retries) return reject(err); // exponential backoff with jitter: 100, 200, 400 ms ... plus up to 50% random const delay = this.baseDelay * 2 ** attempt * (1 + Math.random() * 0.5); await this.wait(delay); // the slot stays used while we wait (a choice: say it aloud) } } } onIdle() { if (this.#running === 0 && this.#waiting.length === 0) return Promise.resolve(); return new Promise((r) => this.#idleWaiters.push(r)); }}Four tests passed, with a fake wait so they do not sleep: the limit is never passed and the order is kept (the peak was 2); retries use the delays 100 to 150 ms then 200 to 300 ms; a job that fails for ever rejects only its own promise while the next job still runs; and onIdle() works, including when already idle.
Common mistakes.
- Starting all jobs with
Promise.all, so the limit does nothing. - Checking the limit with
ifinstead ofwhile, so free slots stay empty. - Forgetting to call
pumpafter a job ends: the queue stalls. - Retrying for ever, or with no delay, which hammers a service that is already down. Add a maximum and a growing delay.
- Letting one failed job reject the whole queue.
- Retrying jobs that are not safe to repeat (such as charging a card).
- Using
array.shift()on a huge array, which is O(n) in theory (modern engines make it fast for moderate sizes, but I would mention a linked list or index if asked).
What to say out loud. "A job that waits for its backoff still holds a slot. That keeps the code simple, and the number of running jobs honest, but other jobs wait. If this were a problem I would put the retry back at the end of the line with a 'not before' time. Also, this queue lives in one process memory: if the process dies, the jobs are gone."
Extensions.
- Priorities: a second array, or a heap.
- Timeout per job: race the job against a timer and pass an
AbortSignalto the job. - Pause and resume; cancel a job; events for progress.
- Rate limit instead of concurrency limit ("at most 10 calls per second"). A token bucket is in Lesson 17, Q22, and the design view (many servers, Redis, headers like
Retry-After) is in Design problems, Lesson 2. - Make it durable: see the next question.
(Senior signal) Your in-memory queue works. What changes in production?
The queue must survive restarts and work across many servers. That needs a shared store, so I would use a library such as BullMQ with Redis, or a managed queue, instead of my class. See Queues and webhooks, Lesson 19.
What I would check:
- Durability. Jobs saved outside the process. A crash must not lose them.
- At-least-once delivery. A job can run twice (a worker dies after doing the work, before it says "done"). So jobs must be idempotent: safe to run again. Use an idempotency key or a "already done" check.
- Retries and a dead-letter queue. After the last retry, move the job to a place where a person can look at it.
- Backpressure. If jobs arrive faster than workers finish, the line grows. Limit its size or slow the producers.
- Visibility. Counts of waiting, active, failed jobs, plus alerts.
- Graceful shutdown. On SIGTERM, stop taking jobs and finish the running ones.
The point to say out loud: the interview class teaches the shape (limit, order, retry, idle). Production adds durability and idempotency.
(Senior signal) You have 10 minutes and no test runner set up. What do you test and how?
I test the riskiest logic, not everything. In Task 4 that is queryRows, a pure function: no framework needed, only a few assert lines in a file run with node. In Task 1, the status codes of the sad paths. In Task 3, the race: a late response must not win.
Without a runner I can use Node's built-in one: node --test and node:assert/strict. That worked for the Express API above with no install. For React, I would ask whether Vitest and Testing Library are set up. If not, I say "I will test the logic as a plain function, and the UI by hand, and here is what I would automate".
What I do not do: test implementation details (like state variable names). I test what a user sees and does: "after typing ab then abc, only the abc result is visible." See Frontend depth, Lesson 5 and Testing tools, Lesson 21.
A small trick: make the test fail first (or break the code on purpose for a second) to see that the test can fail. A test that cannot fail proves nothing.
(Trap) What are the bugs that fail most candidates in these rounds?
From the six tasks, the same bugs repeat:
| Bug | Where | Fix |
|---|---|---|
| Old response overwrites new one | Search, any fetch in an effect | ignore flag plus AbortController |
| Request fires twice | Infinite list, StrictMode | Ref guard; cleanup resets the flag |
| Mutating input | Table (sort), any reducer | Copy first |
String sort puts 10 before 2 | Table | Intl.Collator with numeric |
| Page number out of range after filtering | Table | Reset page; clamp |
| Validation only in the browser | Form | Validate again on the server |
Trusting req.body fields | API | Strict schema, pick fields |
| 200 with an error inside, or 500 for bad input | API | Right status codes |
Unbounded limit | API | Cap it |
| Retry storm | Queue | Max retries, backoff, jitter |
| Storage throws | Form draft | try/catch |
await in a loop when it should be parallel (or the reverse) | Everywhere | Say which one you want and why |
Before I say "done", I walk down this list once. It takes two minutes and finds most bugs.
(Senior signal) How do you finish a round well in the last five minutes?
I stop adding features at the five-minute mark and do four things.
- Run everything once (tests, the app, the main path) so I can say "this works" and mean it.
- Summarise in one breath: "I built the API with five routes, one validation layer and one error handler. Three tests pass. The store is in memory."
- Name the gaps honestly: "No auth, no database, and the id counter resets on restart."
- Say the next steps and the scale answer: "Next I would swap the
Mapfor MongoDB behind the same functions, add an index ondone, and cursor pagination if the list grows."
Then I ask: "What would you like to dig into?" That hands control to the interviewer and shows I am comfortable with questions. If I made a mistake earlier, I say so first: "I noticed the PATCH accepts an empty title if the key is missing. I would fix it with this one line." Finding your own bug is a good signal, not a bad one.
Key takeaways
- Interviewers score process as much as output: questions, a small working slice, tests, edge cases, honest limits.
- Follow the routine: clarify, plan, build the smallest slice, test, talk. Run the code after each step.
- For a take-home: one command to run, a README with decisions and cuts, a few good tests, no secrets, near the time limit.
- API tasks: validate with a strict schema, one error handler, right status codes, a capped
limit. Express 5 forwards async errors; Express 4 does not. - Fetch-in-React tasks: handle the race (
AbortControllerplusignore), the double effect in StrictMode and the empty/loading/error states. - Put logic in pure functions (sort, filter, page); it is easy to test and easy to move to the server.
- Forms: validate per step and again at submit, treat storage as unreliable, keep the draft if the send fails.
- Queues: the limit, the order, retries with backoff and jitter, and an honest answer about durability and idempotency.
What's next
- JavaScript coding utilities, Lesson 17 for
debounce,Promise.alland an LRU cache. - Design problems, Lesson 2 for the rate limiter at scale, and Design problems, Lesson 6 for autocomplete.
- Frontend depth, Lesson 5 for testing the user's view.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Building an Evaluation Harness for an LLM Feature · AI Engineering
- Documenting APIs: OpenAPI, Contracts and Testing · API Design
- Testing an API: Unit, Integration and Contract · Backend in Depth
Part of the study guide
- Coding Interview Preparation Guide: DSA Study Plan
- Full-Stack Developer Interview Guide: Questions and Answers
Related series