A DOM event travels in three phases: capture (from window down to the target), target, and bubble (back up to window). Event delegation puts one listener on a common ancestor and uses event.target to find which child was hit, instead of a listener on every child.
addEventListener(type, fn) listens in the bubble phase; pass { capture: true } to listen during capture.
stopPropagation() stops the event reaching further elements; preventDefault() cancels the browser's default action (following a link, submitting a form) and does not stop propagation. They are independent.
Why delegate: fewer listeners, and elements added later are handled automatically. Use target.closest('button') because the click may land on a nested <span> or icon.
Some events do not bubble: focus, blur, mouseenter, mouseleave. Use focusin/focusout or the capture phase instead.
React delegates too: it attaches its listeners to the root container, not to each element.
ReactMiddle
Web Platform & Security
How the browser renders a page, the DOM and events, CSS layout, accessibility, HTTP and caching, XSS, CSP and CORS, cookies, tokens and OAuth, storage
Questions closed
0 / 23
- 0Review
- 15Asked often
Questions
A plain <script> stops HTML parsing until it downloads and runs. async downloads in parallel and runs as soon as it arrives, in any order; defer downloads in parallel and runs in document order after parsing finishes, just before DOMContentLoaded.
**defer for app code that needs the DOM or depends on another script.
async for independent scripts such as analytics, where order does not matter.
type="module"** is deferred by default; adding async makes it run as soon as it and its imports are ready. Vite emits module scripts.
A blocking script in <head> delays the first paint, which is why old advice put scripts at the end of <body>.
A script inserted from JavaScript (document.createElement('script')) behaves like async.
The cascade compares, in order: origin and !important, cascade layers (@layer), specificity, and finally source order, where the later rule wins. Specificity counts ids first, then classes, attributes and pseudo-classes, then element names and pseudo-elements.
Specificity is compared as a triple: #nav a is (1,0,1), .menu .link is (0,2,0), so the id selector wins however many classes the other one has.
Inline style beats any selector; !important beats inline styles and should stay rare, since overriding it needs another !important.
:where() adds zero specificity; :is() and :not() take the specificity of their most specific argument.
**@layer**: a rule in a later-declared layer beats any rule in an earlier layer regardless of specificity, and unlayered styles beat all layers. That is how you put a reset or a vendor stylesheet below your own code.
CSS Modules, Tailwind and BEM keep specificity flat (one class each), which is the practical way to avoid specificity wars.
Flexbox lays items out in one dimension (a row or a column) and lets the content size drive the layout; Grid lays out rows and columns at once, with the tracks defined by the container. Toolbars and chip lists are Flexbox; page layouts and card galleries are Grid.
Flexbox: justify-content along the main axis, align-items across it, gap between items, flex: 1 to take the free space. Items can wrap with flex-wrap, but each line is independent, so columns do not line up.
Grid: grid-template-columns, grid-template-areas, fr units, items that span several cells. Columns line up across rows.
Responsive without media queries: repeat(auto-fill, minmax(240px, 1fr)) fits as many columns as the width allows.
Subgrid, supported in all major browsers since 2023, lets nested elements align to the parent's tracks, for example card titles across a row.
They combine: a Grid page whose header is a Flexbox row.
localStorage and sessionStorage are small synchronous string stores per origin (about 5 MB); localStorage persists, sessionStorage lives with the tab. Cookies are tiny (about 4 KB) and go to the server with every matching request; IndexedDB is an async database for large structured data.
**localStorage**: survives restarts, is shared by every tab of the origin and fires a storage event in the other tabs. It is synchronous, so large reads block the main thread.
**sessionStorage: one per tab; a reload keeps it, closing the tab clears it.
Cookies**: the only storage the server can set and read; HttpOnly, Secure, SameSite and Expires control them. Every cookie adds bytes to every request.
IndexedDB: hundreds of megabytes, indexes and transactions, available in service workers. The raw API is clumsy, so people use idb or Dexie. Cache Storage holds HTTP responses for offline use.
Anything JavaScript can read, an XSS payload can read, so secrets do not belong in localStorage. Safari may also clear script-written storage of a site the user has not visited for seven days.
GET reads, POST creates or triggers an action, PUT replaces a resource with the body, PATCH changes part of it and DELETE removes it. Safe methods (GET, HEAD, OPTIONS) change nothing; idempotent ones (the safe ones plus PUT and DELETE) leave the same state when repeated.
Idempotent is about server state, not the response: a second DELETE /orders/7 may answer 404, but the order is gone either way. A second POST /orders creates a second order.
**PUT or PATCH**: PUT sends the whole resource, so a field you leave out is cleared; PATCH sends only the changes. PATCH is not guaranteed to be idempotent: { "op": "increment" } is not.
Retries: an idempotent request can be resent after a timeout without harm; a POST needs an idempotency key so the server can drop the duplicate.
**Never change state on GET**: browsers and CDNs cache GET responses, and prefetchers, link previews and crawlers send GET freely. A GET /logout or GET /delete?id=7 fires by accident and is an easy CSRF target.
The first digit is the class: 2xx success, 3xx redirect, 4xx a problem with the request, 5xx a failure on the server. 401 means the server does not know who you are and 403 that it knows and says no; 301 is a permanent redirect, 302 a temporary one, and 304 says your cached copy is still valid.
2xx: 201 Created after a POST; 204 No Content for a save or delete with no body, so res.json() on it throws.
3xx: browsers cache a 301 and search engines move the page to the new URL; a 302 is not remembered. 307 and 308 are the same pair but keep the method and body, while a 302 after a POST usually turns into a GET. 304 Not Modified answers a conditional request and has no body.
401 or 403: on 401 refresh the token or send the user to sign in; on 403 show "no access", because signing in again will not help.
Other 4xx: 400 malformed request, 404 not found, 409 conflict (someone else changed the record), 422 validation errors to show next to the fields, 429 too many requests (wait for Retry-After).
5xx: 500 is a server bug; 502, 503 and 504 come from a proxy or load balancer when the backend is down, overloaded or slow. Retry these with backoff; most 4xx fail the same way again.
HTTP/2 sends many requests in parallel over one TCP connection with compressed headers, and HTTP/3 moves the same model to QUIC over UDP, so one lost packet no longer stalls every stream. Domain sharding now hurts, and file count is no longer a reason for one big bundle.
HTTP/1.1: one request at a time per connection and about six connections per host, so requests queue behind a slow one. Hence the old tricks: one huge bundle, sprites, inlined images, extra hosts (static1., static2.).
HTTP/2 multiplexing: many streams interleave on one connection, and HPACK compression sends repeated headers such as cookies once instead of with every request.
HTTP/2's weak spot: it still runs on TCP, so one lost packet holds back every stream until it is resent, which hurts on mobile networks. Chrome removed Server Push in 2022 in favour of 103 Early Hints and preload.
HTTP/3: QUIC recovers each stream from loss on its own, puts TLS 1.3 into the handshake (one round trip, zero on resumption) and survives a phone switching from Wi-Fi to mobile data.
Domain sharding now hurts: each extra host costs DNS, TCP and TLS and splits the one prioritised connection.
Bundling: the file count no longer forces one huge bundle, but hundreds of tiny modules still compress worse and cost more requests. Split by route and by vendor so a deploy invalidates less of the cache.
The script is reused for a year without any request; index.html is stored but revalidated before every use, which costs only a 304; /api/me is never stored; the product list is reused for 60 s, then served stale for up to 300 s more while a background request refreshes it.
**max-age with immutable**: while the response is fresh the browser does not contact the server at all, and immutable rules out revalidation even on a reload. Safe only because the hash in the file name changes with every build.
**no-cache does not mean "do not cache"**: the response is stored, but before each use the browser sends If-None-Match: "v42". If the ETag still matches, the server answers 304 Not Modified with no body: you pay a round trip, not the download.
**no-store** is the real "do not keep": nothing goes into the HTTP cache. Use it for personal or sensitive data. private is another matter: the browser may keep the response, only a CDN or proxy may not.
**stale-while-revalidate=300: between 60 and 360 s after the response, the stale copy is used at once and a background request updates the cache; after 360 s the next use waits for the network.
No Cache-Control at all** is not "no caching": with a Last-Modified header browsers guess a lifetime (often 10% of the time since the last change), which is how an old index.html sticks for hours. Always set the header.
The browser resolves the domain through DNS, opens a TCP or QUIC connection, negotiates TLS, sends an HTTP request and receives HTML. It then parses the HTML, fetches CSS, scripts and images, builds the DOM and CSSOM, and runs layout, paint and compositing to show pixels.
Network
Caches first: HSTS can upgrade the URL to https; a service worker or a fresh HTTP cache entry can answer without the network at all.
DNS: browser cache, OS cache, then a resolver; the answer is an IP address.
Connection: a TCP handshake plus a TLS 1.3 handshake, or HTTP/3 over QUIC, which combines the two. The connection is reused for later requests.
Request and response: a GET with cookies and headers; the answer may be a redirect (301, 302) or come from a CDN edge.
Rendering
The parser builds the DOM incrementally while the preload scanner starts downloading CSS, scripts and images it finds ahead.
CSS blocks rendering, a classic <script> blocks parsing.
DOM and CSSOM give the render tree, then layout, paint and composite produce the first frame. DOMContentLoaded fires when the HTML is parsed, load when subresources finish.
In a client-rendered SPA
The HTML is often an empty shell, so content waits until the JavaScript bundle downloads and runs. That delay is the main argument for SSR or prerendering.
The browser parses HTML into the DOM and CSS into the CSSOM, combines them into a render tree of visible nodes, computes each box's size and position (layout), draws the pixels of each layer (paint) and lets the GPU combine the layers into the final frame (composite).
CSS is render-blocking: nothing is painted until the CSSOM is ready, to avoid a flash of unstyled content. Keep critical CSS small and load the rest later.
Scripts are parser-blocking: a classic script may read or change the DOM and styles, so parsing waits for it. Use defer or type="module".
Render tree: elements with display: none are left out; visibility: hidden ones still take up space.
After the first frame the same pipeline runs on every change: a change to geometry re-runs layout, a colour change only paint, and transform or opacity can be handled by the compositor alone.
Measuring: the DevTools Performance panel shows each step; FCP and LCP are what the user feels.
Reflow (layout) recalculates the size and position of elements and is the expensive one; repaint redraws pixels without changing geometry. Layout thrashing is code that alternates DOM writes and layout reads in a loop, forcing a synchronous reflow on every read.
Reflow triggers: changing width, height, margin, top or font size, adding or removing elements, resizing the window.
Repaint only: color, background-color, visibility.
Compositor only: transform and opacity on an element with its own layer, which is why animations should use them.
Forced synchronous layout: reading offsetHeight, getBoundingClientRect(), scrollTop or getComputedStyle() right after a write makes the browser compute layout immediately instead of once per frame.
Fix: do all reads first, then all writes, or schedule writes in requestAnimationFrame. The Performance panel marks the problem as "Forced reflow".
outer capture, button, outer bubble, document: capture listeners run on the way down from window to the target, then the target's own listeners, then bubble listeners on the way back up.
Registration order does not matter across phases: outer capture was added second but runs first, because capture happens before the target is reached.
Within one element and one phase, listeners run in the order they were added.
On the target itself, capture listeners run before non-capture ones in all current browsers, as the spec says.
event.stopPropagation() inside the button's listener would skip outer bubble and document; outer capture has already run by then.
event.eventPhase tells you the phase: 1 capture, 2 at target, 3 bubble.
z-index only orders elements inside the same stacking context. If an ancestor creates its own context (transform, opacity below 1, filter, position with z-index, isolation), its children can never rise above that ancestor's siblings, whatever their own z-index.
A stacking context is painted as one unit: the whole subtree takes the ancestor's place in the parent context, and the children's z-index values only matter inside it.
z-index does nothing on an element with position: static, unless it is a flex or grid item.
Debugging: find the nearest ancestor that creates a context; the DevTools Layers panel helps.
Fixes: render the element elsewhere with a portal, use the native <dialog> or the Popover API (the top layer sits above everything), or remove the property that creates the context.
isolation: isolate creates a context on purpose, so a component's internal z-index values cannot leak out and fight the rest of the page.
Semantic HTML first (button, a, label, headings, landmarks), everything usable from the keyboard with a visible focus, text alternatives for images and icon buttons, enough colour contrast, and focus management whenever the UI changes without a page load.
Semantics: a <button> brings focus, Enter and Space activation and the right role for free; a <div onClick> has none of them.
Forms: every input has a <label htmlFor> or an aria-label; error messages are linked with aria-describedby.
Images: a meaningful alt, or alt="" for decoration.
Focus: never remove the outline without a replacement (:focus-visible); after a client-side route change move focus to the main heading; a modal traps focus and returns it on close.
Contrast: WCAG AA asks for 4.5:1 for body text and 3:1 for large text and UI components.
Checking: eslint-plugin-jsx-a11y, axe in tests, Testing Library queries by role (getByRole('button', { name: 'Save' })), and a manual pass with VoiceOver or NVDA.
A <div> with onClick is not focusable, ignores Enter and Space and has no role, and the icon has no text alternative, so keyboard users cannot reach it and a screen reader announces nothing useful. Use a <button> with an accessible name.
<button type="button"> is reachable with Tab, fires click on Enter and Space, and is announced as a button.
Name: an icon-only button needs aria-label (or visually hidden text); the decorative image gets alt="" so it is not read out on top of the label.
State: aria-expanded and aria-controls tell assistive technology whether the menu is open and which element it controls.
Patching the div with role="button", tabIndex={0} and an onKeyDown handler works, but re-implements what the browser already gives you and is easy to get wrong.
eslint-plugin-jsx-a11y reports both problems (click-events-have-key-events, alt-text).
ARIA is a set of HTML attributes (role, aria-*) that tell assistive technology what an element is, its state and its relations when HTML alone cannot. The first rule of ARIA: if a native element with that meaning exists, use the element instead.
Roles such as dialog or tablist; states such as aria-expanded, aria-selected, aria-checked; relations such as aria-labelledby, aria-describedby, aria-controls; live regions (aria-live="polite") announce updates like toasts.
ARIA changes only what is announced, not behaviour: role="button" does not make an element focusable or keyboard-operable.
Wrong ARIA is worse than none: a wrong role or a stale aria-expanded actively misleads a screen reader user. The yearly WebAIM Million survey finds more detected errors on pages that use ARIA than on pages that do not.
Common mistakes: aria-label on a plain div with no role, aria-hidden="true" on something focusable, a redundant role="button" on a <button>.
For complex widgets (combobox, tabs, tree) follow the WAI-ARIA Authoring Practices patterns, or take them ready-made from React Aria or Radix.
Cross-site scripting (XSS) is attacker-controlled content running as script on your page, with the user's session. React escapes every string rendered in JSX, so {comment} shows as text; the protection ends where you bypass JSX: dangerouslySetInnerHTML, URLs and direct DOM access.
**dangerouslySetInnerHTML: sanitize the HTML first with DOMPurify, or render Markdown with a library that does not pass raw HTML through.
URLs**: escaping does not make a URL safe. href={user.website} holding javascript:alert(1) runs code on click, so allow only https:, http: or mailto: for user-supplied links.
Direct DOM: ref.current.innerHTML, insertAdjacentHTML, eval, new Function and string setTimeout skip React entirely.
SSR: serialising state into a <script> tag with plain JSON.stringify lets a </script> inside the data break out; escape < or use the framework's serializer.
Content Security Policy limits the damage: script-src 'self' with nonces stops injected inline scripts from running, and HttpOnly cookies keep a session token out of the injected script's reach.
CORS is how a server tells the browser that JavaScript from another origin may read its responses: the browser enforces the same-origin policy and exposes the response only if it has a matching Access-Control-Allow-Origin. The fix is on the server or a same-origin proxy, never in frontend code.
Origin: scheme, host and port. app.example.com and api.example.com are different origins.
The request still reaches the server: CORS blocks reading the response, not sending the request, which is why CORS is not a defence against CSRF.
Preflight: for a non-simple request (a method other than GET, HEAD or POST, Content-Type: application/json, a custom header such as Authorization) the browser first sends OPTIONS with Access-Control-Request-Method and Access-Control-Request-Headers. The server answers with what it allows and can cache the answer with Access-Control-Max-Age.
Credentials: credentials: 'include' needs Access-Control-Allow-Credentials: true and an exact origin, not *.
In development Vite's server.proxy makes API calls same-origin; in production serve the API under the same site or configure the server's allow-list.
mode: 'no-cors' fixes nothing: it returns an opaque response you cannot read.
CSP is a response header that tells the browser which scripts, styles, frames and connections a page may use, so injected markup cannot run even if it reaches the DOM. Roll it out in report-only mode first, move inline scripts behind nonces or hashes, and only then enforce it.
A strict policy
script-src 'nonce-…' 'strict-dynamic': only scripts that carry this response's random nonce run, plus the scripts they load. Host allow-lists (script-src cdn.example.com) are weaker: any JSONP endpoint or outdated library on an allowed host becomes a bypass.
object-src 'none' and base-uri 'none' close old injection tricks, and frame-ancestors 'self' stops clickjacking.
The rollout
Report only: send Content-Security-Policy-Report-Only with a report endpoint. Nothing is blocked; violations arrive as JSON. Collect them over a week or two of real traffic.
Inventory: inline <script> blocks (analytics snippets, a theme script, serialized state), onclick="" attributes, eval in old libraries and tag managers that inject more scripts.
Fix: move inline code into files, put the nonce on the scripts you keep, add the third parties you actually use.
Enforce for a share of traffic with reporting still on, then everywhere.
Nonces or hashes
A nonce must be new on every response, so the HTML has to be rendered per request. In Next.js, proxy.ts sets the header and the framework adds the nonce to its own scripts, but those pages become dynamic and lose static and CDN caching.
A static SPA on a CDN cannot vary its HTML: use no inline scripts and script-src 'self', or build-time hashes ('sha256-…') of the few inline ones.
The step after CSP is Trusted Types (require-trusted-types-for 'script'), which makes innerHTML and similar sinks accept only sanitized values; all major browsers support it as of 2026.
Cross-site request forgery makes the user's browser send a request to your site from another site, with the user's cookies attached. SameSite cookies stop the browser from attaching them to cross-site requests; a CSRF token proves the request came from your own page.
Only cookie auth is exposed: a token in the Authorization header is not sent automatically, so a bearer-token API is not open to classic CSRF (it is to XSS).
**SameSite=Lax**, the default in Chrome and Edge when the attribute is missing, sends the cookie on top-level GET navigations but not on cross-site POST, fetch or iframes. **Strict never sends it cross-site, so a link from an email lands the user logged out. None** requires Secure and is for real cross-site use such as embedded widgets.
Same-site is not same-origin: evil.example.com is the same site as app.example.com, so a compromised subdomain bypasses SameSite.
Tokens: a synchronizer token kept in the session and sent in a header or hidden field, or a double-submit cookie; server frameworks generate them for you.
Rules: never change state on GET; check the Origin or Sec-Fetch-Site header on mutating requests as a cheap extra layer.
The safest common setup is a short-lived access token in memory plus a refresh token in an HttpOnly, Secure, SameSite cookie, or only a session cookie with a backend-for-frontend holding the tokens. localStorage is the easiest to steal: any XSS payload can read it and send it away.
Options
**localStorage: simple and survives reloads, but readable by any script on the page, including a compromised npm dependency.
HttpOnly cookie**: JavaScript cannot read it, so XSS cannot copy it out; but the browser sends it automatically, so you need SameSite and CSRF protection, and a cross-origin API needs CORS with credentials.
Memory: gone on reload, so the app calls a refresh endpoint (with the cookie) on start; XSS can use the token while the page is open but cannot keep it.
Backend-for-frontend
The browser holds only a session cookie; a thin server keeps the OAuth tokens and attaches them to API calls. The IETF guidance on OAuth for browser-based apps ranks this as the most secure pattern.
What storage does not fix
While an attacker's script runs, it can send requests as the user whatever you chose. Storage decides how long and from where stolen access works; CSP and output escaping prevent the XSS itself.
The app sends the user to the provider with the hash of a one-time secret, the provider sends the user back with a short-lived code, and the app exchanges the code plus the original secret for tokens. That secret (PKCE) replaces the client secret, which a browser app cannot keep.
The flow
Start: generate a random code_verifier, compute code_challenge = BASE64URL(SHA-256(verifier)), keep the verifier and a random state in sessionStorage, and redirect to /authorize with response_type=code, client_id, redirect_uri, scope, state and the challenge.
At the provider: the user signs in and consents on the provider's domain; the app never sees the password.
Callback: the provider redirects to redirect_uri?code=…&state=…. The app checks that state matches what it saved, which stops an attacker from slipping in their own code (login CSRF), and removes the code from the URL.
Exchange: POST /token with the code and the code_verifier. The provider hashes the verifier, compares it with the stored challenge and returns an access token, a refresh token and, with OpenID Connect, an id_token that describes the user.
Why PKCE
A SPA is a public client: anything in the bundle, a "secret" included, can be read. Without PKCE a code leaked through history, logs or a malicious extension can be redeemed by whoever holds it; with PKCE only the holder of the verifier can.
The old implicit flow (response_type=token) returned the token in the URL, where it leaked through history and referrers. RFC 9700 (2025) advises against it and requires PKCE for public clients.
In production
Use a maintained library (oidc-client-ts or the provider's SDK) rather than hand-written code, and register exact redirect URIs.
Refresh tokens held by the browser must rotate on every use or be bound to the client.
A backend-for-frontend can run the same flow on the server as a confidential client and give the browser only a session cookie.