Frontend interviews have a particular failure mode: candidates who use React every day get caught out on the JavaScript underneath it. Someone asks about the event loop or this binding, and the answer comes out as a half-remembered blog post rather than something the person has reasoned about.
So the answers below are written as spoken answers — the length you would actually deliver, in the words you would actually use. Concrete where a concrete detail proves you have shipped something. A code example only where the example genuinely carries the point, because reciting code out loud in an interview is rarely what wins.
Click any question to open it. Expand all when you are revising; leave them closed when you want to test yourself.
How to use this properly: read the answer once, close the toggle, and say it back out loud in your own words. Recognition is not recall. The whole difficulty of an interview is producing an answer under pressure, and you cannot practise that by reading.
Tier 1 — JavaScript and TypeScript Core
Every frontend interview starts here, and it is where most of them are quietly decided. Framework knowledge is easy to fake for twenty minutes; language knowledge is not.
Q1 Explain the JavaScript event loop.
JavaScript runs on a single thread with one call stack, so only one thing executes at a time. Anything asynchronous — a timer, a fetch, a click handler — is handled by the browser outside that thread, and when it finishes, its callback is placed in a queue. The event loop does one job: when the call stack is empty, it takes the next callback from the queue and pushes it onto the stack.
The part worth knowing is that there are two queues with different priority. Microtasks — promise callbacks and queueMicrotask — drain completely after the current task and before the browser does anything else. Macrotasks — setTimeout, setInterval, I/O events — get one per loop iteration, with rendering allowed in between.
The practical consequence. A long synchronous function blocks everything, including rendering and clicks, which is exactly why the page freezes. And an infinite chain of microtasks starves rendering entirely, whereas a chain of setTimeout calls does not.
Example. In a script logging synchronously, then in a setTimeout(0), then in a Promise.resolve().then(), the order is: synchronous first, then the promise, then the timeout — because microtasks always beat macrotasks.
Q2 What is a closure, and where have you actually used one?
A closure is a function that keeps access to the variables of the scope it was defined in, even after that outer function has returned. The function carries its lexical environment with it rather than losing it.
I use them constantly without labelling them. Every React hook is one — a useState setter closes over the state slot for that component instance. Any debounce or throttle helper is one, because the timer ID has to persist between calls without being global. Module-scoped private state is one. And every event handler that references a variable from the component body is one, which is also where the classic bug comes from: a handler registered once closes over the first render's values and keeps seeing stale data.
Example.
function makeCounter() {
let count = 0; // not accessible from outside
return () => ++count; // but this closure keeps it alive
}
const next = makeCounter();
next(); next(); // 1, 2
Q3 What is the difference between var, let and const?
var is function-scoped and hoisted, so it exists from the top of the function as undefined before the line that declares it. let and const are block-scoped and live in the temporal dead zone — they are hoisted too, but accessing them before declaration throws a ReferenceError rather than silently giving you undefined, which is much better behaviour.
const prevents reassignment of the binding, not mutation of the value. So a const object can have its properties changed; you just cannot point the name at something else. That distinction catches people out regularly.
In practice. I default to const, use let only when I genuinely reassign, and never use var. The clearest illustration of why is a loop: var in a for loop creates one shared binding, so every callback registered inside sees the final value. let creates a fresh binding per iteration, which is almost always what you meant.
Q4 How does this work in JavaScript?
For a regular function, this is determined at call time by how the function is called, not where it is defined. Called as a method, this is the object before the dot. Called standalone, it is undefined in strict mode and the global object otherwise. Called with new, it is the newly created object. And it can be set explicitly with call, apply or bind.
Arrow functions do not have their own this at all. They inherit it lexically from the enclosing scope, and that binding cannot be changed — bind has no effect on an arrow function.
Where this shows up in real code. Passing a method as a callback loses the binding, because the call site no longer has the object before the dot:
const user = { name: "Ada", greet() { return `Hi ${this.name}`; } };
const fn = user.greet;
fn(); // this is undefined — breaks
setTimeout(() => user.greet(), 0); // arrow preserves the call shape
This is most of the reason React function components and hooks feel simpler than the old class components — there is no this to bind.
Q5 What is the difference between == and ===?
=== compares value and type with no conversion. == performs type coercion first, following rules that are genuinely surprising in places — 0 == "" is true, null == undefined is true, [] == false is true.
I use === everywhere. The one exception I will make deliberately is == null, because it is a concise check for null or undefined together, and that idiom is well understood.
The related point worth adding. Objects compare by reference, so {a: 1} === {a: 1} is false and always will be. That is not a coercion issue, but it is the same category of surprise, and it is the reason React's default memo comparison misses changes inside an object and why an inline object in a dependency array causes an effect to re-run on every render.
Q6 Explain prototypal inheritance.
Every JavaScript object has an internal link to another object called its prototype. When you access a property, the engine checks the object itself, then its prototype, then that object's prototype, and so on up the chain until it finds the property or reaches null. That chain is how inheritance works — there are no classes underneath, only objects delegating to other objects.
class syntax is sugar over this. A class body puts its methods on the prototype, so every instance shares one copy of each method rather than carrying its own, which is why methods defined in a class are cheaper than methods assigned per instance in a constructor.
Where it matters day to day. It explains why hasOwnProperty exists — you sometimes need to know whether a property is the object's own or inherited — and it explains why adding to a built-in prototype is dangerous, because you have changed behaviour for every object of that type across the entire application including library code.
Q7 Promises versus async/await — and how do you handle multiple async calls?
async/await is syntax over promises, not a different mechanism. An async function always returns a promise, and await pauses that function until the promise settles while leaving the rest of the thread free. I prefer it because sequential asynchronous logic reads like ordinary code and stack traces are far more useful.
For multiple calls, the thing interviewers listen for is whether you parallelise. Awaiting three independent requests one after another takes the sum of their durations; Promise.all takes the longest of them. The difference between all and allSettled matters too — all rejects as soon as any one fails and you lose the successful results, while allSettled always resolves with a status per promise, which is what you want when partial success is acceptable.
Example.
// sequential — roughly 900ms if each takes 300ms
const user = await getUser();
const orders = await getOrders();
const prefs = await getPrefs();
// parallel — roughly 300ms
const [user, orders, prefs] = await Promise.all([getUser(), getOrders(), getPrefs()]);
Error handling is try/catch around the await, and every top-level async call needs a .catch or it becomes an unhandled rejection.
Q8 What is the difference between debounce and throttle?
Debounce waits until the activity stops — it resets its timer on every call and only fires once things have gone quiet for the delay. Throttle fires at most once per interval regardless of how many calls arrive, so it produces a steady stream rather than a single trailing event.
The rule I use is: debounce when only the final state matters, throttle when intermediate updates matter. A search-as-you-type input is debounce, because you only want to query once the user has stopped typing. A scroll position indicator or a resize handler is throttle, because updating every hundred milliseconds during the gesture is exactly the point.
Example.
function debounce(fn, delay = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
In React. The subtlety is that a debounced function must not be recreated on every render or the timer resets each time — it belongs in a useRef or a useMemo with a stable dependency list.
Q9 How do you copy an object in JavaScript, and what goes wrong?
Spread syntax and Object.assign both give you a shallow copy — a new outer object whose nested values are still the same references. So mutating something one level down changes both copies, and in React that is a real bug rather than a curiosity: you update nested state, the reference at the top has not changed, and the component does not re-render.
For a true deep copy, structuredClone is now the built-in answer and it handles Dates, Maps, Sets and cycles properly. The old JSON.parse(JSON.stringify(x)) trick works but silently destroys Dates, undefined values and functions, so I would not reach for it.
Example.
const state = { user: { name: "Ada" }, tags: ["a"] };
const copy = { ...state };
copy.user.name = "Grace";
state.user.name; // 'Grace' — shared reference
const safe = structuredClone(state); // fully independent
The React-specific version. Update immutably at every level you change: { ...state, user: { ...state.user, name } }. That is what makes change detection work.
Q10 What does TypeScript actually buy you, and what is the difference between a type and an interface?
What it buys is that a whole category of bugs becomes a red squiggle instead of a production incident — misspelled properties, wrong argument order, forgetting that a value can be null. The secondary benefit is honestly larger on a team: types are documentation that cannot go stale, and refactoring stops being frightening because the compiler finds every call site.
interface and type overlap heavily for describing object shapes. interface supports declaration merging and reads more naturally when modelling an object contract. type can express things interface cannot — unions, tuples, mapped and conditional types. My rule is interface for object shapes I might extend, type for everything else, and consistency within a codebase matters more than the choice.
The point worth adding unprompted. any switches the checker off and quietly infects everything downstream. unknown is the safe version — it accepts anything but forces you to narrow before use. Turning on strict mode, especially strictNullChecks, is where most of the value actually comes from.
Q11 When would you write a generic in TypeScript?
When a function or type needs to work over multiple types while preserving the relationship between input and output. Without a generic you either duplicate the function per type or fall back to any and lose the information entirely.
The everyday case is an API wrapper. A fetch helper should return the shape the caller asked for, not any, so the generic threads the response type through and every consumer keeps full autocompletion. Same for a typed React hook, or a utility like "pick these keys from this object".
Example.
async function getJSON<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.json() as Promise<T>;
}
const user = await getJSON<User>("/api/me"); // user is User, not any
Where I would not use one. If the type parameter appears exactly once, it is doing nothing a plain parameter type would not do. Generics that exist to look clever make code harder to read, and interviewers notice restraint as much as capability.
Tier 2 — React and Component Architecture
Framework round. The good questions here are not about API surface, they are about rendering behaviour and where state should live.
Q12 What is the virtual DOM, and is it actually faster?
The virtual DOM is a lightweight JavaScript representation of the UI. On a state change React builds a new tree, diffs it against the previous one, and applies only the differences to the real DOM.
The honest answer to the second half is no — it is not faster than perfectly hand-written direct DOM manipulation. Doing the diff is extra work. What it buys is that you write declarative code describing what the UI should look like for a given state, and React handles the minimal update for you. It is faster than the realistic alternative, which is hand-written imperative updates that gradually accumulate bugs and redundant work.
Saying this plainly is a good signal, because a lot of candidates repeat "virtual DOM is fast" as though it were magic. It is a trade of a little computation for a large amount of correctness and maintainability.
Q13 Why does React need a key on list items, and why is the array index a bad key?
The key is how React matches elements between renders. Without it React compares by position, so inserting an item at the top makes it think every item changed. With a stable key it can tell that the same items moved and reuse their DOM nodes and their state.
The index is a bad key precisely when the list can reorder, filter or have items inserted, because the index of an item changes while the item does not. The visible symptom is state attaching to the wrong row — you type into the second input, delete the first item, and your text is now in the first input. That happens because React kept the DOM node for index one and only swapped the props.
When the index is fine. A static list that never reorders and has no per-item state. But since that is a property of today's code and not tomorrow's, I would use a stable ID whenever one exists.
Q14 useState, useRef, useMemo, useCallback — when do you reach for each?
useState for values that, when they change, should re-render the component. useRef for values that persist across renders but should not trigger one — a timer ID, a previous value, or a DOM node I need to focus or measure. The distinction is exactly "does the UI depend on this".
useMemo caches a computed value between renders, and useCallback caches a function identity. Both exist for two reasons: skipping a genuinely expensive computation, and keeping a reference stable so a memoised child does not re-render or an effect does not re-fire.
The judgement part. I do not wrap everything by reflex. Memoisation has a cost — the comparison, the memory, and the readability — and wrapping a cheap function in useCallback for a child that is not memoised anyway achieves nothing. I add it when a profile shows it matters, or when a stable reference is semantically required. Mentioning that the React Compiler is starting to handle much of this automatically is a good current note.
Q15 Explain useEffect and its dependency array.
useEffect runs code after render to synchronise the component with something outside React — a subscription, an event listener, a browser API, an imperative animation. The dependency array tells React which values the effect reads, so it can re-run when they change. An empty array means run once after mount; no array means run after every render.
The return value is a cleanup function, and it is the part people skip. It runs before the next execution of the effect and on unmount, so subscriptions get torn down and timers cleared. Without it you leak listeners and get warnings about setting state on unmounted components.
The detail worth adding. Never lie to the dependency array to stop an effect re-running. If it re-runs too often the real fix is upstream — memoise the object being passed in, move the function inside the effect, or use the functional form of a state setter so the effect does not need to depend on the current value.
Q16 When should you not use useEffect?
More often than most codebases assume. Three cases in particular.
First, deriving state from props. If a value can be computed during render from things you already have, compute it during render — do not keep it in state and sync it in an effect. That adds a second render and a window where the two are inconsistent.
Second, responding to a user event. If something should happen because the user clicked, that logic belongs in the click handler, not in an effect watching a state change. The effect version runs on any path that sets that state, including ones you did not intend.
Third, data fetching in most modern apps. A raw fetch inside an effect has to hand-roll loading state, error state, cancellation on unmount, race conditions between overlapping requests, and caching. A data library like TanStack Query, or a framework loader in Next.js or Remix, solves all of that. Effects are for synchronising with external systems, and that framing answers most of these questions on its own.
Q17 What is the difference between a controlled and an uncontrolled component?
In a controlled component React state is the single source of truth — the input's value comes from state and every keystroke goes through an onChange handler. In an uncontrolled component the DOM keeps its own value and you read it when you need it, usually through a ref or on form submit.
I default to controlled, because validating as you type, disabling submit conditionally, or driving one field from another all require the value to be in React. The cost is a render per keystroke, which is fine for a normal form and becomes noticeable on a very large one.
Where uncontrolled wins. Large forms where per-keystroke renders hurt, file inputs which are necessarily uncontrolled, and integrating a non-React widget. Libraries like React Hook Form are essentially a well-engineered uncontrolled approach, which is exactly why they perform well on big forms.
Q18 How do you decide where state should live?
I start at the lowest level that works and only lift when something forces me to. Local useState if one component cares. Lifted to the nearest common parent if siblings need to share it. Context when a value is genuinely global and low-frequency — theme, current user, locale — and I keep in mind that a context update re-renders every consumer, so it is a poor fit for values that change constantly.
The distinction I would make explicitly is between client state and server state. Server state is not really state, it is a cache of something that lives elsewhere, and it has its own problems: staleness, revalidation, deduplication, retries. Trying to manage it in Redux is where a lot of unnecessary complexity comes from. I would use a query library for that and keep a client store, if I need one at all, for genuine UI state.
The signal here. Interviewers are checking whether you reach for a global store by default. Saying "most applications need far less global state than they have" is usually the answer they are hoping for.
Q19 A component re-renders more than it should. How do you find and fix it?
First I measure rather than guess — React DevTools Profiler, with "highlight updates" on, shows me what is re-rendering and why. Guessing at memoisation is how codebases end up wrapped in useMemo everywhere with no measurable improvement.
The usual causes are a handful. A parent re-rendering cascades to children that did not change, which React.memo fixes. An inline object, array or function passed as a prop creates a new reference every render and defeats memo, which useMemo or useCallback fixes. A context value constructed inline re-renders every consumer on every parent render. And state living higher than it needs to means a keystroke in one input re-renders half the page.
The fix I would try before memoising anything. Move state down, or restructure so the expensive subtree is passed as children and therefore does not re-render when the parent's state changes. Composition solves this more cleanly than memoisation, and it does not rot.
Q20 CSR, SSR, SSG and React Server Components — when would you use each?
Client-side rendering ships an empty shell and renders in the browser. Fast to build and fine behind a login, but poor for SEO and for time-to-first-content on a slow device.
Static generation renders at build time and serves HTML from a CDN. It is the fastest and cheapest option and the right default for content that does not change per user — marketing pages, docs, a blog. With incremental regeneration it also covers content that updates occasionally.
Server-side rendering builds the HTML per request. Necessary when the page is personalised but still needs to be indexable or fast on first paint.
Server Components are the newer piece: components that run only on the server and send rendered output rather than JavaScript, so data fetching happens close to the data and the client bundle shrinks. Interactive parts stay as client components. In practice a real application mixes all of these per route rather than choosing one globally.
Q21 How do you handle loading and error states properly?
The mistake I try to avoid is treating loading and error as afterthoughts, because a boolean isLoading alongside a data and an error allows states that make no sense — loading and error simultaneously, or data present while an error is showing. Modelling it as one status value, or using a library that does, removes the whole class of bug.
For loading, a skeleton that matches the final layout beats a spinner, because it does not shift content when the data arrives. For anything under about three hundred milliseconds I would rather show nothing than flash a loader, since a flash reads as jank.
For errors, I distinguish between recoverable and not. A failed request gets an inline message with a retry, keeping the rest of the page usable. An unexpected render error gets caught by an error boundary so one broken widget does not blank the entire page. And the message says what the user can do, rather than surfacing a stack trace.
Q22 How do you test a React component?
I test behaviour rather than implementation. With React Testing Library that means querying the way a user would — by role, label or visible text — then interacting and asserting on what appears. Tests written against internal state or component names break on every refactor and stop being trusted, which is worse than having no test.
The bulk of my coverage is integration-level: render a feature with its children, mock the network at the boundary with something like MSW rather than mocking the fetch call itself, and assert the flow. That catches real bugs. Unit tests are for genuinely tricky pure logic — a date formatter, a reducer.
Priorities if asked what to test first. The happy path of anything revenue-critical, then error and empty states, which are the ones nobody writes and everybody ships broken. Accessibility comes almost free here, because if a test cannot find a button by its accessible role, neither can a screen reader.
Tier 3 — CSS, the Browser and Performance
The round that separates people who build interfaces from people who assemble components.
Q23 Explain the box model and box-sizing.
Every element is a box of content, then padding, then border, then margin. The question is what width refers to. By default, with box-sizing: content-box, width sets only the content area, so padding and border are added on top — set a width of 200 pixels with 20 pixels of padding and you get a 240-pixel box. With border-box, width includes padding and border, so the box is exactly what you asked for.
Practically every codebase sets box-sizing: border-box globally, because sizing components inside a grid is far more predictable when the declared width is the real width.
The margin detail. Vertical margins between adjacent block elements collapse into the larger of the two rather than adding, which is the source of a great deal of confusion. Flex and grid containers do not collapse margins, which is one more reason modern layouts feel more predictable.
Q24 Flexbox or Grid — how do you choose?
Flexbox is one-dimensional: it distributes items along a single axis and is content-driven, so items size themselves and flex to fill. Grid is two-dimensional and layout-driven — you define rows and columns up front and place items into them.
My rule is that Grid is for page and section structure, Flexbox is for arranging things inside a component. A card grid, a page shell with a sidebar, a dashboard — Grid. A navbar with a logo on the left and actions on the right, a button with an icon and a label, a row of tags — Flexbox. They compose freely, and most real layouts use both.
A specific case worth naming. A responsive card grid with no media queries is grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)). Doing the same in Flexbox needs percentage widths and negative margins and still leaves the last row misaligned. Being able to name that trade-off quickly signals real CSS experience.
Q25 Explain CSS specificity, and how do you avoid ending up with !important everywhere?
Specificity decides which rule wins when several target the same element. Inline styles beat IDs, IDs beat classes and attributes and pseudo-classes, and those beat element selectors. Within the same specificity, the later rule wins. !important overrides all of it, which is why it escalates — one use forces the next person to use it too.
The way to avoid it is to keep specificity flat and roughly equal everywhere, so ordering does the work instead of weight. That means styling with single classes, avoiding IDs for styling, and not nesting selectors three levels deep because a preprocessor made it easy.
How this is actually solved now. Scoped styles — CSS Modules, Astro or Svelte component styles — remove the collision problem at the source, and a utility framework like Tailwind sidesteps it by keeping everything at one class of specificity. If asked about legitimate uses of !important, overriding a third-party widget you cannot edit is the honest one.
Q26 What is the critical rendering path, and what is the difference between reflow and repaint?
The browser parses HTML into the DOM and CSS into the CSSOM, combines them into a render tree, computes the geometry of every element in the layout step, paints pixels, and composites layers onto the screen. Anything blocking that path delays first paint — CSS is render-blocking by default, and a synchronous script in the head blocks parsing entirely.
Reflow, or layout, is recalculating geometry, and it cascades: changing one element's height can move everything after it. Repaint is redrawing pixels without changing geometry — a colour change. Reflow is much more expensive.
Why this matters when animating. Animating width, height, top or left forces a reflow on every frame and drops the frame rate. Animating transform and opacity is handled on the compositor and can run without touching layout or paint at all. That single fact is the most useful practical takeaway from the whole rendering pipeline, and it is what I would lead with.
Q27 What are Core Web Vitals, and how do you improve each one?
Three metrics. LCP, largest contentful paint, is how long until the main content appears — good is under 2.5 seconds. INP, interaction to next paint, measures responsiveness across the whole visit, replacing the old FID — good is under 200 milliseconds. CLS, cumulative layout shift, measures unexpected movement — good is under 0.1.
LCP is usually the hero image or heading, and the fixes are preloading that resource, serving modern formats at the right size, cutting render-blocking CSS and JavaScript, and getting the server response fast. INP is almost always long JavaScript tasks blocking the main thread — the fixes are breaking up long tasks, reducing hydration work, and shipping less JavaScript. CLS comes from images without dimensions, fonts swapping, and content injected above existing content, so the fixes are explicit width and height, font-display: optional or a matched fallback, and reserving space for anything asynchronous.
A line that lands well. Field data over lab data — Lighthouse tells you what is possible on a fast machine, but real user monitoring tells you what your users actually experience.
Q28 How would you reduce a large JavaScript bundle?
I would start by looking rather than guessing — a bundle analyser almost always reveals one or two dependencies responsible for a disproportionate share. A date library imported wholesale, an icon set where every icon ships, or a chart library on a page with no charts are the classics.
Then, in order: route-based code splitting so each page loads only what it needs, dynamic imports for heavy components that are below the fold or behind an interaction, and replacing oversized dependencies with smaller ones or native APIs — Intl.DateTimeFormat instead of a formatting library, for instance. Tree shaking only works with ES modules and side-effect-free packages, so importing named exports rather than the default namespace matters.
The thing worth adding. Fewer bytes is only half of it. Parse and execute time is what actually blocks the main thread on a mid-range Android phone, so I would measure on a throttled CPU rather than on my laptop. And I would add a bundle size budget to CI, because bundles regrow silently otherwise.
Q29 What is CORS, and how do you fix a CORS error properly?
CORS is a browser mechanism that relaxes the same-origin policy. By default a page can only read responses from its own origin; CORS lets a server opt in to being read by other origins through response headers, primarily Access-Control-Allow-Origin. For anything beyond a simple request the browser first sends a preflight OPTIONS request to check whether the actual method and headers are permitted.
The point I would make clearly is that a CORS error is not a frontend bug. The request usually reached the server and the server responded; the browser blocked the frontend from reading the response because the headers did not allow it. So the fix belongs on the server, or on a proxy in front of it. Nothing you write in the client can grant permission it was not given.
What not to do. Setting the allowed origin to a wildcard to make the error disappear, especially with credentials involved — that combination is actually rejected by the browser, and where it works it is a real security hole. Configure the specific origins you intend to allow.
Q30 What frontend security issues do you think about, and where should an auth token be stored?
XSS first, because it is the one that actually happens. Any user-controlled content rendered as HTML is the attack surface, so I escape by default and treat dangerouslySetInnerHTML as something that requires sanitising with a library like DOMPurify and a good reason. A Content Security Policy is the defence in depth, limiting what an injected script could do even if one got through.
CSRF matters when authentication rides on cookies, since the browser attaches them automatically to cross-site requests. SameSite=Lax on the cookie handles most of it, with anti-CSRF tokens for the rest.
On token storage. localStorage is readable by any JavaScript on the page, so a single XSS is a full account compromise and the token persists. An HttpOnly, Secure, SameSite cookie cannot be read by JavaScript at all, which is why it is the safer default. The honest caveat is that it trades XSS exposure for CSRF exposure, and CSRF has well-understood mitigations while XSS is open-ended — that is the reasoning, and being able to state the trade-off rather than just the rule is what the question is testing.
Tier 4 — Scenario and Behavioural
Rehearse these. Most candidates improvise them and it is obvious.
Q31 A page feels janky on a mid-range Android phone but fine on your laptop. Walk me through it.
The first thing I would do is reproduce it under the right conditions — DevTools with CPU throttled to a four or six times slowdown and network set to slow 4G, because a laptop on office wifi hides almost everything.
Then I would record a performance profile and look at where the main thread is blocked. Long tasks over fifty milliseconds are what makes interaction feel unresponsive, and the flame chart usually points at one of a few things: too much JavaScript executing during hydration, an expensive render triggered on every scroll or keystroke, or a layout thrash where code reads a layout property and writes one repeatedly in a loop.
The fixes follow from the cause. Break long tasks up or move them off the main thread. Virtualise long lists so only visible rows render. Debounce or throttle the handler. Move animation onto transform and opacity. Ship less JavaScript to that route.
What makes this answer good. Leading with reproduction and measurement rather than jumping to a fix. Anyone can list optimisations; the signal is that you would confirm the cause first.
Q32 How do you make a component accessible?
Mostly by using the right element. A button is focusable, keyboard-activatable and announced correctly for free; a div with a click handler is none of those and needs a role, a tabindex and key handlers to catch up. So semantic HTML first, ARIA only to fill genuine gaps — the rule that no ARIA is better than bad ARIA is true, because a wrong role actively misleads a screen reader.
Beyond that: every interactive element reachable and operable by keyboard, with a visible focus indicator; every form input tied to a label; images with meaningful alt text, or empty alt when decorative; colour contrast at least 4.5 to 1 for body text; and no information conveyed by colour alone.
For anything with custom interaction — a modal, a combobox, a tab set — I follow the ARIA Authoring Practices pattern rather than inventing keyboard behaviour, and for a modal specifically I handle focus trapping, returning focus on close, and Escape to dismiss. I would also mention testing with the keyboard alone and running axe, since automated checks catch roughly a third of issues and the rest need a human.
Q33 Tell me about a time you disagreed with a designer or product manager.
The structure that works is: the disagreement, what you did to resolve it, and the outcome — including the part where you might have been wrong.
A good shape: a design called for an animated transition on a data-heavy list, and I was concerned about performance on low-end devices and about motion sensitivity. Rather than arguing in the abstract, I built both versions and profiled them on a throttled device, which turned an opinion into evidence. We kept the animation but simplified it to transform-only and added a prefers-reduced-motion variant. The designer was right that the transition mattered for comprehension; I was right that the original implementation would have dropped frames.
Why this shape works. It shows you can disagree without making it personal, that you resolve with data instead of seniority, and that you acknowledge the other side had a real point. Interviewers are screening for whether you are difficult to work with, and an answer where you were simply correct and everyone else came round is a worse answer than one where the resolution was genuinely shared.
Q34 How do you decide between using a library and building it yourself?
I weigh how much of the problem is genuinely hard against what the dependency costs. Things that look simple and are not — date and timezone handling, form state, virtualisation, accessible combobox and modal behaviour, rich text — I take the library, because the difficulty is entirely in the edge cases and I will rediscover every one of them slowly.
Things that are a thin wrapper over a platform API, or that I would need to fight to customise, I write. A carousel, a tooltip, a simple modal are often less code than the configuration needed to bend a library into the design.
What I look at before adding a dependency. Bundle cost, whether it is maintained, how many transitive dependencies come with it, how hard it would be to remove later, and whether the platform now does it natively — dialog, Intl, container queries and the Popover API have all replaced dependencies that used to be automatic. The cost of a library is not the day you add it, it is the day you need to upgrade it or work around it.
A note on delivery
Everything above gets you through the technical filter. What separates candidates after that is almost never knowledge:
- Answer the question, then stop. Rambling past the answer is the most common way a strong candidate weakens a good response.
- Say "I do not know" cleanly, then say how you would find out. They are looking for the edge of your knowledge. Pretending it is further out than it is fails you faster than the gap would have.
- Bring one real detail per answer. A number you measured, a bug you shipped, a tool you actually opened. It is the difference between sounding like you read this and sounding like you lived it.
- Ask a clarifying question on anything open-ended. For "how would you build X", jumping straight to a solution without scoping is itself a negative signal.