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.
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.
Loading diagram…
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 iterationfor (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 firstconst 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.
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.
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.
Loading diagram…
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.
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.
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.
// Fix 1: let is block-scoped, so each iteration gets its own bindingfor (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 iterationfor (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.
const obj = {
name: 'obj',
regular() { returnthis.name; },
arrow: () =>this?.name,
};
obj.regular(); // 'obj' — called as a method: this is the receiverconst f = obj.regular;
f(); // TypeError in strict mode - this is undefined
f.call({ name: 'other' }); // 'other' — explicit bindingnew (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.
if
contains
return
select
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.
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.
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.
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.
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.
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.
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.
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.
Small enough to write on a whiteboard, and the details are where the marks are.
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:
functiondebounce(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 = () => (timer);
debounced;
}
That matters in a component: without it, a debounced call scheduled before
unmount still fires afterwards and updates something that no longer exists.
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.
React uses keys to match elements between renders. An index describes position,
not identity, so any reordering makes the match wrong.
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.
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.
The most common React bug, and it is the closure rule from earlier meeting the
dependency array.
functionCounter() {
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 0return<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 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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The visual result is identical, and everything else is not.
// Not a button
<div className="btn" onClick={save}>Save</div>
// A button<buttontype="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:
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.
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.
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.
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.
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.
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.
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.
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.