Frontend Developer — Interview Questions
Real interview questions with answers you can say out loud. Each topic matches the concept notes; tap a question to reveal its answer. Levels: Basic, Intermediate, Advanced.
1. What Frontend Really Is
BasicWhat does a frontend engineer actually own, beyond "building the UI"?
The frontend engineer owns everything the user experiences: what renders, how fast it renders, how it behaves on every device and input method, and how it talks to the backend.
In practice that covers:
- Rendering and layout — turning designs into semantic, responsive HTML/CSS that holds up with real content (long names, empty states, translations).
- State and data — fetching, caching, loading/error/empty states, optimistic updates, keeping the UI in sync with the server.
- Performance — bundle size, Core Web Vitals (LCP, INP, CLS), avoiding unnecessary work on the main thread.
- Accessibility — keyboard, screen reader, focus management, contrast.
- Resilience and security — handling failed requests, escaping untrusted content, not leaking secrets into the bundle.
Interviewers look for: that you think in terms of user outcomes and states (loading, error, empty, slow network), not just "matching the Figma".
BasicWhy is "the client can't be trusted" such an important principle?
Everything shipped to the browser is fully visible and modifiable by the user, so the frontend can improve UX but can never be the thing that enforces rules.
A user can open DevTools, edit the DOM, remove disabled from a button, change JavaScript variables, or skip the UI entirely and call your API with curl. That means:
- Validation on the client is for fast feedback; the server must validate again.
- Authorization (who can see or do what) must be checked server-side; hiding a button is not access control.
- Never put secrets (API keys with write access, private tokens) in frontend code or
VITE_/NEXT_PUBLIC_env vars — they end up in the bundle. - Prices, discounts, and totals must be computed or verified on the server.
Interviewers look for: a concrete example — "if the price is sent from the client in the checkout request, a user can change it to 0".
IntermediateExplain CSR, SSR, and SSG. When would you pick each?
They differ in where and when HTML is produced: in the browser at runtime (CSR), on the server per request (SSR), or at build time (SSG).
| CSR | SSR | SSG | |
|---|---|---|---|
| HTML built | In the browser by JS | On server, per request | At build time |
| First paint | Slow (blank until JS runs) | Fast | Fastest (served from CDN) |
| SEO | Weaker | Good | Good |
| Data freshness | Live | Live | Stale until rebuild |
| Good for | Dashboards behind login | Personalised or fast-changing pages | Docs, blogs, marketing |
SSR and SSG pages still need hydration: the JS bundle loads and attaches event handlers to the server-rendered HTML, so the page can look ready before it is interactive. Hybrids exist too: ISR (regenerate static pages in the background) and React Server Components / streaming, which send HTML in chunks and ship less JS for non-interactive parts.
Interviewers look for: mentioning hydration cost and that real apps mix strategies per route — static marketing pages, SSR product pages, CSR inside the logged-in app.
IntermediateWhat makes frontend code "good" in your view?
Good frontend code is correct for users in every state, easy for the next developer to change, and cheap for the browser to run.
- Handles all states — loading, error, empty, partial, slow network, long content.
- Accessible by default — semantic elements, labels, keyboard support; ARIA only where HTML falls short.
- Predictable data flow — single source of truth, derived values computed rather than duplicated in state.
- Small, focused components with clear props; business logic separated from presentation so it can be tested.
- Performance-aware — no needless re-renders, lazy-load heavy routes, optimised images.
- Consistent — follows the design system and team conventions rather than one-off styles.
AdvancedA page scores well in Lighthouse but users say it feels slow. How do you reason about that?
Lighthouse is lab data from one simulated device on a clean load; users experience field data — slower phones, bad networks, and interactions long after load.
Things I would check:
- Field metrics from CrUX or a RUM tool (the
web-vitalslibrary): especially INP, which Lighthouse cannot measure because it requires real interactions. - Long tasks after load — heavy re-renders on typing, big lists without virtualisation, synchronous JSON parsing.
- Perceived performance — missing loading feedback, layout shifts when data arrives, spinners instead of skeletons, no optimistic UI.
- Device segments — p75 on low-end Android is often far worse than the median on a MacBook.
Interviewers look for: the lab vs field distinction and knowing that INP replaced FID as a Core Web Vital in 2024.
2. How the Browser Works
BasicWhat happens when you type a URL into the browser and press Enter?
The browser resolves the domain, opens a secure connection, requests the HTML, then parses and renders it while fetching sub-resources.
- URL parsing — decide if it is a URL or a search; check HSTS to force HTTPS.
- Caches — service worker, then HTTP cache; may skip the network entirely.
- DNS lookup — browser cache → OS cache → resolver, returns an IP.
- Connection — TCP handshake + TLS handshake (or QUIC for HTTP/3, which combines them).
- HTTP request/response — server returns status, headers (caching, cookies, CSP) and HTML, possibly after redirects.
- Parsing — HTML → DOM, CSS → CSSOM; discovered resources (CSS, JS, images, fonts) are fetched, helped by the preload scanner.
- Render — render tree → layout → paint → composite; JS executes and may change the DOM, triggering more work.
Interviewers look for: depth where it matters for your role — for frontend, the parsing/rendering half and how blocking resources affect it, not just networking trivia.
BasicWhat are the DOM, the CSSOM, and the render tree?
The DOM is the tree of HTML nodes, the CSSOM is the tree of parsed style rules, and the render tree combines them into only the nodes that will actually be displayed, with their computed styles.
Elements with display: none (and things like <head>) are in the DOM but not in the render tree. Elements with visibility: hidden are in the render tree — they take up space but are not painted.
The browser needs the full CSSOM before it can build the render tree, which is why CSS is render-blocking: rendering with partial CSS would cause a flash of unstyled content.
BasicWhat is the difference between defer and async on a script tag?
Both download the script without blocking HTML parsing; defer runs scripts in order after parsing finishes, async runs each script as soon as it arrives, in any order.
| Plain | async | defer | |
|---|---|---|---|
| Blocks parsing while downloading | Yes | No | No |
| Executes | Immediately | When downloaded (pauses parser) | After parsing, before DOMContentLoaded |
| Order preserved | Yes | No | Yes |
| Use for | Rarely | Independent scripts (analytics) | App code that needs the DOM |
type="module" scripts are deferred by default.
Interviewers look for: knowing that async scripts can still block parsing while they execute, and that module scripts defer automatically.
IntermediateWalk me through the critical rendering path. How do you optimise it?
The critical rendering path is the sequence from bytes of HTML to pixels: DOM → CSSOM → render tree → layout → paint → composite. Optimising it means getting the first meaningful pixels out with as few blocking resources and round trips as possible.
- Minimise render-blocking CSS — inline critical above-the-fold CSS, load the rest non-blocking, split CSS by route.
- Get JS out of the way —
defer/type="module", code-split, avoid large synchronous scripts in<head>. - Prioritise the LCP resource —
<link rel="preload">orfetchpriority="high"on the hero image; never lazy-load it. - Fewer round trips —
preconnectto critical third-party origins, HTTP/2 or 3, CDN. - Fonts —
font-display: swaporoptional, preload the key font file.
IntermediateWhat is the difference between reflow, repaint, and composite?
Reflow (layout) recalculates geometry, repaint redraws pixels without changing geometry, and composite only moves or blends already-painted layers on the GPU — each is cheaper than the one before.
- Reflow — triggered by changing
width,height,top,margin, font size, adding DOM nodes. Can cascade to children and siblings. Always followed by paint. - Repaint — triggered by
color,background,box-shadow,visibility. - Composite only —
transformandopacity(on a layer). Can run on the compositor thread even if the main thread is busy.
Interviewers look for: "animate transform and opacity, not top/left/width" — and the caveat that promoting too many layers with will-change costs GPU memory.
IntermediateWhat is the difference between preload, prefetch, and preconnect?
preload fetches a resource needed for this page with high priority, prefetch fetches something likely needed for a future navigation at low priority, and preconnect just warms up the connection (DNS + TCP + TLS) to an origin.
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch" href="/checkout.js">
<link rel="preconnect" href="https://cdn.example.com">
Preload is for resources discovered late (fonts referenced in CSS, background images, the LCP image in JS-rendered pages). The as attribute is required for correct prioritisation; fonts need crossorigin even from the same origin or they will be fetched twice.
Interviewers look for: the trap that over-preloading steals bandwidth from genuinely critical resources, and Chrome warns about preloads not used within a few seconds.
AdvancedWhat is layout thrashing? Spot the problem in this code.
items.forEach(el => {
const h = el.offsetHeight; // read
el.style.height = h * 2 + 'px'; // write
});
Layout thrashing is forcing the browser to recalculate layout repeatedly in a loop by interleaving DOM reads and writes. Each write invalidates layout, so the next read (offsetHeight, getBoundingClientRect, scrollTop) forces a synchronous reflow — N elements means N layouts instead of one.
Fix: batch all reads, then all writes.
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px'; });
For ongoing work, schedule writes in requestAnimationFrame, or use ResizeObserver instead of polling sizes.
Interviewers look for: naming "forced synchronous layout" and knowing the Performance panel flags it with purple Layout blocks and a warning.
AdvancedWhy can a CSS transform animation stay smooth while the main thread is blocked, but a left animation can't?
Because transform/opacity animations on a composited layer run on the compositor thread, which does not wait for the main thread; animating left needs layout and paint, which only happen on the main thread.
If JavaScript runs a 300 ms task, a left animation freezes for 300 ms, while a CSS transform animation keeps moving. This only holds for declarative CSS animations/transitions or the Web Animations API — a requestAnimationFrame loop that sets transform is still driven by the main thread and will stall.
3. HTML & Semantics
BasicWhat is semantic HTML and why does it matter?
Semantic HTML means choosing elements for what the content is (<nav>, <button>, <article>) rather than how it looks, so browsers, assistive tech and search engines understand the page.
- Accessibility — screen readers announce roles, users can jump between landmarks and headings.
- Free behaviour — a
<button>is focusable, activates on Enter and Space, and submits forms; a<div>does none of that. - SEO — headings and structure help crawlers.
- Maintainability — the markup documents itself.
BasicWhat is the difference between <section>, <article>, and <div>?
<article> is self-contained content that would make sense on its own (a post, a comment, a product card); <section> is a thematic grouping that usually has a heading; <div> has no meaning and is purely for styling or grouping.
A quick test: if it could be syndicated in an RSS feed, it is an article. If it has a heading and is part of a larger whole, it is a section. If you only need a hook for CSS, it is a div.
BasicWhy is <div onclick> a problem? When would you use a link vs a button?
A clickable div is invisible to keyboard and screen reader users: it is not focusable, does not respond to Enter/Space, and has no role. Use the native element instead.
<a href>— navigates somewhere (changes URL). Supports open-in-new-tab, copy link, middle click.<button>— performs an action (submit, open modal, toggle, delete).
Rebuilding a button from a div needs role="button", tabindex="0", and key handlers for Enter and Space — more code for a worse result.
Interviewers look for: that a <button> inside a form defaults to type="submit" — a common bug when a "Cancel" button submits the form.
IntermediateWhat should go in the document <head>, and why does order matter?
The head holds metadata and resource hints: charset, viewport, title, description, canonical, social/Open Graph tags, stylesheets, preloads, and deferred scripts.
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Product name — Site</title>
<meta name="description" content="...">
<link rel="canonical" href="https://example.com/p/1">
<link rel="preconnect" href="https://cdn.example.com">
<link rel="stylesheet" href="/app.css">
<script src="/app.js" defer></script>
charset must be in the first 1024 bytes so the parser does not have to restart. Without the viewport tag, mobile browsers render at a desktop width (~980px) and zoom out. Resource hints should come early so fetches start sooner.
IntermediateHow do you write good alt text? When should alt be empty?
Alt text should convey the purpose of the image in context, not describe every pixel; decorative images get alt="" so screen readers skip them.
- Informative image — describe the information:
alt="Revenue grew 40% from Q1 to Q2". - Image inside a link or button with no text — describe the action/destination:
alt="Home". - Decorative or redundant with adjacent text —
alt="".
Omitting alt entirely is different from alt="": without it, many screen readers read the file name.
IntermediateHow do you serve responsive images? Explain srcset, sizes, and <picture>.
srcset + sizes let the browser pick the best resolution of the same image; <picture> lets you switch formats or serve a different crop (art direction).
<img src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
sizes="(min-width: 768px) 50vw, 100vw"
width="1600" height="900" alt="...">
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="...">
</picture>
Always set width and height (or aspect-ratio) so the browser reserves space and avoids layout shift. Use loading="lazy" for below-the-fold images but never the LCP image.
Interviewers look for: that sizes describes the rendered width, not the screen width — without it the browser assumes 100vw and often downloads too large an image.
AdvancedWhat is the "first rule of ARIA"? Give an example of ARIA misuse.
The first rule of ARIA is: don't use ARIA if a native HTML element can do the job. ARIA only changes what assistive tech is told — it adds no behaviour.
Common misuse:
<div role="button">withouttabindexand key handlers — announced as a button but not operable.aria-labelon a<div>with no role — ignored by many screen readers.aria-hidden="true"on a focusable element — keyboard users land on something that screen readers pretend doesn't exist.- Redundant roles like
<button role="button">or<nav role="navigation">.
Where ARIA genuinely helps: states the HTML can't express (aria-expanded, aria-current="page"), live regions (aria-live="polite" for toast messages), and relationships (aria-describedby linking an input to its error).
Interviewers look for: "no ARIA is better than bad ARIA" — and awareness of native <dialog>, <details>, and the popover attribute replacing many custom widgets.
AdvancedHow would you build an accessible modal dialog today?
Use the native <dialog> element opened with showModal(), which gives you a top-layer, backdrop, inert background, Escape-to-close, and focus handling for free.
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete file?</h2>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="ok">Delete</button>
</form>
</dialog>
Remaining responsibilities: give it an accessible name, put initial focus somewhere sensible (autofocus on the safest action), and return focus to the trigger on close (browsers now do this, but verify). If building a custom modal, you must implement a focus trap, inert on the rest of the page, role="dialog" + aria-modal="true", and Escape handling yourself.
4. Forms & Inputs
BasicWhy must every input have a <label>? Is a placeholder enough?
The label gives the input its accessible name and enlarges the click target; a placeholder is not a substitute because it disappears on typing, often has poor contrast, and is not reliably announced.
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email">
<!-- or wrap it -->
<label>Email <input type="email"></label>
If a design has no visible label, use a visually-hidden label or aria-label — but visible labels are better for everyone, including users with memory or cognitive impairments.
BasicWhat do you get from using the right type, inputmode, and autocomplete?
The right attributes give you the right mobile keyboard, built-in validation, and browser autofill — all without JavaScript.
type="email","tel","url","number","date"— keyboard layout plus native validation/pickers.inputmode="numeric"— numeric keyboard for things like OTPs or card numbers, wheretype="number"is wrong (it strips leading zeros and adds spinners).autocomplete="email","one-time-code","new-password","cc-number"— reliable autofill and password manager support.
Interviewers look for: knowing type="number" is for quantities, not for numeric identifiers like phone numbers, ZIP codes, or card numbers.
IntermediateWhat is the difference between controlled and uncontrolled inputs? When would you use each?
A controlled input's value lives in component state and is set via value + onChange; an uncontrolled input keeps its own value in the DOM, and you read it when needed (via a ref or FormData).
- Controlled — when the UI reacts to every keystroke: live validation, formatting, dependent fields, disabling submit.
- Uncontrolled — simple forms where you only need values on submit; fewer re-renders. React 19 form actions work naturally with this:
<form action={fn}>receivesFormData.
Libraries like React Hook Form are uncontrolled under the hood for performance on large forms.
Interviewers look for: the warning "a component is changing an uncontrolled input to be controlled" — caused by value starting as undefined. Initialise with ''.
IntermediateHow do you handle form validation well — client, server, and UX?
Validate on the client for fast feedback, always validate again on the server for correctness, and show errors in a way that is timely, specific, and accessible.
- Use native constraints (
required,minlength,pattern) as a base; style with:user-invalidso errors show only after interaction, not on page load. - Validate on blur or submit, not on every keystroke for the first entry; re-validate on change once an error is shown.
- Put error text next to the field, link it with
aria-describedby, setaria-invalid="true", and move focus to the first invalid field on submit. - Map server errors (e.g. "email already taken") back to the right field.
- Share one schema (e.g. Zod) between client and server where possible.
IntermediateThis form reloads the page when submitted. Why, and how do you fix it?
<form onSubmit={handleSubmit}> ... </form>
function handleSubmit() {
fetch('/api/save', { method: 'POST', body: ... });
}
A form's default action is a full-page navigation to its action URL. Without e.preventDefault(), the browser submits and reloads, often cancelling the in-flight fetch.
async function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget);
await fetch('/api/save', { method: 'POST', body: data });
}
Also: prevent double submits by disabling the button while pending, and prefer listening to submit on the form over click on the button so Enter-to-submit keeps working.
AdvancedHow would you design a large multi-step form (e.g. onboarding or checkout)?
Treat it as a state machine with a single form state, per-step validation, persistence, and clear progress — and never lose user input.
- State — one form object (React Hook Form or a reducer), steps render slices of it. Keep the current step in the URL so back/forward and refresh work.
- Validation — validate the current step before advancing; validate everything again on final submit and on the server.
- Persistence — save drafts (sessionStorage or server) so a refresh or crash doesn't wipe progress; don't persist sensitive fields like card numbers.
- Accessibility — move focus to the step heading on change, announce progress ("Step 2 of 4"), keep errors linked to fields.
- Submission — idempotency key to avoid duplicate orders on retry; handle partial server failures gracefully.
Interviewers look for: URL-driven steps and idempotent submission — the two things that separate a demo from a production checkout.
5. The Cascade & Specificity
BasicWhat is the cascade? In what order does the browser decide which rule wins?
The cascade is the algorithm that resolves conflicting declarations for the same property. It compares, in order: origin and importance, context (shadow DOM), cascade layers, specificity, then source order — the first difference decides.
- Origin & importance — user-agent, user, author styles;
!importantreverses the order of origins. - Cascade layers — for normal rules, later layers beat earlier ones, and unlayered styles beat all layers.
- Specificity — more specific selector wins.
- Source order — the later declaration wins.
Inline style attributes sit above any selector specificity (but below !important author rules).
BasicHow is specificity calculated? Which of these wins?
#nav a { color: red; } /* (1,0,1) */
.menu .item a { color: blue; } /* (0,2,1) */
Specificity is a three-part score (IDs, classes/attributes/pseudo-classes, elements/pseudo-elements) compared column by column from the left. #nav a wins: one ID beats any number of classes.
- IDs → column A.
- Classes,
[attr],:hover→ column B. - Elements,
::before→ column C. *and combinators add nothing.
Interviewers look for: that columns don't "carry over" — eleven classes (0,11,0) still lose to one ID (1,0,0).
IntermediateWhat is the specificity of :is(), :not(), :has(), and :where()?
:is(), :not(), and :has() take the specificity of their most specific argument; :where() always has zero specificity.
:is(#main, .card) p /* (1,0,1) — the ID counts even if .card matched */
:where(#main, .card) p /* (0,0,1) */
This makes :where() ideal for library and reset styles that consumers should override easily, and it is a trap with :is(): one ID in the list raises specificity for every match.
IntermediateWhich properties inherit? What do inherit, initial, unset, and revert do?
Mostly text-related properties inherit (color, font-*, line-height, visibility, cursor); box and layout properties (margin, border, width, background, display) do not.
inherit— take the parent's computed value.initial— the property's spec default (display: initialisinline, notblock).unset—inheritif the property inherits, elseinitial.revert— roll back to the previous origin, usually the browser's default style (so adivgoes back toblock).revert-layer— roll back to the previous cascade layer.
Interviewers look for: knowing form controls don't inherit fonts by default — hence button, input { font: inherit; } in resets.
IntermediateYour style isn't applying. How do you debug it?
Open DevTools, inspect the element, and check the Styles and Computed panels to see whether the rule matches at all, and if it does, what is overriding it.
- Rule not listed → selector doesn't match (typo, wrong class, element isn't where you think, CSS module hashing, file not loaded).
- Rule struck through → something wins: check specificity, source order, layers,
!important, inline styles. - Rule applied but no visible effect → the property doesn't apply in this context (e.g.
widthon an inline element,z-indexwithout a stacking context,topon a static element). Chrome shows an info icon for these. - Invalid value → shown with a warning triangle; one bad value in a shorthand invalidates the whole declaration.
AdvancedWhy is !important considered a smell, and when is it legitimate?
!important jumps the whole specificity system, so the only way to override it is another !important with higher specificity — an escalation war that makes styles unpredictable.
Legitimate uses:
- Utility classes that must always win (
.hidden { display: none !important; }, Tailwind's!modifier). - Overriding third-party inline styles you don't control.
- Accessibility overrides such as the reduced-motion reset.
Today cascade layers solve most of the problems people used !important for. Note that inside layers, !important inverts: an important declaration in an earlier layer beats one in a later layer.
AdvancedWhat colour is the text?
@layer base, components;
@layer components { .btn { color: blue; } }
@layer base { #save.btn { color: green; } }
button { color: red; }
<button id="save" class="btn">Save</button>
Red. Layers are compared before specificity, and unlayered styles beat every layer. So button (0,0,1, unlayered) beats both the ID selector in base and the class in components.
If the unlayered rule were removed, it would be blue: components is declared after base, so it wins regardless of #save's higher specificity.
Interviewers look for: this is exactly why layers exist — you can wrap a third-party library in a low layer and override it with simple selectors.
6. The Box Model & Sizing
BasicExplain the CSS box model.
Every element is a rectangular box made of four layers from inside out: content, padding, border, and margin.
Padding and border are part of the element's visible box (background paints under padding and border); margin is transparent space outside it. With the default box-sizing: content-box, width sets only the content width, so padding and border are added on top.
BasicWhat is the rendered width of this box, and what does box-sizing: border-box change?
.box { width: 200px; padding: 20px; border: 5px solid; margin: 10px; }
With content-box (the default) the box is 250px wide (200 + 20×2 + 5×2); margin adds 10px each side outside that but is not part of the box's width.
With border-box, width: 200px means the whole border box is 200px and the content shrinks to 150px. That is far more intuitive, so almost every codebase sets:
*, *::before, *::after { box-sizing: border-box; }
BasicWhen would you use px, rem, em, %, and viewport units?
Use rem for font sizes and most spacing so they respect the user's browser font setting, em for things that should scale with the component's own font size, px for fine details like borders, % relative to the parent, and viewport units for sizing against the screen.
rem— relative to the root font size; the accessible default.em— relative to the element's font size (forfont-sizeitself, the parent's); compounds when nested.px— borders, hairlines, shadows.vw/vh— viewport; preferdvh/svhon mobile since100vhignores the collapsing address bar.ch— readable line lengths (max-width: 65ch).
Interviewers look for: that px font sizes ignore the user's browser default size setting (though zoom still works) — an accessibility concern.
IntermediateWhat is margin collapsing, and when does it not happen?
Vertical margins between adjacent block boxes in normal flow combine into a single margin equal to the larger one (not the sum).
It happens between siblings, between a parent and its first/last child (when there is no padding, border, or content separating them), and within empty blocks. The parent-child case causes the classic bug where a child's margin-top appears to push the parent down.
It does not happen:
- for horizontal margins;
- inside flex or grid containers;
- when the parent has padding, border, or establishes a new block formatting context (
display: flow-root,overflowother than visible); - on floated or absolutely positioned elements.
Interviewers look for: the modern workaround — use gap in flex/grid for spacing, which sidesteps collapsing entirely.
IntermediateWhat is the difference between width: 100% and width: auto on a block element?
auto fills the available space minus the element's own margins, padding, and border; 100% sets the content (or border box) to exactly the parent's content width, and then margins are added on top — which can overflow.
.child { width: 100%; margin-left: 20px; } /* overflows by 20px */
.child { margin-left: 20px; } /* auto: fits perfectly */
For block elements, leaving width as auto is usually right.
IntermediateWhat do min(), max(), and clamp() do? Give a practical use.
They are CSS math functions: min() picks the smallest value, max() the largest, and clamp(MIN, PREFERRED, MAX) keeps a fluid value within bounds.
.container { width: min(100% - 2rem, 1200px); margin-inline: auto; }
h1 { font-size: clamp(1.75rem, 1rem + 3vw, 3rem); }
The first gives a centred container with gutters and a max width, no media query. The second gives fluid type. Including a rem component in the preferred value keeps it responsive to user zoom — pure vw font sizes don't scale with zoom.
AdvancedA flex child with long text refuses to shrink and overflows. Why?
Flex items default to min-width: auto, which means they can't shrink below their content's minimum size — a long unbreakable string or a <pre> sets that minimum.
.item { min-width: 0; } /* allow shrinking */
.item p { overflow-wrap: anywhere; } /* or let text break */
The same applies to grid tracks with 1fr, which is really minmax(auto, 1fr): use minmax(0, 1fr). Text truncation with text-overflow: ellipsis inside a flex item also needs min-width: 0 to work.
Interviewers look for: naming "automatic minimum size" — one of the most common real-world layout bugs.
AdvancedWhat is the difference between intrinsic and extrinsic sizing? Explain min-content, max-content, and fit-content.
Extrinsic sizing is imposed from outside (width: 300px, 50%); intrinsic sizing is derived from the content itself.
min-content— the narrowest the box can be without overflow (wraps at every opportunity; the longest word sets it).max-content— the width if the content never wraps.fit-content—max-contentbut capped by available space; like a shrink-to-fit button.
figure { width: fit-content; } /* caption no wider than the image */
.tag { width: max-content; }
These keywords also work in grid tracks (grid-template-columns: max-content 1fr) and make layouts that adapt to content without magic numbers.
7. Layout: Normal Flow & Positioning
BasicWhat is the difference between block, inline, and inline-block?
Block elements start on a new line and take the full available width; inline elements flow within text and ignore width/height; inline-block flows inline but respects width, height, and vertical padding/margin like a block.
| block | inline | inline-block | |
|---|---|---|---|
| New line | Yes | No | No |
| width/height | Respected | Ignored | Respected |
| Vertical margin | Respected | Ignored | Respected |
| Vertical padding | Pushes content | Painted but doesn't affect line layout | Pushes content |
Interviewers look for: the whitespace gap between inline-block items (from the HTML source), and the gap under images (they sit on the text baseline) — fixed with display: block or vertical-align: middle.
BasicExplain static, relative, absolute, fixed, and sticky positioning.
They control whether an element stays in normal flow and what its offsets (top, left, …) are relative to.
- static — default; in flow; offsets and
z-indexignored. - relative — in flow (space reserved); offset visually from its normal position; becomes the containing block for absolute children.
- absolute — removed from flow; positioned relative to the nearest positioned ancestor (not static), else the initial containing block.
- fixed — removed from flow; relative to the viewport (unless an ancestor has
transform,filter, orcontain). - sticky — in flow; acts relative until it crosses a threshold (
top: 0), then sticks within its scroll container and parent.
IntermediateWhy doesn't my position: sticky header stick?
Usually because of the containing context rather than the element itself. Checklist:
- No threshold set — sticky needs
top(orbottom/left/right). - An ancestor has
overflow: hidden,auto, orscroll— that ancestor becomes the scroll container, and if it doesn't scroll, nothing sticks. - The parent is only as tall as the sticky element — a sticky element can't leave its parent, so there is no room to stick.
- In a flex container with
align-items: stretch, the item is as tall as the container; usealign-self: flex-start.
Interviewers look for: the overflow ancestor cause — overflow-x: hidden on body or a wrapper is the classic culprit; overflow: clip avoids it.
IntermediateWhat is a stacking context? Why is my z-index: 9999 element still behind another?
A stacking context is an isolated group in which children are layered among themselves; the whole group is then placed as a single unit in its parent context. z-index only competes with siblings in the same context.
So a modal with z-index: 9999 inside a parent that forms a stacking context with z-index: 1 will still be behind a sibling of that parent with z-index: 2.
Things that create a stacking context:
- Positioned element with
z-indexother thanauto. - Flex/grid item with
z-index. opacity< 1,transform,filter,backdrop-filter,mix-blend-mode.position: fixedorsticky;isolation: isolate;will-changeon those properties;contain: paint.
Fixes: render overlays at the document root (React portals), or use native <dialog>/popover, which live in the top layer above all stacking contexts.
Interviewers look for: that a transform added for an animation can silently create a stacking context and break z-index elsewhere.
IntermediateHow do you centre an element horizontally and vertically?
With modern CSS, use grid or flexbox on the parent.
.parent { display: grid; place-items: center; }
.parent { display: flex; justify-content: center; align-items: center; }
/* absolutely positioned overlay */
.child { position: absolute; inset: 0; margin: auto; width: 200px; height: 100px; }
For horizontal centering of a block with a known width, margin-inline: auto still works. Avoid the old top: 50%; transform: translate(-50%, -50%) unless needed — it can blur text on sub-pixel positions.
IntermediateWhat is a block formatting context (BFC), and how do you create one?
A BFC is an independent layout region: floats inside it are contained, its children's margins don't collapse through it, and it doesn't overlap outside floats.
Created by: display: flow-root (the purpose-built way), overflow other than visible/clip, floats, absolute/fixed positioning, inline-block, flex/grid items, and contain: layout.
Practical uses: containing floats (the old clearfix), stopping a child's top margin escaping the parent, and making a block sit beside a float without wrapping under it.
AdvancedA position: fixed element is scrolling with the page instead of staying fixed. Why?
An ancestor has transform, perspective, filter, backdrop-filter, contain: paint, or will-change: transform. Any of these makes that ancestor the containing block for fixed descendants, so "fixed" becomes relative to it.
This commonly happens inside animated containers or page-transition wrappers. Fixes: move the element out via a portal to body, remove the property from the ancestor once the animation ends, or use a top-layer element (<dialog>, popover).
AdvancedWhat is CSS anchor positioning, and what problem does it solve?
Anchor positioning lets you tether an absolutely positioned element (tooltip, dropdown, popover) to another element purely in CSS, with automatic fallbacks when it would overflow the viewport.
.trigger { anchor-name: --menu; }
.menu {
position: absolute;
position-anchor: --menu;
top: anchor(bottom);
left: anchor(left);
position-try-fallbacks: flip-block;
}
Previously this required JS libraries like Floating UI to measure and reposition on scroll/resize. Combined with the popover attribute it gives top-layer, light-dismiss menus with no JS. Supported in Chromium since 2024 and landing in other engines, so keep a JS fallback where cross-browser support matters.
8. Flexbox
BasicWhat are the main axis and cross axis? Which properties act on each?
The main axis follows flex-direction (horizontal for row, vertical for column); the cross axis is perpendicular to it.
justify-content— distributes items along the main axis.align-items/align-self— align items on the cross axis.align-content— distributes lines on the cross axis when wrapping.
Interviewers look for: that switching to flex-direction: column swaps what justify-content and align-items visually do.
BasicWhat does flex: 1 mean?
flex: 1 is shorthand for flex-grow: 1; flex-shrink: 1; flex-basis: 0% — the item grows to share free space equally with other flex: 1 items, starting from zero.
flex: auto→1 1 auto: starts from content size, then shares leftover space (items with more content end up wider).flex: none→0 0 auto: rigid, content-sized.- Default is
0 1 auto: don't grow, can shrink.
IntermediateWhat is the difference between flex-basis and width?
flex-basis is the item's starting size along the main axis before grow/shrink are applied; width is only used as the basis when flex-basis is auto and the axis is horizontal.
So in a column flex container, flex-basis controls height, not width. If both are set, flex-basis wins (still clamped by min-width/max-width). Prefer flex-basis via the flex shorthand for flex items.
IntermediatePredict the widths: a 600px container with two items.
.a { flex: 1 1 100px; }
.b { flex: 2 1 100px; }
Free space = 600 − (100 + 100) = 400px, split by grow ratio 1:2. So a = 100 + 133.3 = 233.3px, b = 100 + 266.7 = 366.7px.
Shrinking is different: it is weighted by flex-shrink × flex-basis, so larger items give up more space in absolute terms.
Interviewers look for: that grow distributes the free space, not the total — flex-grow: 2 does not mean "twice as wide" unless bases are 0.
IntermediateHow do you push one item to the far end of a flex row (e.g. a logout button in a navbar)?
Give it margin-left: auto (or margin-inline-start: auto). Auto margins in flexbox absorb all free space in that direction.
.nav { display: flex; gap: 1rem; }
.nav .logout { margin-left: auto; }
This is cleaner than wrapping items in extra divs with justify-content: space-between, and works for any split point.
AdvancedWhy is gap better than margins for spacing flex items?
gap only puts space between items, so there is no trailing margin to remove, no :last-child hacks, and wrapped rows get consistent spacing on both axes.
With margins on wrapping items, the edge items carry extra margin that misaligns with the container, which led to negative-margin-on-parent hacks. gap has been supported in flexbox in all modern browsers since 2021.
AdvancedWhen would you choose flexbox over grid, and vice versa?
Flexbox is one-dimensional and content-out — items size themselves and the container distributes leftovers; grid is two-dimensional and layout-in — you define tracks and place items into them.
- Flexbox: navbars, toolbars, button groups, tag lists, centring, anything where item sizes should follow content.
- Grid: page layouts, card grids where columns must align across rows, forms with label/input columns, overlapping elements.
A tell: if you are setting widths on flex items to make columns line up across rows, you want grid.
9. Grid
BasicWhat is the fr unit?
fr is a fraction of the remaining free space in the grid container after fixed tracks and gaps are subtracted.
grid-template-columns: 200px 1fr 2fr; /* in 800px: 200, 200, 400 */
Note that 1fr is really minmax(auto, 1fr), so a track won't shrink below its content's minimum size; use minmax(0, 1fr) for truly equal columns.
BasicHow do you place an item across multiple rows and columns?
Use line numbers with grid-column/grid-row, span, or named areas.
.hero { grid-column: 1 / -1; } /* full width */
.feature { grid-column: span 2; grid-row: span 2; }
.layout {
display: grid;
grid-template-areas:
"header header"
"sidebar main";
}
.header { grid-area: header; }
Line -1 is the last explicit line, which makes "span the full width" independent of the column count.
IntermediateBuild a responsive card grid with no media queries. What's the difference between auto-fill and auto-fit?
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
gap: 1rem;
}
This creates as many columns of at least 250px as fit, stretching them to fill the row. The inner min(250px, 100%) prevents overflow on screens narrower than 250px.
The difference shows with few items: auto-fill keeps empty tracks, so 2 items in a wide container stay 250px-ish with blank columns beside them; auto-fit collapses empty tracks, so the 2 items stretch to fill the row.
IntermediateWhat is the difference between the explicit and implicit grid?
The explicit grid is what you define with grid-template-rows/columns; the implicit grid is extra tracks the browser creates automatically when items are placed outside it or there are more items than cells.
Implicit tracks are sized by grid-auto-rows / grid-auto-columns (default auto), and grid-auto-flow decides whether new items fill by row or column. Setting grid-auto-rows: minmax(100px, auto) is a common way to give unknown numbers of rows a minimum height.
IntermediateHow do you overlap elements in grid without absolute positioning?
Place multiple items in the same grid area; they stack in the same cell, ordered by source order or z-index.
.hero { display: grid; }
.hero > * { grid-area: 1 / 1; }
.hero .caption { align-self: end; z-index: 1; }
Unlike absolute positioning, the cell grows to fit the tallest item, so text over an image can't overflow its container.
AdvancedWhat is grid-auto-flow: dense, and what is its accessibility risk?
dense makes the auto-placement algorithm backfill earlier holes with later, smaller items — useful for masonry-like galleries with mixed spans.
The risk: visual order no longer matches DOM order, so keyboard tab order and screen reader order jump around the page. The same applies to order in flexbox and explicit grid placement. Only use it when the order of items doesn't carry meaning.
AdvancedWhat is subgrid and when do you need it?
subgrid lets a nested grid reuse its parent's tracks, so content inside separate child components can align across siblings.
.cards { display: grid; grid-template-columns: repeat(3, 1fr); }
.card {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid; /* title, body, footer rows shared */
}
Classic use: a row of cards where titles have different lengths, but you want every card's body and button to start at the same height. Without subgrid you'd need fixed heights or JS. Supported in all major browsers since 2023.
10. Responsive Design
BasicWhat does "mobile first" mean in CSS?
Write the base styles for the smallest screen, then add complexity with min-width media queries as space allows.
.grid { display: grid; gap: 1rem; }
@media (min-width: 48rem) { .grid { grid-template-columns: 1fr 1fr; } }
Benefits: simple, single-column base styles work everywhere; small devices don't parse overrides they don't need; and it forces prioritising content. Desktop-first tends to produce many max-width overrides that undo earlier styles.
BasicWhat does the viewport meta tag do?
<meta name="viewport" content="width=device-width, initial-scale=1"> tells mobile browsers to use the device's width as the layout viewport instead of pretending to be a ~980px desktop and zooming out.
Without it, media queries never fire on phones and text is tiny. Never add user-scalable=no or maximum-scale=1 — it blocks zoom, which is an accessibility failure.
IntermediateHow do you choose breakpoints?
Pick breakpoints where the content breaks, not at specific device widths — resize the browser until the layout looks wrong and add a breakpoint there.
In practice teams standardise a small set (e.g. 40rem, 48rem, 64rem, 80rem) for consistency, defined in em/rem so they respond to user font-size changes. Increasingly, fluid techniques (clamp(), auto-fit grids, flex-wrap) and container queries remove the need for many breakpoints altogether.
IntermediateWhat are container queries, and how do they differ from media queries?
Media queries respond to the viewport; container queries respond to the size of a parent container, so a component can adapt to wherever it is placed.
.card-wrap { container-type: inline-size; }
@container (min-width: 400px) {
.card { display: grid; grid-template-columns: 150px 1fr; }
}
The same card can be stacked in a narrow sidebar and horizontal in the main column on the same screen. Container query units (cqi) let you size type relative to the container too.
Interviewers look for: that an element can't query itself — you must set container-type on an ancestor, and inline-size containment means the container's width can't depend on its content.
IntermediateWhy is 100vh problematic on mobile? What do you use instead?
On mobile, 100vh equals the viewport height with the browser toolbars collapsed, so a full-height hero is taller than the visible area when toolbars are showing, cutting off the bottom.
svh— small viewport (toolbars visible): content always fits.lvh— large viewport (toolbars hidden).dvh— dynamic, updates as toolbars move (can cause resize jank if overused).
Most full-screen layouts want min-height: 100svh or 100dvh.
IntermediateBesides screen width, what else should a responsive UI adapt to?
Input type, user preferences, and network/device capability.
@media (hover: hover) and (pointer: fine)— only show hover-dependent UI where hover exists; make touch targets at least 24px (44px recommended).prefers-color-scheme,prefers-reduced-motion,prefers-contrast,forced-colors.- User zoom and larger default font sizes — layouts built in
remshould reflow at 200% zoom (WCAG 1.4.10 asks content to work at 320 CSS px width). - Safe areas on notched devices:
env(safe-area-inset-bottom). - Responsive images so phones don't download desktop assets.
AdvancedHow would you implement fluid typography that stays accessible?
Use clamp() with a preferred value that mixes rem and vw, and bounds in rem.
h1 { font-size: clamp(2rem, 1.2rem + 2.5vw, 3.5rem); }
The rem part keeps the size responding to browser zoom and user font settings; pure vw text does not scale with zoom, which fails WCAG 1.4.4 (resize text to 200%). A good rule of thumb is to keep max/min ratio at or below about 2.5 so zoom still meaningfully enlarges text. Pair it with fluid spacing using the same approach for a scale that feels consistent.
11. Transitions & Animation
BasicWhat is the difference between a CSS transition and a CSS animation?
A transition interpolates between two states when a property changes (e.g. on hover or class toggle); an animation runs a @keyframes sequence that can have many steps, loop, and start on its own.
.btn { transition: transform 150ms ease-out; }
.btn:hover { transform: translateY(-2px); }
@keyframes spin { to { transform: rotate(1turn); } }
.loader { animation: spin 1s linear infinite; }
Use transitions for state changes, animations for loaders, attention cues, and multi-step sequences.
BasicWhich properties are cheap to animate and why?
transform and opacity, because they can be handled by the compositor without re-running layout or paint.
Animating width, height, top, left, or margin triggers layout every frame; box-shadow, background, and filter trigger paint. To "animate width", scale instead; to move, translate; to fade a shadow, fade the opacity of a pseudo-element that carries it.
Interviewers look for: the 16.7ms-per-frame budget at 60fps (less on 120Hz screens) and naming layout → paint → composite.
IntermediateWhy doesn't transition work when toggling display: none? How do you animate an element in and out?
Historically display was not animatable, so the element appears/disappears instantly and there's no rendered "before" state to transition from on entry.
Modern CSS fixes this with transition-behavior: allow-discrete and @starting-style:
.toast {
opacity: 1;
transition: opacity .2s, display .2s allow-discrete;
}
.toast[hidden] { display: none; opacity: 0; }
@starting-style { .toast { opacity: 0; } }
Fallbacks: animate opacity + visibility instead of display, or remove the element from the DOM on transitionend/animationend (what libraries like Framer Motion's AnimatePresence do).
IntermediateHow do you respect users who prefer reduced motion?
Use the prefers-reduced-motion media query to remove or reduce non-essential motion — especially large movement, parallax, zooming, and auto-playing animation.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Better still, design motion as opt-in (no-preference) and swap movement for a fade rather than removing feedback entirely. In JS, check matchMedia('(prefers-reduced-motion: reduce)').matches.
Interviewers look for: that "reduce" doesn't mean "none" — a quick opacity change is still fine and preserves the UX cue.
IntermediateWhen would you use JavaScript for animation instead of CSS?
Use CSS for simple state transitions and loops; reach for JS when animation depends on runtime values, needs to be interrupted or chained, or follows physics or gestures.
- Web Animations API (
el.animate()) — CSS-performance keyframes controllable from JS (pause, reverse,finishedpromise). requestAnimationFrame— custom per-frame logic (canvas, drag following the pointer).- Libraries (Motion/Framer Motion, GSAP) — springs, layout animations, exit animations, timelines.
Never animate with setInterval — it isn't synced to the display refresh and keeps running in background tabs.
AdvancedWhat does will-change do, and how can it hurt?
will-change: transform hints the browser to promote an element to its own compositor layer ahead of time, avoiding a jank spike when an animation starts.
It hurts when overused: every layer costs GPU memory (a full-screen layer on a high-DPI phone can be tens of MB), and it creates a stacking context and a containing block for fixed children, which can break layouts. Apply it just before an animation (or on elements that animate constantly) and remove it afterwards — never * { will-change: transform; }.
AdvancedWhat are the View Transitions API and scroll-driven animations?
Both move animation work that used to need JS libraries into the platform.
- View Transitions —
document.startViewTransition(() => updateDOM())snapshots the old and new state and crossfades or morphs between them; give elements a sharedview-transition-nameto animate a thumbnail into a hero image. Also works across page navigations with@view-transition { navigation: auto; }. - Scroll-driven animations —
animation-timeline: scroll()orview()ties keyframe progress to scroll position (progress bars, reveal-on-scroll) and runs off the main thread, replacing scroll listeners.
Both should be treated as progressive enhancement and wrapped in reduced-motion checks.
12. Modern CSS & Architecture
BasicWhat are CSS custom properties, and how are they different from Sass variables?
Custom properties (--brand: #0a66c2;, used via var(--brand)) are live values in the browser that cascade, inherit, and can change at runtime; Sass variables are replaced at build time and don't exist in the output.
- Can be scoped and overridden per element or media query — ideal for theming and dark mode.
- Can be read and set from JS:
el.style.setProperty('--x', '10px'). - Support fallbacks:
var(--gap, 1rem). @propertycan register a type so custom properties become animatable.
BasicWhat is BEM and what problem does it solve?
BEM (Block__Element--Modifier) is a naming convention that gives every style a single flat class, avoiding specificity battles and making the relationship between classes obvious.
.card {}
.card__title {}
.card--featured {}
It solves global namespace collisions and deep selectors like .sidebar ul li a that are fragile and hard to override. Downsides: verbose names and relying on discipline rather than tooling — which is why many teams moved to CSS Modules or utility CSS.
IntermediateCompare CSS Modules, CSS-in-JS, and Tailwind. Which would you choose?
All three solve CSS scoping; they differ in where styles live and what they cost at runtime.
| CSS Modules | Runtime CSS-in-JS (styled-components, Emotion) | Tailwind (utility-first) | |
|---|---|---|---|
| Scoping | Hashed class names at build | Generated at runtime | No scoping needed — shared utilities |
| Runtime cost | None | Style injection on render; hurts SSR/RSC | None |
| Dynamic styles | Via custom properties/classes | Props directly in styles | Conditional classes, custom properties |
| CSS size | Grows with components | Grows with components | Plateaus |
The industry has moved away from runtime CSS-in-JS because it conflicts with React Server Components and streaming; zero-runtime options (vanilla-extract, Panda, StyleX) keep the authoring style. My default would be Tailwind or CSS Modules with a token system, chosen by team preference and design-system needs.
Interviewers look for: a reasoned trade-off, not tribalism — and awareness of why runtime CSS-in-JS fell out of favour.
IntermediateWhat does :has() enable? Give a real example.
:has() is a relational "parent" selector: it matches an element based on its descendants or following siblings, which previously required JavaScript.
/* card with an image gets a different layout */
.card:has(img) { grid-template-columns: 120px 1fr; }
/* highlight field wrapper when its input is invalid */
.field:has(input:user-invalid) { border-color: red; }
/* lock scroll while a modal is open */
body:has(dialog[open]) { overflow: hidden; }
Supported in all major browsers since late 2023. Keep selectors inside :has() specific — broad ones like body:has(*) can be costly to re-evaluate on DOM changes.
IntermediateHow would you implement dark mode?
Define semantic colour tokens as custom properties, override them for dark mode, and let the user's system preference be the default with a manual override.
:root { --bg: #fff; --text: #111; color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #111; --text: #eee; }
}
:root[data-theme="dark"] { --bg: #111; --text: #eee; }
body { background: var(--bg); color: var(--text); }
Details: color-scheme makes scrollbars and form controls match; the light-dark() function shortens paired values; persist the user's choice and apply it with a tiny inline script in <head> to avoid a flash of the wrong theme.
AdvancedWhat are cascade layers, and how would you structure a codebase with them?
@layer lets you declare explicit priority groups so that the order of layers decides conflicts before specificity does.
@layer reset, vendor, base, components, utilities;
@import url("lib.css") layer(vendor);
With this order, a single-class utility beats a highly specific component rule, and third-party CSS can be overridden without specificity hacks. Unlayered styles beat all layers, which is useful for quick overrides but can surprise you when mixing layered and unlayered code.
Interviewers look for: the !important inversion across layers and that Tailwind v4 uses native cascade layers internally.
AdvancedHow do you keep CSS maintainable in a large app with many teams?
Constrain choices with design tokens, scope styles to components, and control global CSS tightly.
- Design tokens — colours, spacing, radii, type scale as custom properties (or a token pipeline feeding CSS, iOS, Android). No raw hex values in components; enforce with Stylelint.
- Scoping — CSS Modules, utilities, or Shadow DOM so components can't leak styles.
- Layers — a documented layer order so resets, libraries, components, and utilities never fight.
- Shared components — a design-system library so teams don't restyle buttons locally.
- Guardrails — visual regression tests (Chromatic/Playwright screenshots), dead-CSS detection, bundle size budgets.
Interviewers look for: that architecture is about constraints and ownership, not just a naming convention.
13. Types & Coercion
BasicWhat are the data types in JavaScript, and how do primitives differ from objects?
There are seven primitives — string, number, bigint, boolean, undefined, null, symbol — and everything else is an object (arrays, functions, dates, maps).
Primitives are immutable and compared and copied by value. Objects are mutable and variables hold a reference to them, so assigning an object to another variable or passing it to a function shares the same underlying object.
let a = "hi"; let b = a; b += "!"; // a is still "hi"
const o = { n: 1 }; const p = o; p.n = 2; // o.n is now 2
{ n: 1 } === { n: 1 }; // false — two different references
Interviewers look for: the reference point applied to React — mutating an object in state keeps the same reference, so a shallow equality check sees "no change" and skips the re-render.
BasicWhat is the difference between == and ===?
=== compares value and type with no conversion; == converts the operands to a common type first (loose equality), which produces surprising results.
0 == ""; // true
"" == "0"; // false — loose equality is not even transitive
null == undefined;// true
null == 0; // false — null only loosely equals undefined
[] == false; // true ([] → "" → 0, false → 0)
Default to ===. The one idiomatic use of == is x == null, which checks for null or undefined in one go.
BasicWhat is the difference between null and undefined? Why is typeof null "object"?
undefined means "no value has been assigned" — the engine gives it to uninitialised variables, missing arguments, missing properties and functions with no return. null is a value you assign to say "deliberately empty".
typeof null === "object" is a bug from the first implementation (type tags where null's tag matched objects) kept forever for backwards compatibility. Check for it with x === null.
- Default parameters and destructuring defaults apply for
undefinedbut not fornull. JSON.stringifydropsundefinedproperties but keepsnull.
IntermediatePredict the output of these coercion expressions.
"5" + 2 // "52" — + with a string concatenates
"5" - 2 // 3 — - is numeric only
true + 1 // 2
null + 1 // 1 — null → 0
undefined + 1 // NaN — undefined → NaN
[] + [] // "" — both become ""
[] + {} // "[object Object]"
[] == ![] // true — ![] is false; [] → "" → 0, false → 0
The rules: + concatenates if either side becomes a string after conversion to a primitive; every other arithmetic operator converts to number. Objects convert to primitives via Symbol.toPrimitive, then valueOf, then toString — arrays stringify to their joined contents, plain objects to "[object Object]".
Interviewers look for: that you explain the rule rather than memorise results. [] == ![] works because ! converts to boolean first (arrays are truthy), then loose equality converts both sides to numbers.
IntermediateWhat is the difference between ?? and ||?
|| falls back on any falsy value; ?? falls back only on null or undefined.
const count = input.count || 10; // count 0 becomes 10 — a bug
const count = input.count ?? 10; // 0 stays 0
settings.theme ??= "light"; // assign only if nullish
Use ?? whenever 0, "" or false are valid values — ports, quantities, empty strings, feature flags. The falsy values to remember: false, 0, -0, 0n, "", null, undefined, NaN.
IntermediateHow do you deep copy an object, and what are the pitfalls of each approach?
Use structuredClone(obj) — it is built in, handles nested objects, Date, Map, Set, typed arrays and circular references.
| Approach | Depth | Pitfalls |
|---|---|---|
{...obj} / Object.assign | Shallow | Nested objects still shared |
JSON.parse(JSON.stringify(obj)) | Deep | Dates become strings; drops undefined and functions; Map/Set become {}; throws on cycles |
structuredClone(obj) | Deep | Throws on functions and DOM nodes; class instances lose their prototype (come back as plain objects) |
Interviewers look for: knowing that you often do not need a deep copy at all — in React state updates you copy only the path you change ({...state, user: {...state.user, name}}).
IntermediateHow do you reliably check for NaN, and what does Object.is do?
Use Number.isNaN(x). NaN is the only value not equal to itself, so x === NaN is always false.
The global isNaN coerces first, so isNaN("hello") is true — misleading. Number.isNaN("hello") is false.
Object.is(a, b) is like === with two differences: Object.is(NaN, NaN) is true and Object.is(0, -0) is false. React uses Object.is to decide whether state or dependencies changed.
AdvancedWhy does 0.1 + 0.2 !== 0.3, and how do you handle money or very large integers?
All numbers are IEEE 754 double-precision floats, and 0.1 and 0.2 have no exact binary representation, so the sum is 0.30000000000000004.
- Comparing floats:
Math.abs(a - b) < Number.EPSILON(or a tolerance suited to the magnitude). - Money: store integer minor units (cents), and format with
Intl.NumberFormat. Never do currency maths in floats. - Large integers: integers are exact only up to
Number.MAX_SAFE_INTEGER(253 − 1). Beyond that — 64-bit IDs from a database, for example — useBigInt(123n) or keep them as strings.
Interviewers look for: the real-world trap — a backend returns a 64-bit ID as a JSON number, JSON.parse silently rounds it, and lookups fail. The fix is to send IDs as strings.
14. Scope, Hoisting & Closures
BasicWhat are the differences between var, let and const?
var is function-scoped and hoisted as undefined; let and const are block-scoped and sit in the Temporal Dead Zone until their declaration runs. const also cannot be reassigned.
var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Before declaration | undefined | ReferenceError (TDZ) | ReferenceError (TDZ) |
| Redeclare in same scope | Yes | No | No |
| Reassign | Yes | Yes | No |
Top level creates window property | Yes | No | No |
const locks the binding, not the value: const arr = []; arr.push(1) is fine. Use const by default, let when you must reassign, and var never.
BasicWhat is hoisting? Does it apply to functions the same way?
Hoisting is the engine registering declarations when it enters a scope, before any code in that scope runs — so names exist before the line that declares them.
sayHi(); // works — declarations are fully hoisted
function sayHi() { console.log("hi"); }
sayBye(); // TypeError: sayBye is not a function
var sayBye = function () {}; // only the var is hoisted, as undefined
greet(); // ReferenceError — in the TDZ
const greet = () => {};
Function declarations are hoisted with their body. Function expressions and arrow functions follow the rules of whichever variable they are assigned to. Classes behave like let.
IntermediateWhat does this print, and how do you fix it? for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 100);
It prints 3 3 3. var creates one i for the whole function; all three callbacks close over that same variable, and by the time the timers fire the loop has finished and i is 3.
// Fix 1 — let creates a fresh binding per iteration → 0 1 2
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i), 100);
// Fix 2 — pre-ES6: an IIFE gives each callback its own scope
for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 100))(i);
}
// Fix 3 — pass the value as a setTimeout argument
for (var i = 0; i < 3; i++) setTimeout((j) => console.log(j), 100, i);
Interviewers look for: the precise reason — not "let is block scoped" alone, but that the spec creates a new binding for each loop iteration with let, copying the current value in, so each closure captures a different variable.
IntermediateWhat is the Temporal Dead Zone? What does this code do?
let x = "outer";
function f() {
console.log(x);
let x = "inner";
}
f();
It throws ReferenceError: Cannot access 'x' before initialization. The inner let x is hoisted to the top of f, so it shadows the outer x for the whole function — but it is uninitialised until its line runs. That window is the TDZ.
Even typeof x throws inside the TDZ, unlike an undeclared variable where typeof returns "undefined". The TDZ exists to turn silent undefined bugs into loud errors.
IntermediateWhat is a closure? Give a real use case.
A closure is a function together with the variables of the scope it was defined in; it keeps access to them even after that outer function has returned.
Functions carry a reference to their lexical environment, and the garbage collector keeps that environment alive as long as the function is reachable.
function makeCounter() {
let count = 0; // private state
return { inc: () => ++count, get: () => count };
}
const c = makeCounter();
c.inc(); c.inc(); c.get(); // 2 — nothing outside can touch count
Real uses: data privacy and module patterns, factories, debounce/throttle/once/memoize, event handlers that remember an id, and every React hook callback — which captures the props and state of the render it was created in.
IntermediateImplement debounce. How is it different from throttle?
function debounce(fn, delay = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
Debounce waits until calls stop for delay ms, then runs once with the latest arguments — use it for search-as-you-type, autosave, resize. Throttle runs at most once per interval while calls keep coming — use it for scroll position, drag, infinite-scroll checks.
Returning a regular function and using fn.apply(this, args) preserves this when the debounced function is used as a method.
Interviewers look for: follow-ups — add a cancel() method, a leading option, and in React, creating the debounced function once (useMemo/useRef) instead of on every render, which silently resets the timer.
AdvancedWhat is a stale closure? Why does this React counter get stuck at 1?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return count;
}
The effect runs once, so the interval callback closes over count from the first render, which is 0 forever. Every tick sets the state to 0 + 1.
- Use the functional updater:
setCount(c => c + 1)— it does not read the captured value. - Or list
countas a dependency (the interval is recreated each change). - Or keep the latest value in a
useRefwhen a long-lived callback must read current state.
Interviewers look for: that you connect this to plain JavaScript — each render is a function call with its own constants, and closures capture those, exactly like the loop question.
AdvancedCan closures cause memory leaks? Implement a memoize function and discuss its memory cost.
Yes — anything a reachable closure references stays in memory. The common leaks are event listeners, intervals and subscriptions that are never removed, each holding a closure over large data or detached DOM nodes.
function memoize(fn) {
const cache = new Map();
return function (arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn.call(this, arg);
cache.set(arg, result);
return result;
};
}
This cache grows without limit. In production you bound it (an LRU with a max size), or, when keys are objects, use a WeakMap so entries disappear when the key is garbage collected. Multi-argument versions need a key strategy — JSON.stringify(args) is simple but slow and breaks for objects with identity.
15. this, call, apply, bind
BasicHow is the value of this determined?
this is set by how a function is called, not where it is defined — except for arrow functions, which take it from the surrounding scope.
In order of precedence:
new Fn()→ the newly created object.fn.call(obj)/apply/bind→obj.obj.fn()→obj, the thing left of the dot.fn()→undefinedin strict mode and modules; the global object in sloppy scripts.
Arrow functions skip these rules entirely. In a DOM listener registered with a regular function, this is event.currentTarget.
BasicWhat is the difference between call, apply and bind?
All three set this. call and apply invoke immediately — call takes arguments individually, apply as an array. bind does not invoke; it returns a new function with this (and optionally leading arguments) fixed.
greet.call(user, "Hi", "!");
greet.apply(user, ["Hi", "!"]);
const hiUser = greet.bind(user, "Hi"); // partial application
hiUser("!");
With spread syntax, apply is rarely needed today — Math.max(...nums) replaced Math.max.apply(null, nums).
IntermediatePredict the output.
"use strict";
const user = {
name: "Ada",
greet() { return this?.name; },
later() { setTimeout(this.greet, 0); },
};
const g = user.greet;
console.log(user.greet()); // "Ada"
console.log(g()); // undefined — plain call, this is undefined
console.log((0, user.greet)()); // undefined — comma operator loses the reference
console.log(g.call({ name: "Bo" })); // "Bo"
Pulling a method off its object — assigning it, passing it as a callback, destructuring it — loses this. setTimeout(this.greet) has the same problem: the timer calls it as a plain function.
Interviewers look for: the fixes — setTimeout(() => this.greet()), setTimeout(this.greet.bind(this)), or defining the method as a class-field arrow function.
IntermediateHow do arrow functions handle this, and when should you not use them?
Arrow functions have no this of their own; they use the this of the enclosing scope at definition time, and call/apply/bind cannot change it. They also have no arguments and cannot be used with new.
That makes them ideal for callbacks inside methods. Avoid them when you need dynamic this:
- Object literal methods —
{ name: "A", f: () => this.name }reads the outerthis, not the object. - Prototype methods that should work for any instance.
- DOM handlers that rely on
thisbeing the element (or just usee.currentTarget). - Constructors.
IntermediateWhat does this print, and why?
const obj = {
count: 10,
run() {
function inner() { return this?.count; }
const arrow = () => this.count;
return [inner(), arrow()];
},
};
obj.run(); // [undefined, 10]
inner is a regular function called plainly, so its this is undefined (strict) regardless of being nested in a method. arrow inherits this from run, which was called as obj.run().
Before arrow functions the workaround was const self = this; — you will still see it in older codebases.
AdvancedWrite a polyfill for Function.prototype.bind.
Function.prototype.myBind = function (ctx, ...preset) {
const fn = this;
function bound(...args) {
// if called with new, ignore ctx and construct normally
return this instanceof bound
? new fn(...preset, ...args)
: fn.apply(ctx, [...preset, ...args]);
}
bound.prototype = Object.create(fn.prototype);
return bound;
};
The minimal answer is the apply line with argument concatenation (partial application). The strong answer also handles new: a bound function used as a constructor ignores the bound this.
Interviewers look for: the follow-up "what does binding twice do?" — f.bind(a).bind(b) keeps a. The first bind wins because the outer bound function ignores its own this.
AdvancedWhy did React class components need this.handleClick = this.handleClick.bind(this)?
Because onClick={this.handleClick} passes the method as a bare function reference. React later calls it as a plain function, so this is undefined (class bodies are strict mode) and this.setState throws.
Binding in the constructor creates one bound copy per instance. The later idiom was a class field arrow: handleClick = () => { ... }, which is created per instance and captures the instance as this. Binding inline in render works but creates a new function every render, which defeats PureComponent/memo checks.
Function components removed the problem — there is no this, only closures.
16. Objects & Prototypes
BasicWhat is the prototype chain?
Every object has an internal link, [[Prototype]], to another object. When a property is not found on the object itself, JavaScript looks it up on the prototype, then that object's prototype, until it reaches null.
const arr = [1, 2];
// arr → Array.prototype → Object.prototype → null
arr.map; // found on Array.prototype
arr.hasOwnProperty; // found on Object.prototype
This is how all inheritance in JavaScript works — shared methods live once on a prototype instead of being copied to each object. Writes, by contrast, always go to the object itself (they shadow, never modify the prototype).
BasicWhat are the ways to create an object?
- Object literal —
{ a: 1 }. The everyday choice. - Class / constructor function with
new— when you need many instances sharing methods. Object.create(proto)— explicit prototype;Object.create(null)gives an object with no prototype, useful as a pure dictionary.- Factory function returning a literal — closures give privacy without
thisornew. Object.fromEntries,structuredClone, spread — building from other data.
IntermediateWhat is the difference between __proto__ and prototype?
prototype is a property of functions (constructors and classes): the object that will become the [[Prototype]] of instances created with new. __proto__ is an accessor on every object exposing its own [[Prototype]].
class Dog {}
const d = new Dog();
Object.getPrototypeOf(d) === Dog.prototype; // true
d.__proto__ === Dog.prototype; // true (legacy accessor)
Dog.prototype.constructor === Dog; // true
Prefer Object.getPrototypeOf / Object.setPrototypeOf over __proto__. Changing an existing object's prototype at runtime is slow; set it at creation with Object.create instead.
IntermediateWhat exactly does the new keyword do?
- Creates a new empty object.
- Sets its
[[Prototype]]toConstructor.prototype. - Calls the constructor with
thisbound to that object. - Returns the object — unless the constructor explicitly returns a different object, which then wins.
function myNew(Ctor, ...args) {
const obj = Object.create(Ctor.prototype);
const result = Ctor.apply(obj, args);
return result !== null && typeof result === "object" ? result : obj;
}
Interviewers look for: the fourth step. Returning a primitive from a constructor is ignored; returning an object replaces the instance.
IntermediateAre ES6 classes just syntactic sugar? What is actually different?
Mostly — methods still live on Class.prototype and inheritance is still the prototype chain — but classes add real behaviour a plain constructor function does not have:
- Calling a class without
newthrows. - Class bodies are always strict mode.
- Methods are non-enumerable.
- Class declarations are in the TDZ until defined, not hoisted like function declarations.
- In a subclass,
thisis unavailable untilsuper()is called. - Private fields (
#x) and static blocks have no prototype-based equivalent —#xis enforced by the engine, not by convention.
IntermediateWhen would you use a Map instead of a plain object? What is a WeakMap for?
Use a Map for dynamic key–value collections: keys can be any type (including objects), iteration order is insertion order, it has .size, and it is optimised for frequent adds and deletes. Plain objects are better for fixed-shape records and JSON.
Objects also carry risk as dictionaries — keys are coerced to strings and inherited keys like "constructor" or "__proto__" can collide.
A WeakMap holds its keys weakly: if nothing else references the key object, the entry is garbage collected. Use it to attach metadata or cache results against objects you do not own (DOM nodes, request objects) without leaking memory. It is not iterable and has no size, by design.
IntermediateWhat is the difference between const, Object.freeze and Object.seal?
| Reassign variable | Add props | Delete props | Change values | |
|---|---|---|---|---|
const | No | Yes | Yes | Yes |
Object.seal | — | No | No | Yes |
Object.freeze | — | No | No | No |
Both freeze and seal are shallow — nested objects stay mutable unless you freeze recursively. In sloppy mode, writes to frozen objects fail silently; in strict mode they throw a TypeError.
AdvancedWhat is prototype pollution and how do you prevent it?
Prototype pollution is an attack where untrusted input modifies Object.prototype, so every object in the app suddenly has an attacker-controlled property.
function merge(target, src) { // naive deep merge
for (const k in src) {
if (typeof src[k] === "object") merge(target[k] ??= {}, src[k]);
else target[k] = src[k];
}
}
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
({}).isAdmin; // true — every object is now "admin"
Prevention:
- Skip
__proto__,constructorandprototypekeys in merge/set-by-path utilities, and only copy own properties (Object.hasOwn). - Use
MaporObject.create(null)for user-keyed dictionaries. - Validate input against a schema; keep libraries like lodash up to date.
Interviewers look for: why it matters — polluted defaults can flip auth checks, inject HTML into templates, or crash the server, all from one JSON body.
17. Arrays & Iteration
BasicWhat is the difference between map and forEach?
map returns a new array of the callback's return values; forEach returns undefined and is only for side effects.
Use map to transform data (and in JSX to render lists). Use forEach — or better, for...of — when you are doing something with each item rather than producing a new array. Using map and ignoring the result is a code smell.
Neither can be stopped early with break; use for...of, some or find when you need to exit.
BasicWhich array methods mutate the original array? What are the non-mutating alternatives?
Mutating: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin.
ES2023 added copying versions: toSorted(), toReversed(), toSpliced() and with(index, value). Otherwise copy first: [...arr].sort().
const next = todos.with(2, { ...todos[2], done: true }); // replace one item
const sorted = users.toSorted((a, b) => a.age - b.age);
Interviewers look for: the React consequence — state.sort() mutates state in place and returns the same reference, so the component may not re-render.
BasicWhat is the difference between slice and splice?
slice(start, end) returns a copy of a range and leaves the array untouched. splice(start, deleteCount, ...items) mutates the array — removing and/or inserting items — and returns the removed items.
const a = [1, 2, 3, 4];
a.slice(1, 3); // [2, 3] a is unchanged
a.splice(1, 2, 9); // [2, 3] a is now [1, 9, 4]
IntermediatePredict the output: [10, 9, 1].sort() and ["1", "2", "3"].map(parseInt).
[1, 10, 9] and [1, NaN, NaN].
Default sort converts elements to strings and compares UTF-16 code units, so "10" comes before "9". Always pass a comparator for numbers: (a, b) => a - b.
map calls the callback with (value, index, array), and parseInt takes (string, radix). So it runs parseInt("1", 0) → 1 (radix 0 means default), parseInt("2", 1) → NaN (radix 1 is invalid), parseInt("3", 2) → NaN ("3" is not a binary digit). Use .map(Number) or .map(s => parseInt(s, 10)).
IntermediateWhat is wrong with items.forEach(async (i) => await save(i))?
forEach ignores the promises its callback returns, so nothing waits: all saves start at once, code after the loop runs immediately, and rejections go unhandled.
// Sequential — one after another, order guaranteed
for (const i of items) await save(i);
// Parallel — all at once, wait for all
await Promise.all(items.map((i) => save(i)));
Choose sequential when order matters or the server cannot take the load; parallel when calls are independent. For large lists, limit concurrency (process in batches of, say, 5).
IntermediateWhat is the difference between for...of, for...in and forEach?
| Iterates | break/await | Works on | |
|---|---|---|---|
for...of | Values | Yes | Any iterable: arrays, strings, Map, Set, NodeList, generators |
for...in | Enumerable string keys, including inherited | Yes | Objects |
forEach | Values + index | No | Arrays, Map, Set, NodeList |
Avoid for...in on arrays — indices come as strings and inherited or extra properties show up. For objects, for (const [k, v] of Object.entries(obj)) is clearer and own-properties only.
IntermediateGroup an array of users by role.
// ES2024 built-in
const byRole = Object.groupBy(users, (u) => u.role);
// { admin: [...], editor: [...] }
// Classic reduce
const byRole2 = users.reduce((acc, u) => {
(acc[u.role] ??= []).push(u);
return acc;
}, {});
Object.groupBy returns a null-prototype object; Map.groupBy returns a Map when keys are not strings. Knowing reduce still matters — it is the general tool for turning a list into any single value (sum, index by id, counts).
Interviewers look for: always passing the initial value to reduce. Without it, an empty array throws and the first element becomes the accumulator.
AdvancedImplement a deep flatten and a polyfill for Array.prototype.map.
function flatten(arr, depth = Infinity) {
return depth > 0
? arr.reduce((acc, x) =>
acc.concat(Array.isArray(x) ? flatten(x, depth - 1) : x), [])
: arr.slice();
}
flatten([1, [2, [3, [4]]]]); // [1, 2, 3, 4] — same as arr.flat(Infinity)
Array.prototype.myMap = function (cb, thisArg) {
const out = new Array(this.length);
for (let i = 0; i < this.length; i++) {
if (i in this) out[i] = cb.call(thisArg, this[i], i, this);
}
return out;
};
Details that show depth: passing (value, index, array) and thisArg, and the i in this check that preserves holes in sparse arrays, as the real map does. For very deep nesting, an iterative stack-based flatten avoids recursion limits.
18. The Event Loop
BasicJavaScript is single-threaded. How does it handle asynchronous work?
JavaScript runs on one call stack, but the host (browser or Node) does the slow work — timers, network, I/O — on its own threads and queues a callback when it is done. The event loop picks queued callbacks and runs them whenever the call stack is empty.
One turn of the loop in the browser: run one macrotask → drain all microtasks → render if needed (rAF, style, layout, paint) → repeat.
So "async" never means parallel JavaScript; it means "run this later, when the stack is free". True parallelism needs Web Workers.
BasicWhat is the difference between microtasks and macrotasks?
Microtasks — promise reactions (.then, code after await), queueMicrotask, MutationObserver. Macrotasks (tasks) — setTimeout, setInterval, UI events, network callbacks, MessageChannel.
After each macrotask, and whenever the stack empties, the engine runs the entire microtask queue — including microtasks queued by microtasks — before the next macrotask or render. So a resolved promise's callback always runs before a setTimeout(fn, 0) scheduled at the same time.
IntermediatePredict the output.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => {
console.log("C");
setTimeout(() => console.log("D"), 0);
})
.then(() => console.log("E"));
console.log("F");
A, F, C, E, B, D
- Synchronous code:
A,F. Timer B is queued; the first.thenis queued as a microtask. - Stack empty → drain microtasks:
Cruns and queues timer D; its promise resolves, queuing the second.then, which runs in the same drain:E. - Macrotasks in order of queueing:
B, thenD.
Interviewers look for: that a chained .then runs in the same microtask drain, before any timer — even one that was queued earlier.
IntermediateWhy is setTimeout(fn, 0) not really 0 ms? When should you use requestAnimationFrame instead?
setTimeout gives a minimum delay. The callback must wait for the current script, all microtasks, possibly a render, and any earlier tasks. Browsers also clamp nested timers (after 5 levels) to at least 4 ms, and throttle timers in background tabs to once per second or less.
requestAnimationFrame runs right before the next paint, synced to the display refresh rate, and pauses in hidden tabs. Use it for anything visual — animations, measuring then writing layout, scroll-linked effects. Use setTimeout for "later, not tied to frames" work.
AdvancedPredict the output (async/await mixed in).
async function a1() {
console.log("a1 start");
await a2();
console.log("a1 end");
}
async function a2() { console.log("a2"); }
console.log("script start");
setTimeout(() => console.log("timeout"), 0);
a1();
new Promise((resolve) => {
console.log("p1");
resolve();
}).then(() => console.log("p2"));
console.log("script end");
script start, a1 start, a2, p1, script end, a1 end, p2, timeout
- An
asyncfunction runs synchronously until its firstawait, soa1 startanda2print immediately. - The promise executor also runs synchronously:
p1. - Everything after
awaitis a microtask.a1 endwas queued beforep2, so it runs first. - The timer is a macrotask and runs last.
Interviewers look for: the two key facts — the executor and the pre-await part of an async function are synchronous, and await is sugar for "put the rest in a .then".
AdvancedYou need to process 100,000 items without freezing the UI. What are your options?
A long synchronous loop blocks input and paint, hurting INP. Options, roughly in order:
- Do less — paginate, virtualise the list, or move the work to the server.
- Yield to the main thread — process in chunks and give the browser a chance to handle input between them:
async function processAll(items) { for (let i = 0; i < items.length; i++) { handle(items[i]); if (i % 500 === 0) await yieldToMain(); } } const yieldToMain = () => globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0)); - Web Worker — move CPU-heavy work (parsing, sorting, image processing) off the main thread entirely; post the result back.
requestIdleCallbackfor low-priority background work.
Yielding with a Promise.resolve() does not help — microtasks run before rendering, so the page still freezes. You must yield to a macrotask.
AdvancedHow does the Node.js event loop differ from the browser's?
Node's loop (libuv) runs in phases: timers → pending callbacks → poll (I/O) → check (setImmediate) → close callbacks. There is no render step.
process.nextTickcallbacks run before promise microtasks, after the current operation — overusing it can starve I/O.setImmediateruns in the check phase. Inside an I/O callback it always fires beforesetTimeout(fn, 0); from the main module their order is not guaranteed.- Since Node 11, microtasks drain between each individual timer/immediate callback, matching browser behaviour.
19. Promises & async/await
BasicWhat is a promise, and what states can it be in?
A promise is an object representing the eventual result of an asynchronous operation. It is pending, then settles once to either fulfilled (with a value) or rejected (with a reason) — and never changes after that.
You consume it with .then(onFulfilled, onRejected), .catch and .finally. Each of these returns a new promise, which is what makes chaining work: returning a value passes it down the chain, returning a promise waits for it, throwing rejects.
Promises replaced callback pyramids with composable values and a single error channel.
BasicHow does async/await relate to promises?
async/await is syntax over promises. An async function always returns a promise; await pauses that function (not the thread) until the promise settles, then resumes with the value — or throws the rejection reason.
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Benefits: linear code, ordinary try/catch, and readable loops and conditionals. The resumption after each await is a microtask, so the rest of the page keeps running meanwhile.
IntermediateCompare Promise.all, allSettled, race and any.
| Method | Fulfils when | Rejects when | Typical use |
|---|---|---|---|
all | All fulfil → array of values (in input order) | First rejection | Page needs every piece of data |
allSettled | All settle → {status, value | reason} | Never | Dashboard widgets that can fail independently |
race | First to settle, if it fulfilled | First to settle, if it rejected | Timeouts |
any | First to fulfil | All reject → AggregateError | Fastest of several mirrors |
Interviewers look for: that Promise.all failing fast does not cancel the other operations — they keep running and their results are discarded. Cancelling needs an AbortController.
IntermediateThis takes 3 seconds; each call takes 1 second. Why, and how do you fix it?
const user = await getUser();
const posts = await getPosts();
const prefs = await getPrefs();
Each await waits before the next request even starts, so independent calls run one after another. Start them together, then await:
const [user, posts, prefs] =
await Promise.all([getUser(), getPosts(), getPrefs()]); // ~1 second
Keep them sequential only when a call depends on the previous result (getPosts(user.id)). A mixed approach is common: fetch the user, then fetch its posts and prefs in parallel.
Interviewers look for: the trap of starting promises early without awaiting them together — const a = getA(); const b = getB(); await a; await b; runs in parallel but if b rejects while awaiting a, it becomes an unhandled rejection. Promise.all avoids that.
IntermediateHow do you handle errors properly with promises and async/await? What are the common mistakes?
Wrap awaits in try/catch (or add a .catch at the end of a chain), and either handle the error meaningfully or rethrow it.
fetchdoes not reject on HTTP errors — a 404 or 500 fulfils. Checkres.okand throw.- Swallowing errors — a
catchthat only logs makes the caller think it succeeded. - Missing
await/return—try { return promise; }withoutawaitmeans a rejection escapes thecatchin that function. Usereturn awaitinsidetry. - Floating promises — calling an async function without awaiting or catching it gives unhandled rejections. Listen for
unhandledrejectionto report them.
try {
return await fetchJson(url); // await needed for catch to see it
} catch (err) {
if (err.name === "AbortError") return null;
throw err;
}
IntermediateImplement a timeout for a promise.
function withTimeout(promise, ms) {
let id;
const timeout = new Promise((_, reject) => {
id = setTimeout(() => reject(new Error("Timed out")), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(id));
}
This rejects the result after ms, but the underlying work keeps running — race does not cancel anything. Clearing the timer in finally avoids a stray timer. For fetch, prefer real cancellation: fetch(url, { signal: AbortSignal.timeout(5000) }).
AdvancedHow do you cancel a request? Walk through a search box that avoids race conditions.
Use an AbortController: pass its signal to fetch, and call abort() to cancel. The fetch rejects with an AbortError DOMException, which you ignore.
let controller;
async function search(q) {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`,
{ signal: controller.signal });
render(await res.json());
} catch (err) {
if (err.name !== "AbortError") showError(err);
}
}
Without cancellation, a slow response for "re" can arrive after the fast one for "react" and overwrite the results. In React, create the controller inside useEffect and abort in the cleanup.
AbortSignal.timeout(ms)— a signal that aborts itself.AbortSignal.any([a, b])— combine a user cancel with a timeout.- Signals also remove event listeners:
addEventListener("x", fn, { signal }).
Interviewers look for: awareness that aborting on the client does not necessarily stop work on the server — it only stops waiting for the response.
AdvancedImplement Promise.all.
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const items = Array.from(iterable);
const results = new Array(items.length);
let remaining = items.length;
if (remaining === 0) return resolve(results);
items.forEach((item, i) => {
Promise.resolve(item).then((value) => {
results[i] = value; // keep input order
if (--remaining === 0) resolve(results);
}, reject); // first rejection wins
});
});
}
The points graders check: results kept in input order (not completion order), non-promise values handled via Promise.resolve, the empty-input case resolving immediately, and rejecting on the first failure. A common follow-up is a concurrency-limited version — run at most N at a time, starting the next as each finishes.
20. Modules & Tooling
BasicWhat is the difference between ES modules and CommonJS?
ESM (import/export) is the language standard: imports are static and resolved before code runs, loading is asynchronous, and it works natively in browsers and Node. CommonJS (require/module.exports) is Node's original system: require is a synchronous function call that can run anywhere.
| ESM | CommonJS | |
|---|---|---|
| Resolution | Static, analysable at build time | Dynamic, at runtime |
| Exports | Live bindings | A copied value (module.exports object) |
| Strict mode | Always | Opt-in |
Top-level await | Yes | No |
| Tree shaking | Yes | Poorly |
Modern Node can require() synchronous ESM, which has eased interop, but new code should be ESM.
BasicNamed exports vs default exports — which do you prefer?
Named exports must be imported by their exact name (renaming is explicit with as); a default export can be imported under any name, and there is one per module.
Many teams prefer named exports: consistent names across the codebase, better auto-import and refactoring in editors, typos fail at build time, and tree shaking is more reliable than with a default-exported object of utilities. Defaults are still common for a file's single component or for APIs that expect them, like React.lazy, which needs a default export.
IntermediateWhat is tree shaking, and what can prevent it from working?
Tree shaking is dead-code elimination at the module level: because ESM imports are static, the bundler can see which exports are never imported and drop them from the production bundle.
What breaks it:
- CommonJS dependencies — dynamic
requirecannot be analysed reliably. - Side effects — code that runs on import (polyfills, CSS imports, patching globals) must be kept. Libraries declare
"sideEffects": falseinpackage.jsonto tell the bundler it is safe to drop unused files. - Barrel files and namespace objects —
export default { a, b, c }or re-exporting everything throughindex.jscan drag in unused modules. - Importing the whole library:
import _ from "lodash"instead ofimport debounce from "lodash-es/debounce".
Interviewers look for: that you verify with a bundle analyser (e.g. rollup-plugin-visualizer, webpack-bundle-analyzer) rather than assume.
IntermediateWhat does a bundler do, and do we still need one with HTTP/2 and native ES modules?
A bundler walks the import graph from your entry points and produces optimised output: it transpiles TS/JSX, resolves bare imports from node_modules, tree shakes, minifies, splits code into chunks, processes CSS and assets, and adds content hashes to filenames for long-term caching.
Native ESM works in every browser, but shipping hundreds of unbundled modules still costs: the browser discovers imports one level at a time (request waterfalls), per-file compression is worse, and there is no tree shaking or minification. HTTP/2 reduces request overhead but not the waterfall. So production builds still bundle; dev servers like Vite skip bundling your own code because the speed matters more locally.
IntermediateVite vs webpack — how do they differ?
Vite serves your source as native ES modules in development, transforming files on demand, and pre-bundles dependencies with esbuild. Startup is near-instant regardless of app size and HMR only updates the changed module. For production it bundles with Rollup (newer versions use Rolldown, a Rust port).
webpack bundles the whole app before serving, even in dev, so start-up and rebuilds grow with the project. It is extremely configurable through loaders and plugins, has Module Federation built in, and powers a huge number of existing apps.
For new projects, Vite (or a framework on top of it) is the default choice; webpack remains common in legacy and heavily customised setups. Rspack/Turbopack are Rust-based alternatives aiming for webpack compatibility with better speed.
Interviewers look for: one consequence of Vite's dev/prod split — dev uses unbundled ESM and prod uses a bundle, so rare bugs appear only in the production build. Always test vite build && vite preview.
IntermediateWhat is the difference between transpiling and polyfilling?
Transpiling rewrites syntax the target browser does not understand into equivalent older syntax — optional chaining, class fields, JSX, TypeScript. Tools: Babel, SWC, esbuild, TypeScript.
Polyfilling adds missing APIs at runtime by implementing them in JavaScript — Array.prototype.at, structuredClone, Promise.withResolvers. Syntax cannot be polyfilled; APIs cannot be transpiled away.
Both are driven by your target list (a browserslist query or build target). Tools like core-js with Babel's useBuiltIns: "usage" include only the polyfills your code actually uses. Over-targeting old browsers bloats the bundle for everyone.
IntermediateWhat are source maps? Should you ship them to production?
A source map is a JSON file mapping positions in the minified, bundled output back to the original source files and lines. DevTools and error trackers use it to show readable stack traces and let you debug the original code.
In production, generate them but think about exposure:
- Public source maps expose your original source to anyone — usually acceptable for frontend code, which is shipped anyway, but a concern for some teams.
- Hidden source maps — generate the files without the
//# sourceMappingURLcomment and upload them to your error tracker (e.g. Sentry) only. You get readable stack traces without publishing the source.
AdvancedWhat are ESM live bindings? Predict the output.
// counter.js
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from "./counter.js";
inc();
console.log(count); // 1
// CommonJS equivalent
const { count: c, inc: i } = require("./counter.cjs");
i();
console.log(c); // 0 — destructured a copy
ESM imports are live, read-only views of the exporting module's variable, so the importer sees updates. Assigning to count from main.js throws. CommonJS copies values out of module.exports at require time.
Live bindings are also why ESM handles circular imports better: modules link before evaluating, so a cycle works as long as you do not read a binding before its module has run — otherwise you get a TDZ ReferenceError. In CommonJS a cycle quietly gives you a partially filled exports object.
Interviewers look for: the practical link to code splitting — import() is the dynamic counterpart, returning a promise for the module namespace and creating a separate chunk that loads on demand (routes, heavy charts, editors).
21. DOM Manipulation
BasicWhat is the DOM, and how is it different from the HTML source?
The DOM is the live, in-memory tree of objects the browser builds from the HTML, and JavaScript reads and changes that tree, not the source file.
The parser fixes broken markup (for example, it inserts a missing <tbody>), scripts add and remove nodes, and form fields keep their own live state. That's why "View Source" shows the original HTML and DevTools' Elements panel shows the current DOM. The DOM is also language-neutral: it's a set of interfaces (Node, Element, Document) that browsers expose to JS.
BasicWhat is the difference between innerHTML, textContent and innerText?
innerHTML parses a string as HTML, textContent reads or writes raw text for every descendant node, and innerText gives you text as it's rendered, taking CSS into account.
- innerHTML: an XSS risk if the string contains user input. It also destroys existing child nodes along with their listeners.
- textContent: fast and safe. It includes text in hidden elements and in
<script>/<style>. - innerText: respects
display:noneand line breaks. Reading it forces a layout, so it's slower.
Interviewers look for: "Use textContent for user data." If you need HTML from untrusted input, sanitise it (DOMPurify, or the Sanitizer API where it's supported).
BasicWhat is the difference between querySelectorAll and getElementsByClassName?
querySelectorAll returns a static NodeList, a snapshot taken at the moment you call it. getElementsBy* returns a live HTMLCollection that updates as the DOM changes.
A live collection can surprise you. If you loop over getElementsByClassName('item') and remove the class from each element, the collection shrinks while you're iterating and you skip every other element. A static NodeList has forEach; an HTMLCollection doesn't, so you have to spread it into an array first.
IntermediateWhat are reflow and repaint, and what is layout thrashing?
Reflow (layout) recalculates the geometry of elements, repaint redraws their pixels, and layout thrashing is when you interleave DOM reads and writes so the browser has to lay the page out again and again in a single frame.
// Bad: each read after a write forces a synchronous layout
items.forEach(el => {
el.style.width = box.offsetWidth + 'px'; // read, then write, repeated
});
// Good: read everything first, then write everything
const w = box.offsetWidth;
items.forEach(el => { el.style.width = w + 'px'; });
Layout-reading properties include offsetWidth/Height, getBoundingClientRect(), scrollTop and getComputedStyle(). Animating transform and opacity avoids layout entirely because only the compositor has to do work.
Interviewers look for: the batch-reads-then-writes pattern, and knowing that requestAnimationFrame or a library like fastdom exists to schedule writes.
IntermediateHow do you insert 1,000 list items efficiently?
Build the items off-DOM, in a DocumentFragment or with a single append(...nodes) call, and attach them in one operation so the browser does one layout instead of a thousand.
const frag = document.createDocumentFragment();
for (const item of data) {
const li = document.createElement('li');
li.textContent = item.name;
frag.append(li);
}
list.append(frag);
In practice, modern browsers already batch layout until something reads it, so the bigger costs are usually forced layout reads inside the loop and attaching one listener per item. Use event delegation instead. Past a few thousand rows, virtualise the list and render only the visible rows.
IntermediateWhat is the difference between an attribute and a property?
An attribute is what's written in the HTML (getAttribute). A property is the live value on the DOM object (el.value).
The classic example is <input value="a">. After the user types "b", input.value is "b" but input.getAttribute('value') is still "a", because the attribute now only acts as the default value (defaultValue). Some pairs reflect each other (id, className/class). Boolean attributes like disabled are true when present, whatever their value, so disabled="false" still disables the element.
AdvancedHow would you watch for DOM changes made by third-party code?
Use a MutationObserver. It batches changes to child lists, attributes and text, and delivers them asynchronously as a microtask.
const mo = new MutationObserver(records => {
for (const r of records) console.log(r.type, r.addedNodes);
});
mo.observe(root, { childList: true, subtree: true, attributes: true });
// later: mo.disconnect();
It replaced the deprecated synchronous Mutation Events, which were slow. Keep the callback cheap, narrow the scope with attributeFilter, and never make mutations inside the callback that it will observe again, or you get an infinite loop.
22. Events in Depth
BasicExplain event bubbling and capturing.
An event travels in three phases: capture (from window down to the target), target, then bubble (from the target back up to window). Listeners run in the bubble phase by default.
parent.addEventListener('click', h, { capture: true }); // runs on the way down
parent.addEventListener('click', h); // runs on the way up
Most events bubble. Notable ones that don't are focus/blur (use focusin/focusout), mouseenter/mouseleave, load and scroll on elements.
BasicWhat is the difference between event.target and event.currentTarget?
target is the element where the event started (the deepest element that was clicked). currentTarget is the element whose listener is running right now.
With delegation on a <ul>, a click on a <span> inside an <li> gives target = the span and currentTarget = the ul. That's why delegated handlers use e.target.closest('li').
BasicWhat is the difference between preventDefault and stopPropagation?
preventDefault() cancels the browser's default action, such as following a link, submitting a form or checking a checkbox. stopPropagation() stops the event from travelling to other elements. The two are completely independent.
stopImmediatePropagation() additionally stops the other listeners on the same element. return false only does both in jQuery and in inline on* handlers; in addEventListener it does nothing.
Interviewers look for: caution about stopPropagation. It breaks analytics, "click outside to close" logic, and any delegation higher up the tree. Usually the fix is to check e.target instead.
IntermediateWhat is event delegation, and why use it?
Delegation means attaching one listener to a common ancestor and working out which child was hit from e.target. It relies on events bubbling.
list.addEventListener('click', e => {
const btn = e.target.closest('button[data-id]');
if (!btn || !list.contains(btn)) return;
remove(btn.dataset.id);
});
Benefits: one listener instead of N, less memory, and elements added later work automatically. Watch out for events that don't bubble, and for e.target being a nested child. closest() solves the nested-child case.
IntermediateWhat are passive event listeners, and why do they matter?
{ passive: true } promises the browser that the listener won't call preventDefault(), so it can start scrolling immediately instead of waiting for your JS to finish.
Without it, a touchstart or wheel listener blocks scrolling on the main thread, and scrolling on mobile feels janky. Chrome treats touchstart/touchmove/wheel listeners on window, document and body as passive by default. Other useful options are once: true (auto-removed after the first call) and signal (remove the listener with an AbortController).
IntermediateThis listener never gets removed. Why?
el.addEventListener('click', () => save(id));
el.removeEventListener('click', () => save(id));
removeEventListener needs the same function reference (and the same capture flag). Two arrow functions are two different objects, even when their code is identical.
Fix it by storing the handler in a variable, or better, use an AbortController, which removes many listeners at once:
const ac = new AbortController();
el.addEventListener('click', onClick, { signal: ac.signal });
window.addEventListener('resize', onResize, { signal: ac.signal });
ac.abort(); // removes both
AdvancedHow do you create and dispatch custom events? Are they synchronous?
Use new CustomEvent(name, { detail, bubbles, composed }) and el.dispatchEvent(evt). Unlike native user events, dispatch is synchronous: every listener runs before dispatchEvent returns.
el.dispatchEvent(new CustomEvent('cart:add', {
detail: { id: 42 }, bubbles: true, composed: true
}));
bubbles defaults to false, which trips people up when their delegated listeners never fire. composed: true lets the event cross shadow DOM boundaries, which matters for Web Components. dispatchEvent returns false if a listener called preventDefault() on a cancelable event, which gives you a simple "veto" hook.
Interviewers look for: the fact that it's synchronous. Many candidates assume it's queued like a click.
23. Storage & Cookies
BasicCompare cookies, localStorage, sessionStorage and IndexedDB.
Cookies are small and sent to the server on every request. localStorage and sessionStorage are synchronous string key-value stores that exist only on the client. IndexedDB is an asynchronous, transactional database for large or structured data.
| Size | Sent to server | Lifetime | API | |
|---|---|---|---|---|
| Cookie | ~4 KB | Yes, automatically | Expires/Max-Age or session | String header |
| localStorage | ~5–10 MB | No | Until cleared | Sync, strings |
| sessionStorage | ~5 MB | No | Per tab, until the tab closes | Sync, strings |
| IndexedDB | A share of disk (large) | No | Until cleared or evicted | Async, structured clone |
All of them are scoped by origin. Cookies are the exception: they're scoped by domain and path, and they ignore the port.
BasicWhat do the HttpOnly, Secure and SameSite cookie flags do?
HttpOnly hides the cookie from JavaScript, which protects it from theft via XSS. Secure means it's only sent over HTTPS. SameSite controls whether it's sent on cross-site requests, which is a defence against CSRF.
SameSite=Strict: never sent cross-site, not even when the user clicks a link from another site.SameSite=Lax(the browser default): sent on top-level GET navigations, but not on cross-site POSTs, fetches or iframes.SameSite=None: always sent. RequiresSecure. Needed for embedded or third-party use.
Also useful: the __Host- prefix, which forces Secure, Path=/ and no Domain attribute, so a subdomain can't overwrite the cookie.
IntermediateWhere should you store a JWT or auth token on the frontend?
The safest common answer is an HttpOnly, Secure, SameSite cookie set by the server, so JavaScript can never read the token.
localStorage is easy, but any XSS can read the token and send it to an attacker, and the token keeps working until it expires. An HttpOnly cookie can't be stolen that way. The trade-off is CSRF exposure, which you mitigate with SameSite=Lax/Strict, CSRF tokens, or custom headers checked by the server.
A common SPA pattern is a short-lived access token kept in memory (a JS variable) plus a long-lived refresh token in an HttpOnly cookie, used to silently get a new access token after a reload.
Interviewers look for: the honest point that XSS is game over either way (an attacker can make requests as the user), but HttpOnly at least stops the token from leaving the browser. Mention CSRF as the trade-off.
IntermediateWhy is it a problem that localStorage is synchronous?
Every getItem/setItem blocks the main thread while the browser reads from or writes to disk, and large JSON blobs make it worse because you also pay for JSON.parse/stringify.
It's also unavailable in Web Workers and Service Workers, it stores only strings, and it can throw (in Safari private mode or when the quota is exceeded), so wrap it in try/catch. For anything big, or anything you query, use IndexedDB, ideally through a small wrapper like idb or Dexie.
IntermediateHow can two tabs of the same app stay in sync?
Listen for the storage event, which fires in other tabs when localStorage changes, or use BroadcastChannel for explicit messages.
const ch = new BroadcastChannel('auth');
ch.postMessage({ type: 'logout' });
ch.onmessage = e => { if (e.data.type === 'logout') redirectToLogin(); };
Typical uses are logging out every tab, keeping a cart in sync, or electing a single "leader" tab to hold a WebSocket (the Web Locks API helps with that).
AdvancedWhen would you choose IndexedDB, and what are its gotchas?
Choose IndexedDB for offline-first apps, caching large API responses, storing Blobs or files, or any data you need to query by index. It's asynchronous, transactional, and stores structured-clone values, not just strings.
Gotchas:
- The raw API is event-based and verbose, so use
idbor Dexie. - Transactions auto-commit once there are no pending requests at the end of a task. If you
awaitan unrelated fetch in the middle of a transaction, it closes underneath you. - Schema changes happen only in
onupgradeneeded, triggered by bumping the version number, and an old tab left open can block the upgrade. - The browser can evict storage under pressure unless you call
navigator.storage.persist().
24. Fetch, HTTP & CORS
BasicDoes fetch reject on a 404 or 500? How do you handle errors properly?
No. fetch rejects only on network failures (DNS, offline, CORS block, abort). An HTTP error status still resolves, with response.ok === false.
async function getJSON(url, opts) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Axios behaves differently: it rejects on any non-2xx status by default. Also remember that res.json() can itself throw if the body isn't valid JSON, for example an HTML error page.
Interviewers look for: checking res.ok immediately. The common follow-up is "how do you add a timeout?", and the answer is AbortSignal.timeout(5000).
BasicName the HTTP status codes you should know and what they mean.
- 200 OK, 201 Created, 204 No Content (no body, so don't call
.json()). - 301/308 permanent redirect, 302/307 temporary redirect (307 and 308 keep the method), 304 Not Modified (the cached copy is still valid).
- 400 bad request, 401 unauthenticated (who are you?), 403 forbidden (I know who you are, and you can't), 404 not found, 409 conflict, 422 validation failed, 429 rate-limited.
- 500 server bug, 502 bad gateway, 503 unavailable, 504 gateway timeout.
Interviewers look for: the 401 vs 403 distinction, and knowing that 429 and 503 often include a Retry-After header.
IntermediateWhat is CORS, and when does a preflight request happen?
CORS is how a server tells the browser which other origins may read its responses. The browser enforces it, not the server, and a request that CORS blocks may still have reached the server.
A preflight (OPTIONS) is sent before any "non-simple" request, meaning any method other than GET/HEAD/POST, custom headers such as Authorization, or a Content-Type other than form or text/plain (so application/json triggers one). The server must reply with matching Access-Control-Allow-Origin/Methods/Headers, and can cache that answer with Access-Control-Max-Age.
With credentials (cookies), the server must send an exact origin, not *, plus Access-Control-Allow-Credentials: true, and the client must set credentials: 'include'.
Interviewers look for: knowing CORS is not a security feature for the server. It protects the user's data in the browser. curl ignores it entirely. In development, the fix is a dev-server proxy, not disabling browser security.
IntermediateExplain Cache-Control, ETag and how the browser revalidates.
Cache-Control decides whether and for how long a response may be reused. ETag/Last-Modified let the browser ask the server "has this changed?" cheaply once the cached copy is stale.
max-age=31536000, immutable: for hashed static assets (app.3f9a.js). The browser never asks again.no-cache: may be stored, but must be revalidated every time. Good forindex.html.no-store: never stored. For sensitive data.privatevspublic: whether CDNs and shared proxies may cache the response.stale-while-revalidate=60: serve the stale copy instantly and refresh it in the background.
Revalidation: the browser sends If-None-Match: "etag". If nothing changed, the server replies 304 with no body.
IntermediateHow do you cancel a fetch, for example when a user types fast in a search box?
Pass an AbortController's signal to fetch, and abort the previous request before starting a new one.
let ctrl;
async function search(q) {
ctrl?.abort();
ctrl = new AbortController();
try {
const res = await fetch(`/api?q=${encodeURIComponent(q)}`, { signal: ctrl.signal });
render(await res.json());
} catch (e) {
if (e.name !== 'AbortError') throw e;
}
}
Combine this with debouncing. Aborting also prevents the race where a slow, older response arrives after a newer one and overwrites it. AbortSignal.any([...]) merges a user-cancel signal with a timeout signal.
IntermediateREST vs GraphQL: what are the trade-offs?
REST exposes resources at URLs and works naturally with HTTP caching. GraphQL exposes one endpoint where the client asks for exactly the fields it needs.
- GraphQL wins on over-fetching and under-fetching (one query instead of N round trips), on typed schemas and tooling, and when many different clients need different shapes of data.
- REST wins on simplicity, CDN/HTTP caching (GraphQL usually POSTs to one URL), simple file uploads, and easy rate limiting.
- GraphQL costs: N+1 queries on the server (solved with DataLoader), complexity limits against abusive queries, and client-side normalised caches (Apollo, urql).
AdvancedWhat changes with HTTP/2 and HTTP/3, and does it affect how you bundle?
HTTP/2 multiplexes many requests over one TCP connection and compresses headers. HTTP/3 runs over QUIC (UDP), which removes TCP head-of-line blocking and speeds up connection setup.
That made old HTTP/1.1 hacks (domain sharding, sprite sheets, one giant bundle) unnecessary or even harmful. You can ship more, smaller chunks so caching is more granular: a change to one module invalidates only its own chunk. You still don't want hundreds of tiny files, though, because every request has overhead and compression works better on larger files. Bundlers aim for a middle ground. HTTP/2 server push was deprecated. Use <link rel="preload"> and 103 Early Hints instead.
25. Browser APIs Worth Knowing
BasicWhat is IntersectionObserver, and what would you use it for?
It tells you asynchronously when an element enters or leaves the viewport (or any ancestor you choose as the root), with no scroll listeners and no layout reads.
const io = new IntersectionObserver(entries => {
for (const e of entries) if (e.isIntersecting) loadMore();
}, { rootMargin: '200px' });
io.observe(sentinel);
Uses: infinite scroll with a sentinel element, lazy-loading images and components, analytics for "was this seen?", pausing off-screen videos, and scroll-triggered animations. rootMargin lets you start loading before the element is actually visible.
Interviewers look for: why it beats a scroll listener with getBoundingClientRect. It's off the main thread's hot path and causes no forced layout. For plain images, loading="lazy" is simpler still.
BasicWhy use requestAnimationFrame instead of setTimeout for animation?
requestAnimationFrame runs your callback right before the next paint, in step with the display's refresh rate, and pauses in background tabs. setTimeout(16) drifts, can fire mid-frame, and causes dropped or doubled frames.
Use the timestamp argument to compute progress from elapsed time rather than per frame, so the animation runs at the same speed on 60 Hz and 120 Hz screens. Prefer CSS transitions or the Web Animations API where you can, because they can run on the compositor.
IntermediateWhat is ResizeObserver, and how is it different from the window resize event?
ResizeObserver fires when a specific element's size changes, whatever the cause: the window, a sibling, a font loading, or a sidebar collapsing. window.resize fires only when the viewport changes.
It's ideal for charts, canvases and truncation logic that depend on their container. For purely CSS-driven layout changes, container queries (@container) now handle a lot of this with no JavaScript. Watch out for the "ResizeObserver loop" error if your callback changes the size of the element it's observing.
IntermediateWhat is a Web Worker, and what can't it do?
A Web Worker runs JavaScript on a separate thread, so heavy CPU work doesn't freeze the UI. It communicates with the page through postMessage.
It has no DOM access, no window and no localStorage. It does have fetch, IndexedDB, timers and WebSockets. Data is copied with structured clone, which is slow for big objects, unless you transfer ArrayBuffers (zero-copy) or use SharedArrayBuffer, which requires cross-origin isolation.
Good fits: parsing large files, image processing, search indexing, diffing, crypto. Comlink makes calling a worker feel like calling a normal async function.
IntermediateHow does client-side routing use the History API?
history.pushState(state, '', url) changes the URL and adds a history entry without reloading the page. Your router then renders the matching view. popstate fires when the user presses Back or Forward.
Note that pushState itself does not fire popstate, so routers call their own render function after pushing. replaceState updates the URL without adding an entry, which suits filter or query changes. The server must return index.html for every app route, or a hard refresh on /users/5 returns a 404. The newer Navigation API (navigation.addEventListener('navigate')) unifies all of this, and browser support is growing.
AdvancedWhat is a Service Worker, and how does its lifecycle work?
A Service Worker is a script that acts as a network proxy between your page and the network. It can intercept fetch, serve responses from a cache, work offline, and receive push notifications. It needs HTTPS and has no DOM access.
Lifecycle: register → install (pre-cache assets) → waiting → activate (clean up old caches) → controls pages. A new version waits until every tab using the old one closes, unless you call skipWaiting() and clients.claim(), and doing that risks mixing old HTML with new JS.
Caching strategies: cache-first for hashed assets, network-first for HTML and APIs, stale-while-revalidate for things like avatars. Workbox wraps all of this.
Interviewers look for: the "users stuck on an old version" problem, and how to handle it by showing a "New version available, reload" prompt.
AdvancedHow would you run low-priority work without hurting interactivity?
Break the work into chunks and yield to the main thread between them, so input events get handled. This keeps Interaction to Next Paint (INP) low.
requestIdleCallback: runs when the browser is idle (not in Safari).scheduler.postTask(fn, { priority: 'background' })andscheduler.yield(): the modern prioritised scheduling API (Chromium).- Fallback:
await new Promise(r => setTimeout(r, 0))between chunks. - Or move the work into a Web Worker entirely.
Rule of thumb: keep each task under about 50 ms, which is the threshold for a "long task".
26. Mental Model & JSX
BasicWhat does JSX compile to?
JSX is syntax sugar for function calls that produce plain objects describing the UI. It is not HTML.
<Button kind="primary">Save</Button>
// modern "automatic" runtime (React 17+):
_jsx(Button, { kind: 'primary', children: 'Save' })
// classic runtime:
React.createElement(Button, { kind: 'primary' }, 'Save')
That explains the rules: className and htmlFor (they're JS property names), one root element per expression (a function returns one value, so use a <> fragment), {} for expressions only (no if statements), and capitalised component names (lowercase means a DOM tag string).
BasicWhat is the virtual DOM, and is it why React is fast?
The virtual DOM is the tree of lightweight element objects your components return. React compares the new tree with the previous one and applies only the minimal real DOM changes.
It isn't "faster than the DOM". Hand-written targeted DOM updates are faster. What it gives you is a declarative model: you describe the UI for the current state, and React works out the updates with performance that's good enough by default. Frameworks like Svelte and Solid skip the virtual DOM by compiling to fine-grained updates.
Interviewers look for: not claiming the virtual DOM is magically fast. Say "it's a way to make declarative UI practical."
IntermediateHow does reconciliation work?
React diffs the trees using two heuristics that make it O(n) instead of O(n³): elements of different types produce different trees, and keys identify children across renders.
- Same type at the same position: keep the DOM node or component instance and its state, and update its props.
- Different type (
<div>→<span>, or<A/>→<B/>): unmount the whole subtree, including its state, and mount a new one. - Lists: children are matched by key, not by index.
That's why defining a component inside another component is a bug. The inner component gets a new function identity every render, so React treats it as a new type, remounts it, and wipes its state and input focus.
IntermediateWhy do lists need keys, and what goes wrong with index keys?
Keys tell React which item is which between renders, so it can reorder, insert and delete items while keeping each item's state and DOM node attached to the right data.
With index keys, inserting an item at the top shifts every index by one. React then thinks item 0 simply changed its props, so local state (a checked box, text typed into an input, an open accordion) stays with the position instead of the item. Index keys are fine only for static lists that never reorder and have no per-item state. Use a stable ID from the data, and never Math.random(), which remounts every item on every render.
IntermediateHow can you reset a component's state on purpose?
Change its key. A different key makes React unmount the old instance and mount a fresh one.
<ProfileForm key={userId} user={user} />
This is the clean replacement for "useEffect that resets state when a prop changes". The flip side: rendering the same component type at the same position keeps its state even when its props change completely.
IntermediatePredict the output: what renders for {count && <Badge/>} when count is 0?
It renders the text "0". 0 && x evaluates to 0, and React renders numbers. It only skips false, null, undefined and true.
Fix it with {count > 0 && <Badge/>}, {!!count && ...}, or a ternary. The same trap appears with NaN and empty strings.
AdvancedWhat is Fiber, and what does "concurrent rendering" actually mean?
Fiber is React's internal reimplementation of reconciliation as a linked list of units of work. Rendering can therefore be paused, resumed, prioritised or thrown away instead of running as one blocking recursive pass.
Work happens in two phases. The render phase calls your components and computes the diff; it's interruptible, so it must be pure. The commit phase applies the DOM changes and runs layout effects; it's synchronous. Concurrent features (React 18+), such as startTransition, useDeferredValue and Suspense, let urgent updates like typing interrupt a non-urgent render already in progress.
Interviewers look for: the link to purity. Because a render can run more than once or be thrown away, side effects in the component body are bugs. That's also why StrictMode double-invokes renders.
27. State & Rendering
BasicWhat is the difference between props and state?
Props are inputs passed in by the parent and are read-only to the child. State is data a component owns and changes over time, and changing it triggers a re-render.
If a value can be computed from props or other state, it should be neither. Compute it during render. Copying props into state (useState(props.value)) initialises it only once and then goes out of sync.
BasicPredict the output: count starts at 0, and a click runs this. What is count afterwards?
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
It's 1. Every call reads the same count captured for this render (0), so each one queues "set to 1".
Use the updater form to build on the pending value:
setCount(c => c + 1); // x3 → 3
And console.log(count) right after calling setCount still prints the old value. State is a snapshot for each render, not a variable that changes in place.
Interviewers look for: the word "snapshot", and the rule of using the updater whenever the next state depends on the previous one.
BasicWhen does a React component re-render?
A component re-renders when (1) its own state changes, (2) its parent re-renders, or (3) a context it consumes changes. Changing props alone doesn't trigger anything; props change because the parent re-rendered.
A common misconception is "it re-renders when its props change". In fact, by default every child re-renders whenever its parent does, even with identical props, unless the child is wrapped in React.memo. Also, setting state to the same value (by Object.is) bails out.
IntermediateWhat is automatic batching in React 18?
React groups multiple state updates into one re-render, and since React 18 it does this everywhere: in event handlers, promises, setTimeout and native listeners.
Before React 18, updates inside a setTimeout or a .then each caused a separate render. If you really need a synchronous DOM update in the middle of a handler (to measure layout, for example), flushSync(() => setX(...)) opts out, but it's rarely needed.
IntermediateThis code updates state but the UI doesn't change. Why?
const add = item => {
todos.push(item);
setTodos(todos);
};
The array was mutated, so setTodos receives the same reference. React compares with Object.is, sees no change, and skips the render.
setTodos(prev => [...prev, item]);
setUser(prev => ({ ...prev, address: { ...prev.address, city } }));
Immutable updates also make memoisation (React.memo, dependency arrays) reliable, because "changed" becomes a cheap reference check. For deeply nested updates, Immer lets you write mutating syntax that produces immutable results. The ES2023 methods toSorted, toSpliced and with help too.
IntermediateWhat is "lifting state up", and when does it stop being the right answer?
When two siblings need the same data, you move the state to their closest common parent and pass it down as props, together with callbacks to change it. There's then one source of truth.
It stops scaling when the state has to travel through many layers that don't use it (prop drilling), or when high-level state makes large subtrees re-render. Try these in order: composition (pass elements as children so the layers in between don't need the props), context for low-frequency values, then a store (Zustand, Redux) for frequent, shared, client-owned state. For server data, use a data-fetching cache instead.
AdvancedWhy is storing derived state in useState an anti-pattern? Show the fix.
Two copies of the same truth drift apart. You need an effect to keep them in sync, which costs an extra render, briefly shows stale UI, and invites bugs.
// Bad
const [filtered, setFiltered] = useState([]);
useEffect(() => setFiltered(items.filter(i => i.done)), [items]);
// Good: compute during render (memoise only if it's actually expensive)
const filtered = useMemo(() => items.filter(i => i.done), [items]);
Similarly, store selectedId rather than a copy of the selected object, and look the object up during render so it's always current.
28. Effects & Lifecycle
BasicWhat does the dependency array in useEffect do?
It tells React when to re-run the effect. No array means after every render, [] means once after mount, and [a, b] means after mount and whenever a or b changes (compared with Object.is).
The array isn't a list of "when I want this to run". It must include every reactive value the effect reads (props, state, and functions defined in the component). Leaving values out causes stale closures. Follow eslint-plugin-react-hooks rather than silencing it.
Interviewers look for: "Effects synchronise with external systems", not "effects are lifecycle methods." Thinking in terms of mount and update is the old class-component model.
BasicWhat is the cleanup function, and when does it run?
It's the function you return from an effect. It runs before the effect runs again (with the old values) and when the component unmounts.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
Use it to undo whatever the effect set up: subscriptions, timers, listeners, sockets, in-flight requests. Without cleanup you get leaks, duplicate listeners, and "setState on unmounted component" style bugs.
IntermediateWhy do effects run twice in development?
In development, <StrictMode> deliberately mounts, unmounts and remounts each component (running effect → cleanup → effect) to reveal effects that lack a proper cleanup.
If running twice breaks your code (two sockets, two analytics events), the effect isn't resilient, so fix the cleanup. It doesn't happen in production. It also prepares code for features like Activity (formerly Offscreen), which hide and restore components while keeping their state. Don't "fix" it with a useRef flag that skips the second run.
IntermediateThis fetch effect sometimes shows the wrong user. Why, and how do you fix it?
useEffect(() => {
fetch(`/api/users/${id}`).then(r => r.json()).then(setUser);
}, [id]);
It's a race condition. If id changes from 1 to 2 and request 1 responds after request 2, the stale response overwrites the correct one.
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${id}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== 'AbortError') setError(e); });
return () => ctrl.abort();
}, [id]);
A simpler alternative is a let ignore = false flag set in the cleanup. In real apps, use React Query, SWR or your framework's loader, which handle races, caching and deduplication for you.
Interviewers look for: naming it a race condition without being prompted, and mentioning that a library is the production answer.
IntermediateGive examples where you don't need an effect.
If there's no external system involved, you probably don't need an effect.
- Derived data (a filtered list, a full name): compute it during render.
- Responding to a user event (submit, then POST, then show a toast): do it in the event handler, not in an effect watching a "submitted" flag.
- Resetting state when a prop changes: use a
key. - Notifying the parent of a change: call the parent's callback in the same handler that sets state.
- Chains of effects where each sets state that triggers the next: compute everything in one handler.
Effects are for syncing with things outside React: the DOM API, network subscriptions, timers, and third-party widgets.
IntermediateWhat is the difference between useEffect and useLayoutEffect?
useEffect runs after the browser paints and doesn't block it. useLayoutEffect runs after the DOM is updated but before paint, synchronously.
Use useLayoutEffect only when you need to measure the DOM and change it before the user sees anything, for example positioning a tooltip based on its measured size, which would otherwise flicker. It blocks painting, so keep it rare. It also does nothing during SSR and produces a warning there. useInsertionEffect exists for CSS-in-JS libraries to inject styles.
AdvancedA setInterval inside useEffect always logs the initial count. Why? Give two fixes.
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count stays 1 forever
It's a stale closure. The callback captured count from the first render, and the empty dependency array means the effect never picks up a newer value.
- Use the updater:
setCount(c => c + 1). The effect then no longer depends on count. - If you need to read the latest value (to log it or send it), keep it in a ref that you update each render, or use
useEffectEvent(stable in React 19.2) to read the latest props and state without re-subscribing.
Adding count to the dependencies also works, but it tears down and recreates the interval on every tick.
29. Hooks in Depth
BasicWhat are the rules of hooks, and why do they exist?
Call hooks only at the top level of a component or custom hook: never inside conditions, loops or nested functions. Call them only from React functions.
The reason: React doesn't identify hooks by name. It stores them in a list on the component's fiber and matches them by call order on each render. If a hook is skipped conditionally, every later hook reads the wrong slot, and state ends up in the wrong place.
The one exception is React 19's use(), which can be called conditionally because it doesn't store state in a slot.
Interviewers look for: "call order", "array" or "linked list". That shows you understand the mechanism rather than just reciting the rule.
BasicWhat is useRef for? How is it different from state?
useRef gives you a mutable box (.current) that survives across renders, and changing it does not trigger a re-render.
It has two jobs: (1) holding a reference to a DOM node (ref={inputRef}, then inputRef.current.focus()), and (2) storing values that must persist but don't affect rendering, such as timer IDs, the previous value, a latest-callback reference, or an AbortController. Don't read or write ref.current during render, because the UI won't update to match it. In React 19, ref can be passed as a normal prop, so forwardRef is no longer required.
IntermediateWhen would you choose useReducer over useState?
Choose useReducer when several pieces of state change together, when the next state depends on the previous state in complex ways, or when you want the update logic in one pure, testable function.
function reducer(state, action) {
switch (action.type) {
case 'fetch': return { ...state, status: 'loading' };
case 'success': return { status: 'done', data: action.data, error: null };
case 'error': return { ...state, status: 'error', error: action.error };
default: return state;
}
}
This prevents impossible combinations like loading: true together with error. Also, dispatch is stable, so you can pass it down through context without breaking memoisation.
IntermediateWhat is a custom hook? Do two components using the same hook share state?
A custom hook is a function whose name starts with use and that calls other hooks, so you can reuse stateful logic. It does not share state: each component that calls it gets its own independent copy.
function useDebounced(value, ms = 300) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
To actually share state, lift it up, use context, or use an external store with useSyncExternalStore.
IntermediateWhen do useMemo and useCallback actually help?
They help in two cases: (1) the computation is genuinely expensive, or (2) you need a stable reference for a memoised child's props or for an effect's dependencies.
useCallback(fn, deps) is just useMemo(() => fn, deps). Wrapping every callback adds overhead and noise for no gain if the receiving child isn't wrapped in React.memo, because it re-renders anyway. Measure with the React Profiler first.
React Compiler (stable in 2025) memoises components and values automatically at build time, which makes most hand-written useMemo/useCallback unnecessary in projects that use it.
Interviewers look for: "Memoisation has a cost, and it only matters when something downstream compares references." Mentioning React Compiler shows you're up to date.
IntermediateHow does useContext work, and what is its main pitfall?
A Provider supplies a value, and any descendant calling useContext(Ctx) (or use(Ctx) in React 19) reads the value from the nearest Provider, skipping the layers in between.
The pitfall is that every consumer re-renders whenever the value changes, and there's no way to subscribe to just one slice. Passing value={{ user, setUser }} creates a new object every time the Provider's parent renders, which re-renders all consumers. Memoise the value, split fast-changing and slow-changing contexts, or use a store library that supports selectors.
AdvancedExplain useTransition and useDeferredValue. When would you use each?
Both mark work as non-urgent so React can interrupt it to keep typing and clicking responsive. useTransition wraps the state update. useDeferredValue wraps a value you receive.
const [isPending, startTransition] = useTransition();
const onChange = e => {
setQuery(e.target.value); // urgent: the input stays snappy
startTransition(() => setFilter(e.target.value)); // can be interrupted
};
// or, when you only have the value (e.g. from props):
const deferred = useDeferredValue(query);
const list = useMemo(() => bigFilter(deferred), [deferred]);
Use useTransition when you own the setter (tab switches, filters, navigation). Use useDeferredValue when the value comes from elsewhere. Neither replaces debouncing network calls; they're about CPU rendering work. In React 19, transitions accept async functions ("Actions"), and isPending stays true until the promise settles.
AdvancedWhat is useSyncExternalStore for?
It's the correct way to subscribe to a store that lives outside React (Redux, Zustand, the browser's navigator.onLine, matchMedia) without tearing, where different components show different versions of the store during a single concurrent render.
const online = useSyncExternalStore(
cb => { addEventListener('online', cb); addEventListener('offline', cb);
return () => { removeEventListener('online', cb); removeEventListener('offline', cb); }; },
() => navigator.onLine,
() => true // server snapshot
);
getSnapshot must return a cached or immutable value. Returning a new object every time causes an infinite loop.
30. Performance & Patterns
BasicWhat does React.memo do, and why might it not work?
React.memo(Component) skips re-rendering the component when its props are shallowly equal to last time.
It silently stops working if the parent passes a new reference on every render: an inline object style={{...}}, an array, an arrow function onClick={() => ...}, or JSX children. You need useMemo/useCallback for those props, or you need to restructure the components. A memoised component still re-renders on its own state changes and context changes.
Interviewers look for: the "inline function breaks memo" trap. Ask "what did the Profiler show?" before memoising.
BasicWhat are error boundaries, and what don't they catch?
An error boundary is a component that catches errors thrown during rendering, in lifecycle methods and in constructors of its subtree, and shows fallback UI instead of unmounting the whole app.
They don't catch errors in event handlers (use try/catch there), in async code like setTimeout or promises, errors during SSR, or errors thrown by the boundary itself. They still have to be class components (componentDidCatch/getDerivedStateFromError), so most teams use react-error-boundary. Place them around routes and widgets so one crash doesn't blank the page.
IntermediateHow do you fix a context that makes the whole app re-render?
Every consumer re-renders when the context value changes. Shrink the blast radius:
- Memoise the value:
useMemo(() => ({ user, login }), [user]). - Split contexts: separate state from dispatch (dispatch never changes), and separate fast-changing data from slow-changing data.
- Move the Provider down so it wraps only the subtree that needs it.
- Use a store with selectors (Zustand, Redux, Jotai) for frequently changing shared state, so components re-render only when their slice changes.
Context is a dependency-injection mechanism, not a state manager. It's ideal for theme, locale and the current user.
IntermediateHow does code splitting work in React?
React.lazy(() => import('./Page')) turns a component into its own chunk that loads on first render, and <Suspense fallback> shows a placeholder while it loads.
const Settings = lazy(() => import('./Settings'));
<Suspense fallback={<Spinner/>}><Settings/></Suspense>
Split at route boundaries first, then heavy, rarely used widgets (editors, charts, modals). Declare lazy at module level, never inside a component. Preload on hover or intent by calling the import() early. Wrap the lazy component in an error boundary too, because the chunk can fail to load after a deploy.
IntermediateHow would you render a list of 10,000 rows smoothly?
Virtualise (window) it: render only the roughly 20–30 rows currently visible, plus a small overscan buffer, inside a scroll container sized to the full height.
Use a library such as TanStack Virtual, react-window or react-virtuoso (which handles variable heights well). Also give rows stable keys, memoise the row component, and avoid heavy work per row. The trade-offs: in-page find (Ctrl+F) and screen readers can't see rows that aren't rendered, and dynamic row heights need measuring. The CSS alternative content-visibility: auto skips painting off-screen content with no JS, but it still creates every node.
IntermediateWhat are compound components? Give an example.
Compound components are a set of components that work together and share implicit state through context, giving users a flexible, declarative API instead of a large bag of props.
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Trigger value="a">Account</Tabs.Trigger>
<Tabs.Trigger value="b">Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="a">...</Tabs.Panel>
</Tabs>
Tabs holds the active value in context, and Trigger/Panel read it. Consumers control the layout and markup freely. Radix UI and Headless UI are built this way.
AdvancedRender props, HOCs and hooks: how do they compare?
All three share logic between components. Hooks replaced most uses of the other two because they compose without extra nesting and without prop collisions.
- HOC (
withAuth(Page)): wraps a component and injects props. Downsides: props arrive from an unclear source, names can collide, and you get "wrapper hell" in DevTools. Still useful for cross-cutting wrappers. - Render props (
<Mouse>{pos => ...}</Mouse>): explicit about data flow, but nesting gets deep. Still useful when a library must control where something renders (for example, virtualisation row renderers). - Custom hooks: plain functions returning values, flat and composable. The default today.
AdvancedHow can you avoid a re-render without using memo at all?
Use composition: move state down into the component that needs it, or lift content up by passing expensive subtrees as children.
function ScrollTracker({ children }) {
const [y, setY] = useState(0); // changes often
// ...
return <div>{y}{children}</div>;
}
<ScrollTracker><ExpensiveTree/></ScrollTracker>
ExpensiveTree is created by the outer component, so its element reference is the same when ScrollTracker re-renders, and React skips it. No memo is needed.
Interviewers look for: this answer at senior level. It shows you understand why re-renders happen, not just the APIs that suppress them.
31. Data, Routing & Forms
BasicWhat is the difference between controlled and uncontrolled inputs?
In a controlled input, React state is the source of truth (value + onChange). In an uncontrolled input, the DOM holds the value and you read it when you need it, through a ref or FormData on submit.
Controlled inputs make instant validation, formatting and conditional UI easy, but every keystroke causes a re-render. Uncontrolled inputs are simpler and faster for large forms. That's why React Hook Form works mostly uncontrolled, and why React 19 form Actions (<form action={fn}>, useActionState) lean on FormData.
Interviewers look for: the warning "a component is changing an uncontrolled input to be controlled", which happens when value starts as undefined. Initialise it with ''.
BasicHow does client-side routing differ from traditional navigation?
With client-side routing, the app intercepts link clicks, updates the URL through the History API, and renders the new view in JavaScript, with no full page reload. Traditional navigation requests a whole new HTML document from the server.
Benefits: navigation is faster and state persists (layout, player, scroll inside panels). Costs: you have to handle focus and scroll restoration and announce the new page to screen readers, and the server must serve the app for every route. Routers like React Router and TanStack Router add nested layouts, params, loaders that fetch data before render, and lazy routes.
IntermediateWhy use React Query (TanStack Query) instead of fetching in useEffect?
Server data is a cache of something you don't own. React Query handles the hard parts that a handwritten useEffect gets wrong.
- Caching and deduplication: ten components asking for
['user', 5]make one request. - Stale-while-revalidate, background refetching on window focus or reconnect, and retries.
- No race conditions, plus built-in loading and error states.
- Pagination, infinite queries, prefetching, mutations with invalidation, and optimistic updates.
The key idea: split server state (React Query, SWR, RTK Query, framework loaders) from client state (useState, Zustand). Most "global state" in apps turns out to be server cache.
IntermediateHow do you choose a global state solution?
Start local, and only escalate when there's a concrete problem.
| Need | Tool |
|---|---|
| Local UI state | useState / useReducer |
| Shared by nearby components | Lift state up / composition |
| Low-frequency global state (theme, auth, locale) | Context |
| Frequent, shared client state | Zustand, Jotai, Redux Toolkit |
| Server data | TanStack Query / SWR / RTK Query |
| Shareable or bookmarkable state (filters, tabs) | URL search params |
Redux Toolkit makes sense for large teams that want strict conventions and DevTools time-travel. Zustand is minimal and supports selectors.
IntermediateHow do you implement an optimistic update, and how do you roll it back?
Update the UI immediately as if the request succeeded, send the request, and restore the snapshot if it fails.
useMutation({
mutationFn: toggleLike,
onMutate: async id => {
await qc.cancelQueries({ queryKey: ['post', id] });
const prev = qc.getQueryData(['post', id]);
qc.setQueryData(['post', id], p => ({ ...p, liked: !p.liked }));
return { prev };
},
onError: (_e, id, ctx) => qc.setQueryData(['post', id], ctx.prev),
onSettled: (_d, _e, id) => qc.invalidateQueries({ queryKey: ['post', id] }),
});
React 19 has useOptimistic, which shows a temporary value while an Action is pending and automatically reverts when the Action finishes. Use optimistic updates for low-risk, likely-to-succeed actions (likes, toggles, reordering), not for payments.
IntermediateCompare CSR, SSR, SSG and ISR.
- CSR: the server sends an empty shell and JS builds the page. Simple hosting, but slow first paint and weaker SEO. Fine for dashboards behind a login.
- SSR: HTML is rendered on each request, then hydrated to become interactive. Fast first content and fresh data, but costs server time per request, and TTFB depends on your data fetching.
- SSG: HTML is generated at build time and served from a CDN. Fastest, but the data is only as fresh as the last build. Good for docs, blogs and marketing pages.
- ISR / revalidation: static pages regenerated in the background after N seconds or on demand. Near-SSG speed with fresher data.
Next.js lets you choose per route. Streaming SSR with Suspense sends the page shell first and fills in slow parts as their data arrives.
AdvancedWhat are React Server Components, and how do they differ from SSR?
Server Components run only on the server and ship zero JavaScript for themselves. They can read the database or filesystem directly and send a serialised UI tree to the client. SSR, by contrast, renders client components to HTML and then ships their JS so they can hydrate.
In the Next.js App Router, components are server components by default. You add 'use client' at the boundary where you need state, effects or event handlers. Server components can render client components and pass them serialisable props, but not the other way round (except by passing server components as children). Server Actions ('use server') let forms call server functions directly.
Trade-offs: smaller bundles and data fetching colocated with components, in exchange for a new mental model, serialisation limits, and more complex caching.
Interviewers look for: "RSC and SSR are complementary, not alternatives". RSC decides where a component runs; SSR decides when the HTML is produced.
AdvancedWhat is a hydration mismatch, and how do you fix one?
Hydration attaches React to server-rendered HTML, and it expects the first client render to produce exactly the same markup. When the two differ, you get warnings, and React may throw away the server HTML and render the page again on the client.
Common causes: Date.now(), Math.random(), locale or timezone formatting, reading window or localStorage during render, invalid nesting (a <div> inside a <p>), and browser extensions that modify the DOM.
Fixes: render client-only values after mount (in a useEffect that sets state), use useId for IDs, pass consistent timezone or locale data from the server, use suppressHydrationWarning for unavoidable one-offs like timestamps, or load client-only widgets with dynamic(..., { ssr: false }).
32. Performance
BasicWhat are Core Web Vitals, and what are the "good" thresholds?
Core Web Vitals are Google's three user-centric metrics: LCP (loading), INP (responsiveness) and CLS (visual stability), measured at the 75th percentile of real users.
| Metric | Measures | Good |
|---|---|---|
| LCP — Largest Contentful Paint | When the biggest image or text block in the viewport renders | < 2.5s |
| INP — Interaction to Next Paint | Delay from a click/tap/keypress to the next frame, worst-ish across the visit | < 200ms |
| CLS — Cumulative Layout Shift | How much visible content jumps unexpectedly | < 0.1 |
INP replaced FID in March 2024. FID only measured the input delay of the first interaction; INP covers every interaction and includes processing time and rendering.
Interviewers look for: knowing that these are field metrics (real users, CrUX) and that Lighthouse gives lab values — Lighthouse cannot measure INP directly and uses Total Blocking Time as a proxy.
BasicWhat is the difference between debounce and throttle, and when do you use each?
Debounce waits until events stop for N ms and then fires once; throttle fires at most once every N ms while events keep coming.
- Debounce — search-as-you-type, auto-save, window resize "done" handling, form validation. You only care about the final value.
- Throttle — scroll position tracking, drag, mousemove, infinite-scroll checks. You want regular updates during the activity.
For purely visual work tied to frames, requestAnimationFrame is often a better "throttle" because it syncs to the display refresh. For "is it visible yet?" checks, IntersectionObserver removes the need for scroll handlers altogether.
Interviewers look for: a follow-up of "implement it" — be ready to write debounce with clearTimeout, preserving this and arguments (see Machine Coding below).
IntermediateYour LCP is 4.5 seconds. How do you diagnose and fix it?
First identify the LCP element (Performance panel or Lighthouse tells you), then break LCP into its four sub-parts and fix whichever is largest.
- TTFB — slow server or no CDN. Fix with caching, CDN, edge rendering, fewer redirects.
- Resource load delay — the browser discovers the hero image late (set in CSS, injected by JS, or lazy-loaded). Put it in the HTML as an
<img>, addfetchpriority="high"or a<link rel="preload">, and neverloading="lazy"it. - Resource load time — the image is too big. Serve AVIF/WebP, correct dimensions via
srcset/sizes, compress. - Render delay — the image is downloaded but render-blocking CSS/JS or client-side rendering holds it back. Inline critical CSS,
deferscripts, server-render the above-the-fold content.
Interviewers look for: the sub-part breakdown instead of a random list of tips, and the trap that lazy-loading the hero image makes LCP worse.
IntermediateWhat causes layout shift (CLS) and how do you prevent it?
CLS happens when content that is already visible moves because something above or around it changed size after rendering.
- Images and video without dimensions → always set
width/heightattributes oraspect-ratioso space is reserved. - Ads, embeds, iframes, late banners → reserve a fixed-size slot with
min-height; insert new UI below the fold or as an overlay. - Web fonts swapping → preload the main font and use
size-adjust/ascent-overrideon the fallback so metrics match; orfont-display: optional. - Animating layout properties → animate
transforminstead oftop/height.
Shifts within 500ms of a user interaction do not count, so an accordion opening on click is fine.
IntermediateHow would you reduce a large JavaScript bundle?
Measure what is in it, then ship less and ship it later.
- Analyse —
rollup-plugin-visualizer/webpack-bundle-analyzer, and the Coverage tab in DevTools for unused code. - Replace heavy dependencies — moment → date-fns or
Intl; whole lodash → native methods or per-function imports. - Code-split — route-level
React.lazy+Suspense, dynamicimport()for heavy features (charts, editors) loaded on interaction. - Tree-shaking — use ESM packages, mark
"sideEffects": false, avoid barrel files that pull everything in. - Modern output — target modern browsers to avoid unnecessary polyfills; compress with Brotli.
- Move work to the server — React Server Components or SSR so some components never ship JS.
Interviewers look for: "measure first" and a budget (e.g. a CI check that fails when the main chunk grows past a limit) so the fix sticks.
IntermediateExplain an effective caching strategy for a frontend app's static assets.
Fingerprinted assets get cached forever; the HTML that references them is always revalidated.
# app.3f9a1c.js, styles.a81b.css, fonts, images with hashed names
Cache-Control: public, max-age=31536000, immutable
# index.html
Cache-Control: no-cache # store, but revalidate every time (ETag → 304)
When you deploy, the content hash changes, the new HTML points to new filenames, and users get fresh code without anyone ever needing to purge a cache. API responses are cached separately — often stale-while-revalidate on the CDN or a client cache like React Query / SWR.
Interviewers look for: that no-cache does not mean "don't cache" (that is no-store), and why caching index.html long-term causes users to be stuck on old deploys.
AdvancedUsers report the UI feels laggy when typing into a filter box over a 10,000-row table. How do you fix the INP?
The keystroke is blocked by a long task — filtering and re-rendering 10,000 rows synchronously. The fix is to make the urgent update (the input) cheap and the expensive update deferrable, and to render less.
- Render less — virtualise the table (TanStack Virtual, react-window) so only ~30 rows exist in the DOM. This is usually the biggest win.
- Prioritise the input — in React 18+,
useDeferredValue(query)orstartTransitionlets the input update immediately while the list re-renders at lower priority and can be interrupted. - Avoid wasted renders — memoise the filtered list with
useMemo, memoise row components, stable keys. - Move heavy computation off the main thread — a Web Worker for fuzzy search on large data.
- Yield — break long loops into chunks with
scheduler.yield()(where supported) orsetTimeout.
const [query, setQuery] = useState("");
const deferred = useDeferredValue(query);
const rows = useMemo(() => filterRows(data, deferred), [data, deferred]);
Interviewers look for: confirming the diagnosis in the Performance panel (long tasks, red triangles) before fixing, and knowing debounce helps but just delays the jank — virtualisation and transitions actually remove it.
AdvancedWhat is the difference between lab and field performance data, and how do you collect field data?
Lab data comes from a controlled run (Lighthouse, WebPageTest) — reproducible and good for debugging. Field data (RUM) comes from real users on real devices and networks — what Google ranks on and what users actually feel.
They often disagree: your M-series laptop with a warm cache is not a mid-range Android on 4G. INP especially can only be measured in the field because it needs real interactions.
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
navigator.sendBeacon("/analytics", JSON.stringify(metric));
}
onLCP(send); onINP(send); onCLS(send);
Sources of field data: the web-vitals library to your own analytics, the Chrome UX Report (CrUX) / PageSpeed Insights, or a RUM vendor. The attribution build of web-vitals also tells you which element or interaction was slow.
Interviewers look for: using sendBeacon (survives page unload) and reporting the 75th percentile, not the average.
33. Accessibility
BasicWhat is the "first rule of ARIA"?
If a native HTML element or attribute already gives you the semantics and behaviour you need, use it instead of adding ARIA.
ARIA only changes what assistive technology announces — it adds no keyboard handling, focus or behaviour. <div role="button"> is announced as a button but still is not focusable, does not respond to Enter/Space, and does not submit forms. A real <button> does all of that for free.
<!-- Bad: needs tabindex, key handlers, role... -->
<div role="button" onclick="save()">Save</div>
<!-- Good -->
<button type="button" onclick="save()">Save</button>
Interviewers look for: "no ARIA is better than bad ARIA" — surveys (WebAIM Million) consistently find pages with ARIA have more accessibility errors on average.
BasicHow do you write good alt text? When should alt be empty?
Alt text should convey the image's purpose in context, as briefly as possible; decorative images get alt="" so screen readers skip them.
- Informative image → describe what matters:
alt="Bar chart: sales doubled from Q1 to Q2". - Image inside a link or button → describe the destination/action:
alt="Home", not "logo". - Decorative or redundant with nearby text →
alt="". - Don't start with "image of" — the screen reader already says "image".
Leaving alt off entirely is the worst option: many screen readers then read the file name ("IMG underscore 4032 dot jpg").
BasicWhat does keyboard accessibility require?
Every interactive element must be reachable with Tab, operable with Enter/Space (or arrow keys for composite widgets), have a visible focus indicator, and follow a logical order.
- Use native interactive elements so focusability comes for free.
- Keep DOM order matching visual order; avoid positive
tabindexvalues. Usetabindex="0"to make a custom widget focusable andtabindex="-1"for things you focus programmatically. - Never do
outline: nonewithout a replacement — style:focus-visibleinstead. - Provide a "Skip to main content" link as the first focusable element.
- No keyboard traps (except intentional, escapable ones like a modal).
Interviewers look for: the "unplug your mouse" test — a practical habit, not just a list of rules.
IntermediateWalk me through building an accessible modal dialog.
Focus moves in on open, stays trapped while open, Escape closes it, focus returns to the trigger on close, and the dialog is labelled — the native <dialog> with showModal() handles most of this.
- Move focus to the first meaningful element (or the dialog heading) when it opens.
- Trap Tab/Shift+Tab inside; make the background inert (
inertattribute, orshowModal()does it). - Close on Escape and backdrop click.
- Restore focus to the element that opened it.
role="dialog",aria-modal="true",aria-labelledbypointing to the title.
<dialog id="d" aria-labelledby="d-title">
<h2 id="d-title">Delete file?</h2>
<form method="dialog"><button>Cancel</button><button value="ok">Delete</button></form>
</dialog>
<script>openBtn.onclick = () => d.showModal();</script>
showModal() gives you the top layer, inert background, Escape to close and focus return in modern browsers. Backdrop click still needs a small handler.
Interviewers look for: focus return — it is the step most people forget, and without it keyboard users land at the top of the page.
IntermediateWhat are ARIA live regions and when would you use polite vs assertive?
A live region is an element whose content changes get announced by screen readers without moving focus — used for dynamic updates like "3 results found", "Saved", or form errors.
aria-live="polite"(orrole="status") — announced when the user is idle. Use for almost everything.aria-live="assertive"(orrole="alert") — interrupts immediately. Only for urgent things like "Session expiring" or a failed payment.
The region must exist in the DOM before the content changes; injecting a new element that already contains the text is often not announced. Render the empty container on load and update its text later.
Interviewers look for: that trap — and that overusing assertive makes the app exhausting to use.
IntermediateWhat are the WCAG colour contrast requirements, and what else about colour matters?
WCAG AA requires 4.5:1 for normal text, 3:1 for large text (24px, or 18.66px bold) and 3:1 for UI components and meaningful graphics (input borders, focus rings, icons). AAA raises text to 7:1.
Beyond contrast: never use colour alone to convey meaning — an error field needs an icon or message, not just a red border; a chart needs labels or patterns. Also check both light and dark themes, and placeholder text (often fails contrast).
Tools: DevTools colour picker shows the ratio, axe / Lighthouse flag failures.
AdvancedHow do you test accessibility, and what can automated tools not catch?
A layered approach: automated checks in CI, keyboard-only testing, and manual screen reader testing on key flows.
- Automated — axe-core (via
jest-axe, Playwright's@axe-core/playwright, or browser extension), Lighthouse, eslint-plugin-jsx-a11y. Catches roughly 30–40% of issues: missing labels, contrast, invalid ARIA, missing alt. - Keyboard — tab through every flow; check order, visibility and traps.
- Screen readers — VoiceOver + Safari on macOS/iOS, NVDA + Firefox/Chrome on Windows, TalkBack on Android.
- Zoom and reflow — 200% zoom and 320px width without loss of content.
Automated tools cannot judge whether alt text is meaningful, whether focus order makes sense, whether a custom widget's keyboard pattern is correct, whether announcements are timely, or whether the experience is actually usable.
Interviewers look for: testing with Testing Library's role-based queries doubles as an a11y check — if getByRole("button", { name: "Save" }) fails, a screen reader user can't find it either.
34. Frontend Security
BasicWhat is XSS and what are its types?
Cross-Site Scripting is when attacker-controlled input gets executed as script in your page, running with the victim's session and full access to the page.
- Stored — the payload is saved on the server (a comment, a profile name) and served to every viewer.
- Reflected — the payload is in the request (URL query) and echoed back in the response.
- DOM-based — entirely client-side: JS reads from
location.hash,postMessageetc. and writes to a sink likeinnerHTML,evalorhref.
Prevention: encode output for its context (textContent instead of innerHTML), sanitise when you must render HTML (DOMPurify), validate URLs (block javascript:), and add a CSP as defence in depth.
Interviewers look for: knowing that input validation alone is not the fix — output encoding in the right context is.
BasicReact escapes output by default. How can XSS still happen in a React app?
Through the escape hatches that bypass React's escaping, and through URLs.
dangerouslySetInnerHTMLwith unsanitised data — e.g. rendering CMS or Markdown HTML.<a href={userUrl}>where the URL isjavascript:alert(1). React 19 blocksjavascript:URLs, earlier versions only warn.- Direct DOM access via refs:
ref.current.innerHTML = .... - SSR: serialising state into a
<script>tag with plainJSON.stringify— a value containing</script>breaks out. Escape<as<. - Third-party libraries that write HTML, or
eval-like APIs.
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
IntermediateWhat is Content Security Policy and how does it mitigate XSS?
CSP is an HTTP response header that tells the browser which sources of script, style, images and connections are allowed; anything else is blocked, so an injected script usually cannot run.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-r4nd0m' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
- A nonce-based strict CSP (a fresh random nonce per response on each allowed
<script>) is more robust than long domain allowlists, which are often bypassable via JSONP or CDN-hosted libraries. - Avoid
'unsafe-inline'and'unsafe-eval'— they undo most of the protection. - Roll out with
Content-Security-Policy-Report-Onlyfirst to find violations without breaking the site.
Interviewers look for: CSP is defence in depth, not a substitute for escaping; and frame-ancestors in the same header handles clickjacking.
IntermediateWhat is CSRF, and how is it different from XSS?
CSRF tricks a logged-in user's browser into sending a state-changing request to your site from another site, riding on cookies the browser attaches automatically. XSS runs attacker code inside your origin; CSRF only sends requests from outside and cannot read the response.
<!-- on evil.com -->
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker"><input name="amount" value="1000">
</form>
<script>document.forms[0].submit()</script>
Defences: SameSite=Lax or Strict cookies (modern browsers default to Lax), anti-CSRF tokens (synchroniser or double-submit), checking the Origin header, and never changing state on GET.
Interviewers look for: "if you have XSS, CSRF protection is irrelevant" — script on your origin can read the CSRF token and send any request.
IntermediateWhat is clickjacking and how do you prevent it?
An attacker loads your site in an invisible iframe on top of a decoy page, so the user thinks they're clicking the decoy but actually clicks a button on your site (e.g. "Delete account", "Allow camera").
Prevent it by telling the browser not to allow framing:
Content-Security-Policy: frame-ancestors 'none'; # or 'self' / specific origins
X-Frame-Options: DENY # legacy fallback
JavaScript "frame-busting" code is unreliable and can be defeated with iframe sandbox; use the headers.
AdvancedWhere should you store auth tokens in the browser — localStorage or cookies?
For most web apps, an HttpOnly, Secure, SameSite cookie is the safer default, because JavaScript — including injected XSS — cannot read it.
| localStorage | HttpOnly cookie | |
|---|---|---|
| Readable by XSS | Yes — token can be exfiltrated and used anywhere | No — but XSS can still make requests as the user while the page is open |
| CSRF risk | None (you attach the header manually) | Yes — mitigate with SameSite + CSRF token |
| Cross-domain APIs | Easy | Needs same-site setup or a BFF |
A common strong pattern: a short-lived access token held in memory (a JS variable), plus a refresh token in an HttpOnly cookie scoped to the refresh endpoint. Or a Backend-for-Frontend that keeps tokens server-side entirely and gives the browser only a session cookie.
Interviewers look for: not claiming cookies make XSS harmless — they limit token theft, not in-session abuse. Fixing XSS is still the real defence.
AdvancedHow do you reduce the risk from third-party scripts and CDNs? What is SRI?
Every third-party script runs with full access to your page, so a compromised vendor or CDN is a compromise of your site (as in the 2024 polyfill.io incident). Minimise, pin, isolate and verify.
- Subresource Integrity — the browser hashes the downloaded file and refuses to run it if it doesn't match:
Only works for versioned, immutable files — not for scripts vendors change in place (analytics tags).<script src="https://cdn.example.com/lib@4.2.1/lib.min.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" crossorigin="anonymous"></script> - Self-host what you can, pinned to exact versions.
- Sandbox untrusted widgets in a cross-origin
<iframe sandbox>and talk viapostMessage(validateevent.origin). - CSP to limit which origins can load scripts and where data can be sent (
connect-src). - Dependencies — lockfiles,
npm audit, Dependabot/Renovate, review new packages and their install scripts.
35. Testing
BasicWhat is the testing pyramid (or trophy), and how do you balance test types?
The pyramid says: many fast unit tests, fewer integration tests, very few slow E2E tests. The "testing trophy" (Kent C. Dodds) shifts the weight to integration tests for frontend, because they give the most confidence per unit of effort.
- Static — TypeScript and ESLint catch a whole class of bugs for free.
- Unit — pure functions, utilities, custom hooks, reducers.
- Integration/component — render a feature, interact like a user, mock only the network.
- E2E — a handful of critical paths (login, checkout) in a real browser.
Interviewers look for: reasoning in terms of confidence vs cost (speed, flakiness, maintenance) rather than reciting a shape.
BasicWhat is the difference between unit, integration and end-to-end tests?
| Unit | Integration | E2E | |
|---|---|---|---|
| Scope | One function/hook | Several components + state together | Whole app, real browser, real or staged backend |
| Speed | Milliseconds | Fast | Seconds per test |
| Mocks | Dependencies | Network only (MSW) | Little or none |
| Tools | Vitest / Jest | RTL + MSW | Playwright / Cypress |
Unit tests pinpoint failures but can pass while the app is broken; E2E tests prove the app works but are slower and flakier and tell you less about where it broke.
IntermediateWhat is React Testing Library's philosophy, and which queries should you prefer?
"The more your tests resemble the way your software is used, the more confidence they can give you." Test what the user sees and does, not implementation details like state, props or class names.
- Query priority:
getByRole(withname) →getByLabelText→getByPlaceholderText→getByText→getByTestIdas a last resort. - Use
userEventoverfireEvent— it simulates the full sequence (focus, keydown, input, keyup). getBythrows if missing,queryByreturns null (for asserting absence),findByis async and waits.
await user.click(screen.getByRole("button", { name: /add to cart/i }));
expect(await screen.findByText(/1 item in cart/i)).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
Interviewers look for: a test should survive a refactor (class component → hooks, CSS rename) and fail only when behaviour breaks.
IntermediateHow do you test a component that fetches data? Why MSW instead of mocking fetch?
Mock at the network boundary with Mock Service Worker so the component's real fetching code (fetch, axios, React Query) runs unchanged, and assert on what the user sees.
const server = setupServer(
http.get("/api/user", () => HttpResponse.json({ name: "Ada" }))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("shows the user", async () => {
render(<Profile />);
expect(await screen.findByText("Ada")).toBeInTheDocument();
});
test("shows an error", async () => {
server.use(http.get("/api/user", () => new HttpResponse(null, { status: 500 })));
render(<Profile />);
expect(await screen.findByRole("alert")).toHaveTextContent(/something went wrong/i);
});
Mocking global.fetch or your API module couples tests to implementation — switch from fetch to axios and every test breaks. MSW handlers are also reusable in Storybook and local development.
Interviewers look for: testing loading and error states, not just the happy path, and using findBy rather than arbitrary waits.
IntermediateWhat are snapshot tests good and bad for?
Snapshots serialise output and fail when it changes. They are cheap to write but weak at catching bugs, because a large diff is usually just accepted with -u.
- Good for — small, stable outputs: a serialiser, a generated config, an error message, a tiny presentational component. Inline snapshots (
toMatchInlineSnapshot) keep them reviewable. - Bad for — large component trees. They break on every harmless markup change, reviewers stop reading them, and they don't say what behaviour was expected.
For visual correctness, visual regression tools (Playwright screenshots, Chromatic) compare real rendered pixels and are far more meaningful than DOM snapshots.
AdvancedThis test is flaky — sometimes it passes, sometimes it doesn't. How do you debug it?
test("shows results", async () => {
render(<Search />);
await user.type(screen.getByRole("searchbox"), "react");
expect(screen.getByText("React docs")).toBeInTheDocument();
});
The assertion is synchronous but the results arrive asynchronously (debounce + fetch). It passes only when timing happens to be fast. Use findByText (which waits and retries) instead of getByText.
Common causes of flaky tests in general:
- Asserting before async work finishes; using fixed
setTimeoutwaits instead offindBy/waitFor. - Shared state leaking between tests — module-level caches, a QueryClient reused across tests, un-reset MSW handlers, localStorage.
- Real timers and dates — use fake timers (
vi.useFakeTimers()) and a fixed system time. - Test order dependence — run with random order or in isolation to confirm.
- In E2E: relying on animations or network timing; use Playwright's auto-waiting locators and web-first assertions.
Interviewers look for: fixing the root cause rather than adding retries — retries hide real race conditions that users will also hit.
AdvancedHow do you test a custom hook, and when should you not test it directly?
Use renderHook from React Testing Library, wrapping state updates in act.
import { renderHook, act } from "@testing-library/react";
test("useCounter increments", () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);
});
Pass a wrapper option to provide context (router, QueryClientProvider, theme). Test a hook directly when it is reusable and shared across components (a library-like useDebounce, useLocalStorage). If a hook exists only to serve one component, test it through that component instead — that tests behaviour and lets you refactor the hook freely.
Predict the Output
BasicWhat does this log? (closures in a loop — var vs let)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0);
}
Output: 3 3 3 then 0 1 2
var is function-scoped, so there is only one i; all three callbacks close over it and run after the loop has finished, when i is 3. let creates a fresh binding for every iteration, so each callback captures its own j.
Interviewers look for: the pre-ES6 fix — an IIFE ((k) => setTimeout(() => console.log(k)))(i) or passing i as the third argument to setTimeout.
BasicWhat does this log? (hoisting and the TDZ)
console.log(a);
var a = 1;
console.log(b);
let b = 2;
Output: undefined, then ReferenceError: Cannot access 'b' before initialization
Both declarations are hoisted. var a is initialised to undefined at hoisting time, so reading it early is fine. let b is hoisted but left uninitialised in the Temporal Dead Zone until its declaration line runs, so reading it throws.
IntermediateWhat does this log? (function vs var hoisting)
console.log(typeof foo);
var foo = 1;
function foo() {}
console.log(typeof foo);
Output: "function" then "number"
Function declarations are hoisted with their body, and they take precedence over a var of the same name during hoisting (the var declaration is ignored because the name already exists). So at the first log, foo is the function. Then the assignment foo = 1 runs, and the function declaration line itself does nothing at runtime.
IntermediateWhat does this log? (this in methods vs arrow functions, in an ES module)
const obj = {
name: "A",
regular() { return this?.name; },
arrow: () => this?.name,
delayed() { setTimeout(() => console.log(this.name), 0); },
};
const detached = obj.regular;
console.log(obj.regular());
console.log(obj.arrow());
console.log(detached());
obj.delayed();
Output: "A", undefined, undefined, then "A"
obj.regular()— called as a method, sothisisobj.obj.arrow()— arrows have no ownthis; they take it from where they were defined, the module top level, wherethisisundefined. An object literal does not create a scope.detached()— a plain call; in strict mode (modules are strict)thisisundefined.delayed— the arrow inside inheritsthisfromdelayed, which was called as a method.
Interviewers look for: in a classic non-module script, top-level this is window, and window.name exists (usually "") — so you'd see empty strings, not undefined.
IntermediateWhat order are these logged in? (event loop — microtasks vs macrotasks)
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
queueMicrotask(() => console.log("4"));
(async () => {
console.log("5");
await null;
console.log("6");
})();
console.log("7");
Output: 1 5 7 3 4 6 2
- Synchronous code first:
1, then the async function body runs synchronously up to its firstawait→5, then7. - When the call stack empties, the whole microtask queue drains in the order tasks were queued: the
.then(3),queueMicrotask(4), the continuation afterawait(6). - Only then does the event loop take the next macrotask: the timer →
2.
Interviewers look for: knowing that an async function runs synchronously until the first await — the most common mistake in this question.
IntermediateWhat does this log? (loose equality coercion)
console.log([] == false);
console.log(null == 0);
console.log(null >= 0);
console.log(null == undefined);
console.log(NaN === NaN);
console.log("5" + 3, "5" - 3);
Output: true, false, true, true, false, "53" 2
[] == false— boolean →0;[]→""→0;0 == 0.null == 0—nullis only loosely equal toundefined(and itself); no numeric conversion happens for==.null >= 0— relational operators do convertnullto0, so0 >= 0.NaNis not equal to anything, including itself — useNumber.isNaN.+with a string concatenates;-always converts to numbers.
BasicWhat does this log? (object references and pass-by-sharing)
const a = { x: 1 };
const b = a;
b.x = 2;
function change(o) {
o.x = 3;
o = { x: 4 };
}
change(a);
console.log(a.x, b.x);
console.log({ x: 1 } === { x: 1 });
Output: 3 3 then false
a and b point to the same object. Inside change, o is a copy of the reference: mutating through it (o.x = 3) affects the shared object, but reassigning o only rebinds the local variable. Two separately created object literals are different objects, so strict equality is false.
Interviewers look for: the phrase "JavaScript is pass-by-value, where the value of an object is a reference" — not "pass-by-reference".
BasicWhat does this log? (typeof quirks)
console.log(typeof null);
console.log(typeof []);
console.log(typeof function () {});
console.log(typeof NaN);
console.log(typeof undeclaredVariable);
console.log(typeof class {});
Output: "object", "object", "function", "number", "undefined", "function"
typeof null === "object"is a bug from the first version of JS that can never be fixed. Checkvalue === nullexplicitly.- Arrays are objects — use
Array.isArray. NaNis a numeric value ("not a number" is a value of type number).typeofon an undeclared identifier does not throw — the one safe way to probe for a global (except inside the TDZ of alet, where it does throw).- Classes are functions under the hood.
IntermediateWhat does this log? (the + operator with arrays, objects and strings)
console.log([] + []);
console.log([] + {});
console.log([1, 2] + [3]);
console.log(1 + "2" + 3);
console.log(1 + 2 + "3");
console.log(true + true);
Output: "", "[object Object]", "1,23", "123", "33", 2
+converts objects to primitives first. Arrays become strings viajoin(","):[]→"",[1,2]→"1,2". Plain objects become"[object Object]".- If either side is then a string, it concatenates; otherwise it adds numbers.
- Evaluation is left to right:
1 + "2"is"12", then+ 3→"123"; but1 + 2is3first, then"33". trueconverts to1.
Interviewers look for: typing {} + [] directly in a console gives 0 — the leading {} is parsed as an empty block and +[] is unary plus.
AdvancedWhat does this log? (map(parseInt) and default sort)
console.log(["1", "2", "3"].map(parseInt));
console.log([10, 1, 2, 25].sort());
Output: [1, NaN, NaN] then [1, 10, 2, 25]
mappasses(value, index, array), andparseInttakes(string, radix). So the calls areparseInt("1", 0)→ 1 (radix 0 means "default"),parseInt("2", 1)→ NaN (radix 1 is invalid),parseInt("3", 2)→ NaN ("3" is not a binary digit). Fix:.map(Number)or.map(s => parseInt(s, 10)).sort()without a comparator converts elements to strings and sorts by UTF-16 code units, so"10"comes before"2". Fix:.sort((a, b) => a - b). Also notesortmutates;toSorted(ES2023) returns a copy.
Machine Coding & Polyfills
IntermediateImplement debounce.
function debounce(fn, wait) {
let timer = null;
function debounced(...args) {
// Every call resets the countdown; fn runs only after `wait` ms of silence
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args); // keep the caller's `this` and latest arguments
}, wait);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
};
return debounced;
}
// Usage
const onSearch = debounce((q) => fetchResults(q), 300);
input.addEventListener("input", (e) => onSearch(e.target.value));
this— the returned function must be a regular function (not an arrow) sothisis forwarded; the inner arrow then captures it.- Leading edge — a follow-up is often "fire immediately on the first call, then ignore until quiet": add an
immediateflag that callsfnwhentimeris null. - cancel / flush — needed for cleanup, e.g. in a React effect's cleanup on unmount.
- React trap — creating the debounced function inside render makes a new one every render; wrap it in
useMemo/useRef.
IntermediateImplement throttle (with a trailing call).
function throttle(fn, wait) {
let lastRun = 0;
let timer = null;
let pendingArgs = null;
let pendingThis = null;
return function throttled(...args) {
const now = Date.now();
const remaining = wait - (now - lastRun);
if (remaining <= 0) {
// Enough time has passed: run now (leading edge)
clearTimeout(timer);
timer = null;
lastRun = now;
fn.apply(this, args);
} else {
// Too soon: remember the latest call and schedule one trailing run
pendingArgs = args;
pendingThis = this;
if (!timer) {
timer = setTimeout(() => {
lastRun = Date.now();
timer = null;
fn.apply(pendingThis, pendingArgs);
}, remaining);
}
}
};
}
- Trailing call — without it, the final event (e.g. the last scroll position) is lost, which causes stale UI.
- Simplest version — a boolean "cooling down" flag reset by
setTimeoutis acceptable if the interviewer only wants leading-edge behaviour; say which you're building. - Latest arguments — the trailing call must use the most recent arguments, not the ones that started the timer.
AdvancedImplement Promise.all.
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const items = Array.from(iterable); // accept any iterable
const results = new Array(items.length);
let remaining = items.length;
if (remaining === 0) {
resolve(results); // Promise.all([]) resolves to []
return;
}
items.forEach((item, index) => {
// Promise.resolve handles plain values and thenables
Promise.resolve(item).then(
(value) => {
results[index] = value; // keep input order, not finish order
remaining -= 1;
if (remaining === 0) resolve(results);
},
reject // first rejection wins; later ones are ignored
);
});
});
}
- Order — results go by index, not by completion time; pushing into an array is the classic bug.
- Counter, not length — checking
results.length === items.lengthfails because assigning a late index makes the array that long immediately. - Non-promise values and the empty array must both work.
- Follow-ups —
allSettled(never reject; store{status, value/reason}),race,any(reject with anAggregateErroronly when all reject).
AdvancedImplement Function.prototype.bind.
Function.prototype.myBind = function (thisArg, ...boundArgs) {
const target = this;
if (typeof target !== "function") {
throw new TypeError("myBind must be called on a function");
}
return function bound(...callArgs) {
const args = [...boundArgs, ...callArgs]; // partial application: bound args first
// Called with `new`: bind's thisArg is ignored and a new instance is created
if (new.target) {
return new target(...args);
}
return target.apply(thisArg, args);
};
};
// Usage
const user = { name: "Ada" };
function greet(greeting, punct) { return `${greeting}, ${this.name}${punct}`; }
const hello = greet.myBind(user, "Hello");
hello("!"); // "Hello, Ada!"
- Partial application — arguments passed to bind are prepended to call-time arguments.
newon a bound function — must ignore the boundthis; many candidates miss this.new.targetdetects it.- Rebinding — binding an already bound function again cannot change its
this; the first bind wins. - Without spread/apply — some interviewers ask for an ES5 version using
argumentsandArray.prototype.slice.call.
AdvancedImplement deep clone.
function deepClone(value, seen = new WeakMap()) {
// Primitives and functions are returned as-is
if (value === null || typeof value !== "object") return value;
// Circular reference: return the copy we already made
if (seen.has(value)) return seen.get(value);
if (value instanceof Date) return new Date(value);
if (value instanceof RegExp) return new RegExp(value.source, value.flags);
if (value instanceof Map) {
const copy = new Map();
seen.set(value, copy);
value.forEach((v, k) => copy.set(deepClone(k, seen), deepClone(v, seen)));
return copy;
}
if (value instanceof Set) {
const copy = new Set();
seen.set(value, copy);
value.forEach((v) => copy.add(deepClone(v, seen)));
return copy;
}
// Arrays and plain/class objects: keep the prototype
const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
seen.set(value, copy);
for (const key of Reflect.ownKeys(value)) { // includes symbol keys
copy[key] = deepClone(value[key], seen);
}
return copy;
}
- Circular references — without the
WeakMapyou get infinite recursion. JSON.parse(JSON.stringify(x))— losesundefined, functions, symbols,Date(becomes a string),Map/Set,NaN/Infinity(becomenull), and throws on cycles.- In real code — use the built-in
structuredClone, which handles cycles, Dates, Maps, Sets and typed arrays (but not functions or DOM nodes, and it drops class prototypes). - Very deep structures — recursion can overflow the stack; an iterative version with an explicit stack avoids that.
IntermediateImplement memoize.
function memoize(fn, resolver = (...args) => JSON.stringify(args)) {
const cache = new Map();
return function memoized(...args) {
const key = resolver(...args); // how to turn args into a cache key
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// Usage
const slowSquare = (n) => { /* expensive */ return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(9); // computed
fastSquare(9); // from cache
- Cache key —
JSON.stringifyfails for functions, cycles and treats different objects with the same content as equal. For a single object argument, aWeakMapkeyed by the object avoids leaks. - Use
cache.has, not a truthiness check — otherwise cached0,""orundefinedresults are recomputed. - Unbounded growth — mention an LRU limit for long-running apps.
- Async functions — cache the promise so concurrent calls share one request; delete the entry on rejection so errors aren't cached forever.
IntermediateImplement Array.prototype.map and Array.prototype.reduce.
Array.prototype.myMap = function (callback, thisArg) {
if (typeof callback !== "function") {
throw new TypeError(callback + " is not a function");
}
const arr = Object(this);
const len = arr.length >>> 0; // length as a non-negative integer
const result = new Array(len);
for (let i = 0; i < len; i++) {
if (i in arr) { // skip holes in sparse arrays
result[i] = callback.call(thisArg, arr[i], i, arr);
}
}
return result;
};
Array.prototype.myReduce = function (callback, initialValue) {
if (typeof callback !== "function") {
throw new TypeError(callback + " is not a function");
}
const arr = Object(this);
const len = arr.length >>> 0;
let i = 0;
let acc;
if (arguments.length >= 2) {
acc = initialValue; // initialValue may legitimately be undefined
} else {
while (i < len && !(i in arr)) i++; // find the first real element
if (i >= len) throw new TypeError("Reduce of empty array with no initial value");
acc = arr[i++];
}
for (; i < len; i++) {
if (i in arr) acc = callback(acc, arr[i], i, arr);
}
return acc;
};
- Initial value detection — use
arguments.length, notinitialValue === undefined, becauseundefinedis a valid initial value. - Empty array without initial value must throw a
TypeError. - Holes — native
map/reduceskip missing indices;mappreserves them in the output. - Arrow functions — the polyfill itself must be a regular function so
thisis the array.
IntermediateFlatten a nested array (with an optional depth).
// Recursive, mirrors Array.prototype.flat(depth)
function flatten(arr, depth = Infinity) {
const result = [];
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flatten(item, depth - 1));
} else {
result.push(item);
}
}
return result;
}
// Iterative (no recursion, no stack overflow), full depth
function flattenDeep(arr) {
const stack = [...arr];
const result = [];
while (stack.length) {
const item = stack.pop();
if (Array.isArray(item)) stack.push(...item);
else result.push(item);
}
return result.reverse(); // popped from the end, so reverse
}
flatten([1, [2, [3, [4]]]], 1); // [1, 2, [3, [4]]]
flattenDeep([1, [2, [3, [4]]]]); // [1, 2, 3, 4]
- Depth parameter — the built-in
arr.flat(depth)defaults to 1,flat(Infinity)flattens fully. - Very deep nesting — recursion can overflow the stack; the iterative version avoids that.
- Huge arrays —
push(...big)can hit the argument-count limit; a loop of single pushes is safer. - Sparse arrays — native
flatremoves holes;for...ofyieldsundefinedfor them — mention if asked for exact parity.
AdvancedImplement curry.
function curry(fn) {
return function curried(...args) {
// Enough arguments collected: call the original
if (args.length >= fn.length) {
return fn.apply(this, args);
}
// Otherwise return a function that collects more
return (...next) => curried.apply(this, [...args, ...next]);
};
}
const add3 = (a, b, c) => a + b + c;
const add = curry(add3);
add(1)(2)(3); // 6
add(1, 2)(3); // 6
add(1)(2, 3); // 6
fn.length— does not count default parameters or rest parameters, so(a, b = 2) => ...has length 1. Accept an explicit arity argument for those cases.- Infinite currying — a common variant,
sum(1)(2)(3)(), stops when called with no arguments: keep accumulating untilargs.length === 0. - Immutability — build a new array each step (
[...args, ...next]) so partially applied functions can be reused independently.
AdvancedImplement an EventEmitter with on, off, emit (and once).
class EventEmitter {
#events = new Map(); // event name → array of listeners
on(event, listener) {
if (!this.#events.has(event)) this.#events.set(event, []);
this.#events.get(event).push(listener);
return () => this.off(event, listener); // handy unsubscribe function
}
off(event, listener) {
const listeners = this.#events.get(event);
if (!listeners) return;
const i = listeners.findIndex((l) => l === listener || l.original === listener);
if (i !== -1) listeners.splice(i, 1); // remove one registration
if (listeners.length === 0) this.#events.delete(event);
}
once(event, listener) {
const wrapper = (...args) => {
this.off(event, wrapper);
listener.apply(this, args);
};
wrapper.original = listener; // so off(event, listener) still works
return this.on(event, wrapper);
}
emit(event, ...args) {
const listeners = this.#events.get(event);
if (!listeners) return false;
// Copy first: a listener may call off()/once() and mutate the array mid-loop
[...listeners].forEach((listener) => listener.apply(this, args));
return true;
}
}
const bus = new EventEmitter();
const unsubscribe = bus.on("login", (user) => console.log("hi", user));
bus.emit("login", "Ada"); // hi Ada
unsubscribe();
- Mutation during emit — iterating the live array while a
oncelistener removes itself skips the next listener; copying before iterating fixes it. offwith aoncelistener — the stored function is the wrapper, so keep a reference to the original.- Error isolation — should one throwing listener stop the rest? Node's emitter lets it throw; a UI bus may wrap each call in try/catch.
- Memory leaks — returning an unsubscribe function makes cleanup easy (e.g. in a React effect).