Skip to content
QSWEQB

Frontend fundamentals

What the browser does between HTML and pixels, the JavaScript semantics behind the classic bugs, why a React re-render happens, what actually moves each Core Web Vital, and the accessibility facts that are requirements rather than polish. Fifty-seven items, twelve worked through with code or a diagram.

57 questions

Go deeper on Frontend Engineering

The browser rendering pipeline

Show me what the browser does between HTML and pixels.

Every performance question in the frontend is really a question about which of these stages you triggered and how often.

flowchart LR
    H[HTML] --> D[DOM tree]
    C[CSS] --> S[CSSOM]
    D --> R[Render tree]
    S --> R
    R --> L[Layout<br/>geometry of every box]
    L --> P[Paint<br/>pixels for each layer]
    P --> Comp[Composite<br/>layers to the screen]

The critical path is that the render tree needs both the DOM and the CSSOM, so CSS is render-blocking by definition — the browser will not paint an unstyled page and then restyle it. That is why stylesheets belong in the head and why a large stylesheet delays first paint even when most of it is unused.

Scripts are worse, because a classic <script> blocks parsing entirely: the parser stops, fetches, executes, then resumes, and the executing script may itself read layout, which forces the CSSOM to be complete first. defer keeps the download parallel and runs the script after parsing in document order; async runs it the moment it arrives, in no guaranteed order, which suits an independent analytics tag and breaks anything with a dependency.

The three stages at the end are a hierarchy of cost, and the whole of frontend animation performance follows from it. Changing geometry runs layout, paint and composite. Changing a colour skips layout. Changing transform or opacity skips both layout and paint, so it happens on the compositor and can hit sixty frames per second on a page too heavy to lay out even once.

What is the difference between reflow and repaint?

A reflow — layout — recalculates the geometry of boxes, and because boxes affect their siblings and children it can cascade across a large part of the tree. A repaint fills in pixels for boxes whose appearance changed but whose geometry did not, which is cheaper. Changing width or inserting an element reflows; changing background-color repaints only. The practical rule is that reflow cost scales with how much of the tree is affected, so a change near the root is far more expensive than the same change on a leaf.

Show me layout thrashing and the fix.

The loop below is a well-known way to make a page unusable, and it looks completely reasonable.

// Bad: forces layout on every iteration
for (const el of items) {
  el.style.height = el.offsetHeight + 10 + 'px';
}

Style writes are batched by the browser and flushed later. Reading offsetHeight demands an answer now, so the browser must flush every pending write and run layout before it can respond. With one hundred items that is one hundred synchronous layouts in a single frame — the pattern known as forced synchronous layout, or layout thrashing.

The fix is to separate the phases so all reads happen before any write:

// Read everything first
const heights = items.map((el) => el.offsetHeight);
// Then write, with no interleaved reads
items.forEach((el, i) => { el.style.height = heights[i] + 10 + 'px'; });

Now there is one layout instead of a hundred, because nothing forced a flush in the middle.

The properties that trigger it are worth recognising on sight: offsetTop, offsetHeight, scrollTop, clientWidth, getBoundingClientRect and getComputedStyle. Any of them appearing inside a loop that also writes styles is the bug. And the reason it is hard to spot in a profiler is that the cost attributes to the innocuous-looking read, not to the writes that created the pending work.

What is a stacking context and how does it trap a z-index?

A stacking context is a self-contained layer group: elements inside it are ordered among themselves, and the whole group is placed as one unit within its parent. So a child with z-index: 9999 cannot escape a parent whose own context sits below a sibling — the huge number is compared only against its siblings. Contexts are created by more than position plus z-index: opacity below one, transform, filter, will-change and isolation all create one, which is why adding a fade animation can suddenly break an overlay.

What is critical CSS, and is it worth extracting?

The subset of rules needed to render what is visible before scrolling, inlined into the HTML so first paint does not wait on a stylesheet request. It is worth it when the stylesheet is large and on the critical path, which is most of the time, and the win is a full round trip removed from LCP. The costs are real though: the inlined CSS is duplicated in every HTML response and cannot be cached separately, and the extraction must be automated in the build or it drifts from the actual styles within a week.

What makes a task "long", and why does it matter?

Any block of main-thread work over fifty milliseconds, because the thread is single and nothing else — no click handling, no rendering, no scrolling — happens while it runs. A user who taps during a 300ms task waits at least 300ms for any visual response, which is what INP measures. The causes are large synchronous re-renders, parsing and executing a big bundle, expensive JSON handling, and hydration. The fix is to break the work up so the loop can yield between chunks, or move it off the thread into a worker.

What is the difference between the DOM and the virtual DOM?

The DOM is the browser's actual tree of nodes, where mutations are expensive because they can trigger layout and paint. A virtual DOM is a plain-object description of the tree that a library builds cheaply, diffs against the previous description, and uses to compute a minimal set of real mutations. It is not inherently faster than well-targeted direct manipulation — it is faster than naive re-rendering, and its real value is that it lets you write code as though you redraw everything.

JavaScript the language

Show me the closure bug with `var` and both fixes.

The classic interview question, and the answer explains scoping rather than timing.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// prints 3, 3, 3

var is function-scoped, so there is exactly one i for the whole loop. All three callbacks close over that same variable, and by the time any of them runs — after the synchronous loop has finished — its value is 3.

// Fix 1: let is block-scoped, so each iteration gets its own binding
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);   // 0, 1, 2
}

// Fix 2, the pre-ES6 answer: a new function scope per iteration
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 0); // 0, 1, 2
  })(i);
}

let in a for loop is special-cased by the specification: the binding is created fresh per iteration and the previous value copied in, which is precisely what the wrapper function did manually.

The reason this question survives is that the same shape appears in real code every time a callback captures a loop variable — event handlers attached in a loop, promises created in a loop, setInterval in a component. It is worth being able to say the underlying rule rather than the fix: a closure captures the variable, not the value at the moment it was created.

What is the temporal dead zone?

The region between the start of a block and the point where a let or const declaration is evaluated. The binding exists — it is hoisted to the top of the block — but accessing it throws a ReferenceError rather than yielding undefined. That is a deliberate improvement over var, which hoists and initialises to undefined, so a typo silently produces undefined instead of an error. Function declarations are hoisted and fully initialised, which is why you can call one before its definition.

Show me how `this` is determined.

Four rules, checked in order, and almost every this bug is one of them applied where the author expected another.

const obj = {
  name: 'obj',
  regular() { return this.name; },
  arrow: () => this?.name,
};

obj.regular();              // 'obj'   — called as a method: this is the receiver
const f = obj.regular;
f();                        // TypeError in strict mode - this is undefined
f.call({ name: 'other' });  // 'other' — explicit binding
new (function () { this.name = 'new'; })();  // this is the new object
obj.arrow();                // NOT 'obj' — arrows have no own this

The rule for a regular function is that this is decided at call time by how the function was called, not by where it was written. Extracting a method into a variable, or passing it as a callback, discards the receiver — which is why element.addEventListener('click', obj.handle) loses obj.

An arrow function has no this of its own at all. It closes over the this of the enclosing lexical scope, permanently, and no amount of call or bind can change it. That makes arrows correct for callbacks inside a method and wrong for object methods and for anything the caller intends to rebind.

The practical consequence in class components and event handlers is the same either way: either bind in the constructor, or define the handler as a class field with an arrow, so it captures the instance at definition time.

What is the prototype chain?

Every object has a link to another object, its prototype, and a property lookup that misses walks that chain until it finds the property or reaches null. That is how method sharing works — instances hold data and delegate behaviour to a shared prototype object, so ten thousand instances share one copy of each method. class syntax is a cleaner spelling of this and changes nothing underneath, which is why a class method is enumerable-free and lives on the prototype rather than on each instance.

Why do `==` and `===` differ, and which should you use?

=== compares type and value with no conversion. == applies a coercion algorithm first, producing results that are consistent but not memorable — '' == 0 is true, null == 0 is false, null == undefined is true, and NaN equals nothing including itself. Use === always, with the single defensible exception of x == null as a deliberate check for both null and undefined, which is concise and widely recognised.

Show me how an event travels, and what delegation exploits.

A click does not simply happen on the element you clicked. It makes a round trip through the tree, and both directions are usable.

flowchart TD
    D[document] -->|capturing, downwards| B[div.list]
    B --> T[button.item, the target]
    T -->|bubbling, upwards| B2[div.list]
    B2 --> D2[document]

The capturing phase runs from the document down to the target, then the bubbling phase runs back up. Listeners attach to the bubbling phase by default; passing true or { capture: true } attaches to the descent instead, which is how a parent intercepts an event before the target ever sees it.

Event delegation exploits the bubbling phase: attach one listener to the container instead of one per child, and identify the origin from event.target.

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item || !list.contains(item)) return;
  select(item.dataset.id);
});

Two benefits, one of which is the real reason to do it. One thousand rows cost one listener rather than one thousand, which is a memory and setup win. More importantly, rows added later work with no wiring at all, because the listener lives on a container that was always there — so dynamically rendered content needs no rebinding.

The closest call is what makes it robust. Clicking a span inside the button sets target to the span, so matching target directly against .item fails for exactly the clicks users make most. And note the difference between target, the deepest element clicked, and currentTarget, the element the handler is attached to — confusing them is the delegation bug that actually occurs.

What is the difference between shallow and deep copying?

A shallow copy — spread syntax, Object.assign — duplicates the top level and copies references for anything nested, so mutating a nested object affects both copies. That is the source of the bug where updating state "immutably" still mutates the original, because only the outer object was new. structuredClone performs a real deep copy including cycles, and JSON.parse(JSON.stringify(x)) is the old approach that silently discards functions, undefined, Date types and cycles.

Asynchrony and the event loop

Show me the exact output order and why.

This question tests one specific fact: microtasks drain completely before the next macrotask.

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
queueMicrotask(() => console.log('4'));
console.log('5');

// 1, 5, 3, 4, 2

The synchronous code runs first and to completion: 1 and 5. Nothing asynchronous can interleave, because JavaScript runs on one thread and the call stack must empty first.

Then the microtask queue drains entirely: 3 and 4, in the order they were queued. Promise callbacks and queueMicrotask go here.

Only then does the event loop take one macrotask: the setTimeout callback, printing 2. A zero-millisecond timeout is not immediate — it is "the earliest macrotask slot", which is after every pending microtask.

The consequence that matters in real code is that microtasks can starve the loop. A promise chain that queues another microtask from within a microtask never yields, so rendering and timers never run and the tab freezes with the CPU busy. A macrotask like setTimeout yields between iterations, which is why chunking a long job uses a timer rather than a promise loop.

Rendering fits between macrotasks, which is the other half of the answer: mutate the DOM three times in one synchronous block and the user sees one frame, not three.

What does `async`/`await` actually compile to?

A promise chain with the continuation after each await scheduled as a microtask. await does not block the thread — it returns control to the event loop and resumes when the promise settles. Two consequences follow: an async function always returns a promise regardless of what you return from it, and code after an await runs in a later microtask, so anything captured before it may have changed. It also means a try/catch around an await works normally, which is the whole ergonomic point.

What is the difference between `Promise.all` and `Promise.allSettled`?

Promise.all rejects as soon as any input rejects, discarding the results of those that succeeded, which is right when you genuinely need all of them. allSettled always resolves with a status and value for every input, which is right when partial success is useful — loading four independent dashboard widgets, where one failing should not blank the other three. The related trap is that all does not cancel the others on rejection; they continue running, and their errors become unhandled rejections.

How do you cancel an in-flight request?

With an AbortController, whose signal is passed to fetch and aborted when the work becomes irrelevant — a component unmounting, or a newer search superseding an older one. Without it, a slow response for a stale query can arrive after a fast response for a newer one and overwrite it, which is the out-of-order race that makes a search box show results for a query the user has already changed. Aborting rejects the promise with an AbortError, which should be caught and ignored rather than reported.

What is an unhandled promise rejection, and why does it matter?

A promise that rejects with no rejection handler attached by the time the microtask queue drains. In a browser it fires a global unhandledrejection event and logs to the console; in Node it terminates the process by default in current versions. It matters because it is the asynchronous equivalent of swallowing an exception: the operation failed, execution continued, and nothing in the UI reflects it. The usual cause is calling an async function without awaiting it or attaching a catch, which is easy to do in an event handler.

What is `requestAnimationFrame` for?

Scheduling work to run immediately before the browser's next paint, which is the correct place for any visual update driven by JavaScript. Using a setTimeout at sixteen milliseconds instead means your update lands at an arbitrary point relative to the frame, so you either compute twice for one paint or miss the frame entirely and stutter. It also pauses in a background tab, which stops an off-screen animation burning battery — something a timer loop happily continues doing.

What is the difference between debounce and throttle?

Debounce waits for a pause: it delays the call until the input has been quiet for some interval, so a search box fires once when typing stops. Throttle enforces a maximum rate: it calls at most once per interval regardless of how many events arrive, so a scroll handler runs ten times a second rather than a hundred. Choose debounce when only the final state matters, throttle when regular updates during the activity are the point.

Show me debounce implemented.

Small enough to write on a whiteboard, and the details are where the marks are.

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);                        // cancel the pending call
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

The closure over timer is what makes it work: each returned function has its own, so two debounced handlers do not interfere. Clearing before setting is the whole mechanism — every new event cancels the one that was scheduled, so only a gap of delay with no events lets one through.

Two details separate a correct answer from an approximate one. Using function rather than an arrow for the wrapper, plus fn.apply(this, args), preserves the caller's this — necessary if the debounced function is used as a method or an event handler that expects the element. And forwarding ...args matters because handlers receive the event object.

The follow-up is usually a cancel method, or a leading-edge option that fires immediately and then suppresses:

function debounce(fn, delay) {
  let timer;
  const debounced = function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
  // Attached inside the factory, where `timer` is actually in scope.
  debounced.cancel = () => clearTimeout(timer);
  return debounced;
}

That matters in a component: without it, a debounced call scheduled before unmount still fires afterwards and updates something that no longer exists.

React and the component model

What actually causes a React component to re-render?

Its own state changing, its parent re-rendering, or a context it consumes changing value. Notably absent from that list is props changing — a child re-renders because its parent did, and by default it would re-render even with identical props. React then reconciles and only touches the DOM where the output differs, so a re-render is not automatically a problem. Treating every re-render as a bug leads to memoising everything, which costs more than it saves.

Show me why index-as-key breaks a list.

React uses keys to match elements between renders. An index describes position, not identity, so any reordering makes the match wrong.

{todos.map((todo, i) => <TodoItem key={i} todo={todo} />)}
Before: [A, B, C]   keys 0, 1, 2
Delete A
After:  [B, C]      keys 0, 1

React sees key 0 still present and reuses A's DOM node and internal state
for B. Any uncontrolled input, focus, scroll position or animation state
that belonged to A is now attached to B.

The visible symptom is a checkbox that stays ticked next to the wrong row, or text typed into one field appearing beside a different item after a delete. The data is correct and the DOM is not, which is why it survives review — the render output looks right.

{todos.map((todo) => <TodoItem key={todo.id} todo={todo} />)}

A stable identifier from the data fixes it, because now the key follows the item wherever it moves.

Index keys are safe in exactly one case: the list never reorders, never has items inserted or removed anywhere but the end, and the items hold no internal state. That is a real case — a static list rendered once — and it is not worth the risk of being wrong about it. Generating a key with a random value is worse than an index, because it changes every render and forces a full remount each time.

Show me the stale closure in `useEffect`.

The most common React bug, and it is the closure rule from earlier meeting the dependency array.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);        // captures count from THIS render
    }, 1000);
    return () => clearInterval(id);
  }, []);                          // runs once, so count is frozen at 0

  return <p>{count}</p>;
}
// The counter shows 1 and stops.

The effect ran once with count equal to 0. The interval callback closed over that render's count, so every tick computes 0 + 1. The state does update, and the effect never re-runs to see the new value.

The best fix removes the dependency instead of declaring it, using the functional update form:

setCount((c) => c + 1);   // React supplies the current value

Now the callback does not reference count at all, so the empty dependency array is honest and the interval is created once.

The alternative — adding count to the dependency array — also works and tears down and recreates the interval every second, which resets its phase and is usually not what you want.

The general rule is that the dependency array is a claim about what the effect reads, and lying to it produces stale values rather than an error. When the array forces a dependency you do not want, that is a signal to restructure the effect, not to silence the lint rule.

When are `useMemo` and `useCallback` actually pointless?

When the value they guard is cheap to recompute and the consumer is not memoised. useMemo around an object literal passed to a plain child saves nothing, because the child re-renders on its parent anyway; the memo just adds a dependency check and retained memory. They pay off in three cases: a genuinely expensive computation, a value in a dependency array where identity drives an effect, and a prop passed to a component wrapped in React.memo. Elsewhere they are cost without benefit.

What is the difference between a controlled and an uncontrolled input?

A controlled input's value comes from React state and every keystroke goes through a change handler, so the state is always the truth and validation or formatting can happen per character. An uncontrolled input keeps its value in the DOM and you read it via a ref when needed, which is less code and re-renders nothing while typing. Controlled is the default for forms that react as you type; uncontrolled suits large forms where per-keystroke re-rendering is the performance problem.

What are the rules of hooks, and why do they exist?

Hooks must be called at the top level of a component or another hook, never inside a condition, loop or nested function, and only from React functions. The reason is the implementation: React associates hook state with the call order within a component, not with a name, so it keeps an ordered list and matches by index across renders. A conditional hook shifts every subsequent index on the render where the condition changes, so a useState starts reading another hook's slot. That is why the failure is bizarre state corruption rather than a clear error, and why the lint rule is worth treating as non-negotiable.

What does `React.memo` do, and when does it silently fail?

It skips re-rendering a component when its props are shallowly equal to the previous render's. It fails silently whenever a prop is a new object, array or function created during the parent's render, because shallow equality compares identity and a fresh literal is never identical — so the memo runs a comparison every render and always concludes it must re-render. That is why memo usually has to be paired with useMemo or useCallback on the parent's side, and why applying it to one component in isolation so often changes nothing measurable.

What problem do keys and reconciliation not solve?

State that should survive a component moving in the tree. React discards component state when the element's position and type change, so conditionally rendering the same component under two different parents remounts it and loses everything. This is why a modal implemented by rendering the form in two places resets it, and why the fix is either lifting the state above both positions or keeping one element and changing its styling.

State and data flow

What is the difference between client state and server state?

Client state is owned by the browser and has no truth elsewhere — which tab is open, whether a drawer is expanded, what is typed but unsubmitted. Server state is a cache of something owned elsewhere, which means it can be stale, needs refetching and invalidation, and can fail to load. Treating them as one thing is why so many applications put fetched data in a global store and then hand-write caching, retries and invalidation that a data-fetching library provides.

When does an application actually need a global state library?

When the same state is genuinely needed by distant parts of the tree and lifting it produces prop drilling through many uninterested layers. Before that, local state plus composition handles more than people expect, and passing a component as a child often removes the drilling entirely. The cost of a global store is that state loses its scope: anything can read and write it, so the invariants that a component enforced locally now have to be enforced by convention.

Why can React context cause performance problems?

Because every consumer re-renders when the context value changes, with no mechanism to subscribe to part of it. A context holding an object rebuilt on each provider render changes identity every time, so every consumer re-renders even when the field it reads is unchanged. The mitigations are splitting one context into several by update frequency, memoising the value, and keeping rapidly changing state — a mouse position, a text input — out of context entirely.

Is prop drilling always a problem?

No, and treating it as one is how applications acquire a global store they did not need. Passing a prop through two or three layers is explicit, easy to trace, and makes the dependency visible in a way a context or store does not. It becomes a problem at the point where intermediate components accept props purely to forward them, since each one now changes whenever the shape of unrelated data changes. The first remedy is usually composition — passing rendered children through — which removes the intermediate layers' involvement entirely without introducing shared mutable state.

What is optimistic updating and what does it require?

Applying the expected result to the UI immediately and reconciling when the server responds, so an action feels instant. It requires that you can compute the expected outcome locally, and — the part people forget — that you can undo it cleanly when the request fails, including restoring anything else that changed in the meantime. Without a rollback path it is not optimistic updating, it is lying to the user until they reload.

Performance

Show me what actually moves each Core Web Vital.

Three metrics, three unrelated causes, and treating them as one "make it faster" problem is why work on them so often fails to move them.

LCP  Largest Contentful Paint     good: under 2.5s
     When the biggest above-the-fold element finishes rendering.
     Moved by: server response time, render-blocking CSS and fonts,
     the hero image's format, size and priority, and whether the resource
     was discoverable in the initial HTML rather than requested by JS.
     Not moved by: shrinking your JavaScript bundle, usually.

CLS  Cumulative Layout Shift      good: under 0.1
     How much visible content jumps after it was painted.
     Moved by: images and iframes without width and height, ads and
     embeds injected into flow, web fonts swapping to a different metric,
     content inserted above what the user is reading.
     Fixed by reserving the space up front, not by loading faster.

INP  Interaction to Next Paint    good: under 200ms
     Worst-case delay between an interaction and the visual response.
     Moved by: long tasks blocking the main thread, expensive event
     handlers, large synchronous re-renders, hydration during early input.

The important structural point is that they measure different phases — loading, stability and responsiveness — so a page can be excellent at one and fail another. A server-rendered page can have a superb LCP and a terrible INP because hydration blocks the main thread precisely when an impatient user first clicks.

The other point is that these are field metrics at the 75th percentile of real users, not lab numbers. A local audit on a fast machine over a fast network routinely reports good scores for a page that fails in the field, because the p75 user is on a mid-range phone on a congested network.

What does code splitting actually buy you?

It removes code from the initial download that the first screen does not need, so parse and execute time on the main thread drops — which is usually the larger win on a mid-range phone, where JavaScript costs far more to run than to download. Splitting by route is the reliable default. It goes wrong when a split is so fine that navigating triggers a waterfall of small chunk requests, which is slower than one larger bundle would have been.

What is the difference between CSR, SSR, SSG and streaming?

Client-side rendering ships an empty shell and builds the page in the browser, so time to first content is poor and interaction after that is cheap. Server-side rendering produces HTML per request, giving fast first content at the cost of server work and a hydration step. Static generation renders at build time, which is fastest and only works for content that does not vary per request. Streaming sends HTML in chunks as it becomes ready, so the shell paints while slow parts are still being produced.

What is hydration, and what is a hydration mismatch?

Hydration is React attaching event listeners and rebuilding its internal tree over server-rendered HTML, so markup that was already visible becomes interactive. A mismatch is the client's first render producing different output from the server's — typically from using a date, a random value, or a browser-only API during render. React then discards the server HTML for that subtree and re-renders it, which costs the performance benefit you were paying for and can visibly flash.

What is tree shaking, and why does it so often fail?

Eliminating exports that nothing imports, which a bundler can only do when the module graph is statically analysable — meaning ES modules, since CommonJS imports can be computed at runtime. It fails quietly for three common reasons: a package ships only a CommonJS build, a module has side effects at import time so removing it would change behaviour, or the code imports a whole namespace rather than named exports. The practical consequence is that a bundle can contain most of a library you used one function from, and only inspecting the built output tells you.

What is the difference between preload and prefetch?

preload tells the browser to fetch a resource needed for the current navigation at high priority — a font or hero image the parser would otherwise discover late. prefetch fetches something likely needed for a future navigation at the lowest priority, using idle time. Confusing them is actively harmful: preloading something not used on this page competes with resources that are, which delays LCP rather than improving it. A preloaded resource unused within a few seconds also produces a console warning, which is the browser telling you the priority was wrong.

What is the single most common frontend performance mistake?

Shipping images at the wrong size and format. A hero photograph at its original resolution can outweigh the entire JavaScript bundle several times over, and fixing it is a build-time change rather than an architectural one. Serving appropriately sized variants, a modern format, explicit dimensions to prevent layout shift, and lazy loading for anything below the fold together move LCP more than most refactoring will.

CSS and layout

Show me how specificity is calculated.

Specificity decides which rule wins, and it is a three-part comparison rather than a single score.

Counted as (ids, classes/attributes/pseudo-classes, elements/pseudo-elements)

#nav .item a:hover        -> (1, 2, 1)
.sidebar .item a          -> (0, 2, 1)
a.item                    -> (0, 1, 1)
a                         -> (0, 0, 1)
* , :where(...)           -> (0, 0, 0)

Compared left to right. A single id beats any number of classes, because
the comparison never reaches the second column if the first differs.

That last line is the part people get wrong by treating specificity as a sum. A selector with one id and nothing else defeats one with fifty classes, so there is no "enough classes" that wins.

When specificity ties, source order decides and the later rule wins. That is why stylesheet order matters and why a component's own styles can lose to a library's purely because of import order.

!important sits outside the comparison entirely and beats everything except another !important with higher specificity, which is why it escalates: the only answer to one is another. The reason it is discouraged is not aesthetic — it removes your ability to override later without escalating again.

The modern tools worth naming are :where(), which takes zero specificity and is built for library defaults that should be trivially overridable, and :is(), which takes the specificity of its most specific argument. Cascade layers make the ordering explicit instead of depending on import sequence.

When should you use flexbox rather than grid?

Flexbox lays out along one axis and lets content size itself, which suits a row of buttons, a navigation bar, or centring something — cases where you care about distribution rather than alignment across rows and columns. Grid defines a two-dimensional structure in advance, so items align in both directions regardless of their content, which suits page layout and card grids. The practical division is that flexbox is content-driven and grid is layout-driven, and nesting one inside the other is normal.

What does `box-sizing: border-box` change?

It makes width include padding and border rather than describing the content box alone. Under the default content-box, an element with width: 200px plus 20px of padding and a 1px border occupies 242px, so any layout combining percentage widths with padding breaks. border-box makes the declared width the final rendered width, which matches how people reason about boxes, and applying it globally is one of the few near-universal CSS resets.

What is the difference between `position: absolute`, `fixed` and `sticky`?

absolute positions relative to the nearest positioned ancestor, leaving normal flow entirely. fixed positions relative to the viewport, so it does not scroll — except inside an ancestor with a transform, which makes that ancestor the containing block and is a genuinely surprising failure. sticky behaves as relative until a scroll threshold and then as fixed within its parent, which means it silently does nothing if the parent has overflow: hidden or is not tall enough.

When should you use `rem`, `em` or `px`?

rem is relative to the root font size, so it scales with a user's browser setting — which is why type and spacing in rem respect someone who has enlarged their default text and the same values in px silently ignore them. em is relative to the element's own font size, useful for padding that should scale with the text inside a component, and compounding when nested, which is its usual failure. px remains correct for things that genuinely should not scale, such as a one-pixel border or a fixed hairline.

Why do margins collapse, and when does it stop?

Adjacent vertical margins between block-level siblings combine into the larger of the two rather than adding, and a parent's margin can collapse with its first or last child's. It is a typographic convention from the specification's origins and it surprises everyone. It stops when something separates them: padding or a border on the parent, a new block formatting context via overflow, display: flow-root, or flex and grid containers, whose children never collapse margins at all.

Accessibility, security and traps

Show me why a `div` with an onClick is not a button.

The visual result is identical, and everything else is not.

// Not a button
<div className="btn" onClick={save}>Save</div>

// A button
<button type="button" className="btn" onClick={save}>Save</button>

The div is missing four separate behaviours the browser gives you for free. It is not in the tab order, so a keyboard user cannot reach it at all. It does not respond to Enter or Space, because activation on those keys is button behaviour rather than click behaviour. It is announced as a group or as nothing by a screen reader, so its purpose is unstated. And it has no disabled state, no focus ring and no form semantics.

Reproducing that manually takes all of this and is still worse:

<div
  role="button"
  tabIndex={0}
  onClick={save}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); save(); }
  }}
>Save</div>

That is the first rule of ARIA in practice: do not use ARIA if a native element does the job, because the native element is more correct, works in more assistive technologies, and cannot fall out of sync with its own behaviour.

The usual reason people reach for the div is that button styling is inconvenient. It is not — all: unset or a small reset removes the default appearance while keeping every behaviour, which is the trade you actually want.

What does the first rule of ARIA say?

That you should not use ARIA if a native HTML element or attribute already carries the semantics you need. Native elements bring keyboard behaviour, focus management and consistent announcements across assistive technologies, all of which ARIA only describes and never implements. A role="button" tells a screen reader something is a button and does not make Enter activate it — so incorrect or incomplete ARIA is measurably worse than none, because it makes a promise the element does not keep.

What must you do about focus in a modal?

Move focus into the dialog when it opens, trap it there while it is open so Tab cannot reach the page behind, restore it to the triggering element when it closes, and close on Escape. Without this a keyboard user tabs from the dialog into content they cannot see, with no way back. The <dialog> element with showModal() provides most of it natively, including the inert background, which is another case of the native element being the shorter path.

What is the contrast requirement, and why does it apply to more than text?

Normal text needs a contrast ratio of at least 4.5:1 against its background, large text 3:1, under the standard commonly required. The part that gets missed is that interface components and meaningful graphics — input borders, icon buttons, focus indicators, chart series — also need 3:1, so a fashionably faint input outline can fail even when the text inside passes. It matters beyond compliance because low contrast is a problem for everyone on a phone outdoors.

Why is `dangerouslySetInnerHTML` named that way?

Because it disables the escaping that otherwise makes React output safe by default, so any user-controlled content in that string becomes executable markup — the XSS hole. The name is a deliberate speed bump: it is meant to be hard to write without thinking. When you genuinely must render HTML, it has to be sanitised with a well-maintained library, on a strict allowlist, and preferably on the server, because a client-side sanitiser can be bypassed by anything that reaches the DOM another way.

What does CORS actually protect against, and what does it not?

It stops a script on one origin from reading a response from another origin unless that origin permits it, which is what prevents a malicious page from reading your logged-in email. It does not prevent the request being sent — a simple POST from a form reaches your server and executes regardless of CORS, which is why CSRF is a separate problem needing tokens or SameSite cookies. And it is enforced by the browser, so it protects users, not your API from non-browser callers.

Why is a permissive `Access-Control-Allow-Origin` dangerous?

Because reflecting the request's origin, or using *, tells the browser that any site may read your responses. With credentials involved that is a full cross-origin data leak: a user visiting an attacker's page has their session used to fetch and read their own data. The specification refuses to combine * with credentials for exactly this reason, and the common workaround — reflecting the Origin header back — reintroduces the hole while passing the check.

Where should an auth token be stored in a browser?

Not in localStorage, which any script running on the page can read — so a single XSS anywhere, including in a dependency, exfiltrates it. An HttpOnly, Secure, SameSite cookie is unreadable from JavaScript, which removes that entire class of theft, at the cost of needing CSRF protection because the browser attaches it automatically. sessionStorage has the same readability problem as localStorage and merely clears on tab close. The honest summary is that cookies trade an XSS exposure for a CSRF one, and CSRF has a complete, well-understood defence.

What is the frontend question that separates candidates?

"How would you know this was slow for real users?" It moves the conversation from techniques to measurement, and it needs field data rather than a local audit — real-user monitoring at the 75th percentile, segmented by device and connection, because the average of a fast desktop and a mid-range phone describes neither. A strong answer also says which metric would move if the fix worked, which turns an optimisation into something falsifiable rather than a change that felt faster.