Browser networking & app architecture
CORS and same-origin policy, SPA vs MPA vs SSR vs SSG, component architecture + virtual DOM, bundlers, and web-perf/a11y basics.
This chapter covers how the browser talks to servers under the same-origin security model, the four rendering architectures you’ll choose between, and the tooling — components, virtual DOM, bundlers — that modern frontends are built on. It closes with the performance and accessibility baselines every page should hit.
Same-origin policy and CORS
The same-origin policy is the browser’s foundational security boundary: JavaScript running on https://app.com may send a request to https://api.other.com, but it cannot read the response unless the other server explicitly allows it. Without this, any site you visited could read your logged-in Gmail or bank pages using your cookies. Origin = scheme + host + port; change any one and it’s cross-origin.
CORS is the server’s mechanism to opt in. When the browser makes a cross-origin request, it checks the response for headers like Access-Control-Allow-Origin. For “non-simple” requests (custom headers, methods like PUT/DELETE, JSON bodies), the browser first sends a preflight OPTIONS request asking permission; only if the server’s headers approve does the real request go through.
Cookies add a wrinkle: cross-origin fetch doesn’t send credentials unless you set credentials: "include" and the server returns Access-Control-Allow-Credentials: true with a specific (non-wildcard) origin. fetch is the modern promise-based API that replaced the callback-based XMLHttpRequest (XHR).
// CLIENT — on https://app.com, calling a different origin
const res = await fetch("https://api.other.com/me", {
credentials: "include", // send cookies cross-origin
headers: { "X-Token": "abc" }, // a custom header → triggers a preflight OPTIONS
});# SERVER must answer the preflight AND the real request with matching headers:
Access-Control-Allow-Origin: https://app.com # exact origin, NOT * when credentialed
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: X-TokenIf any header is missing or Allow-Origin is * while credentials are included, the browser throws a CORS error and your await rejects — even though the server already ran the handler.
SPA vs MPA vs SSR vs SSG
The central frontend architecture decision: where and when does HTML get generated? All four are valid; the choice trades off time-to-first-content, interactivity, SEO, and server cost.
| Approach | HTML built… | Navigation | Strengths | Weaknesses |
|---|---|---|---|---|
| MPA (multi-page) | on the server, per request (traditional) | full page reload per link | simple, SEO-friendly, fast first paint | full reloads feel slower; repeated server work |
| SPA (single-page) | in the browser, from JS after load | client-side routing, no reload | app-like, snappy after load | slow first paint, weaker SEO, big JS bundle |
| SSR (server-side render) | on the server per request, then hydrated | client-side after first load | fast first paint and SEO and interactivity | server cost; hydration adds complexity |
| SSG (static-site gen) | at build time, served as static files | client-side after first load | fastest, cheapest, CDN-cacheable | content is stale until the next rebuild |
The shorthand: SSG when content is mostly static (blogs, docs, marketing). SSR when you need fresh, per-request content and SEO and fast first paint (e-commerce, dashboards behind auth). SPA for highly interactive app-like tools where SEO doesn’t matter (internal dashboards). Modern meta-frameworks (Next.js, Astro, Remix, SvelteKit) blur the lines — they let you pick per-route.
Component architecture and the virtual DOM
React, Vue, and Angular all exist to solve the same two problems that raw DOM manipulation makes painful: keeping the UI in sync with state, and reusing UI. The shared model:
- Components. Self-contained, reusable units pairing markup, state, and behavior. UI becomes a tree of components, each owning a slice of state.
- Declarative rendering. You describe what the UI should look like for a given state; the framework figures out how to update the DOM. You stop writing imperative
element.appendChildplumbing. - State management. Local component state covers most needs; when state must be shared across distant components, a store (Redux, Zustand, Pinia, or React Context) holds it centrally to avoid “prop drilling.”
The virtual DOM is React’s strategy for making declarative rendering fast. Re-rendering naively would mean rebuilding the entire page on every state change. Instead the framework builds a lightweight in-memory tree, diffs it against the previous version (“reconciliation”), and applies only the minimal set of real-DOM mutations. Real DOM operations are the expensive part (they reflow); diffing cheap JS objects first minimizes them.
Bundlers and transpilers
Browsers can’t run TypeScript, JSX, or hundreds of separate import files efficiently. The build toolchain bridges the gap, and each tool solves a distinct problem:
| Tool | Category | Problem it solves |
|---|---|---|
| Babel | transpiler | converts modern JS / JSX / TS into syntax older browsers understand |
| Webpack | bundler | walks the import graph and packs many modules into few optimized files; mature, config-heavy |
| Vite | bundler / dev server | instant dev startup via native ES modules + esbuild; bundles with Rollup for production |
A bundler resolves your module graph, removes dead code (tree-shaking), minifies, and emits a handful of files instead of hundreds of network requests. A transpiler translates newer or non-standard syntax (JSX, TypeScript, ESNext) down to JavaScript the browser runs. Vite won the dev-experience battle by serving source as native ES modules in development (near-instant startup) and only bundling for production.
Core Web Vitals and accessibility
Two baselines every production page should meet.
Core Web Vitals are Google’s user-centric performance metrics:
| Metric | Measures | Good | Driven by |
|---|---|---|---|
| LCP — Largest Contentful Paint | loading: when the main content appears | ≤ 2.5s | slow server, render-blocking resources, large images |
| INP — Interaction to Next Paint | responsiveness: lag from interaction to UI update | ≤ 200ms | long JS tasks blocking the main thread |
| CLS — Cumulative Layout Shift | visual stability: unexpected layout jumps | ≤ 0.1 | images/ads without reserved dimensions |
Accessibility (a11y) basics — the high-leverage essentials:
- Semantic HTML first — real
<button>,<nav>,<main>,<label>give correct roles, keyboard support, and screen-reader landmarks for free. alttext on meaningful images; emptyalt=""for purely decorative ones so screen readers skip them.- ARIA only to fill gaps —
aria-label,role,aria-livewhen no native element fits. The first rule of ARIA is don’t use ARIA if a native element works; wrong ARIA is worse than none. - Keyboard navigation — every interactive element must be reachable and operable by keyboard, with a visible focus indicator. Don’t strip
:focusoutlines without a replacement. - Color contrast — text needs ~4.5:1 contrast against its background (WCAG AA).
01 Learning objectives
0 / 5 done02 Curated reading
03 Knowledge check
- 01easy
CORS exists to:
- 02medium
Needs fast first paint, SEO, and per-request personalization. Best rendering approach?
04 Interview questions
browse all ↗What gets asked on this topic — tap a card for how to approach it, the follow-ups, and the trap. Company tags are best-effort & sourced.
-
What is the same-origin policy, and what problem does CORS solve?
The same-origin policy stops a page on origin A (scheme + host + port) from reading responses from origin B by default — it limits how a document loaded from one origin can interact with a resource from another, which protects user credentials.
CORS is the server's controlled opt-in: the server returns headers like
Access-Control-Allow-Origintelling the browser it is allowed to expose the response to that origin. CORS does not turn off security — it lets a server selectively relax the same-origin policy for trusted callers.Follow-ups they push on- What exactly counts as 'same origin'?
- Is CORS enforced by the browser or the server?
Red flag Saying CORS is a thing the client enables to bypass security. It is a server opt-in; the browser enforces it.
source: MDN — Cross-Origin Resource Sharing (CORS) ↗ -
What is a CORS preflight request, and what triggers one versus a 'simple' request?
A preflight is an automatic
OPTIONSrequest the browser sends before the real request to ask the server whether the actual call is allowed. It carriesOrigin,Access-Control-Request-Method, andAccess-Control-Request-Headers.It is triggered by non-simple requests: methods other than GET/HEAD/POST, custom headers, or a
Content-Typeoutsideapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain(e.g.application/json).A simple request skips preflight — but the server still must return
Access-Control-Allow-Originfor the script to read the response.Follow-ups they push on- Why does sending Content-Type: application/json trigger a preflight?
- How can Access-Control-Max-Age reduce preflight overhead?
Red flag Thinking simple requests need no CORS headers — they still need Access-Control-Allow-Origin to be readable. Forgetting application/json forces a preflight.
source: MDN — Preflight request ↗ -
Compare SPA, MPA, SSR, and SSG, and the tradeoffs of each.
MPA: server sends a full HTML page per navigation. Simple, great for content sites; full reloads between pages.
SPA: one HTML shell, JS renders routes client-side. Fast in-app navigation; weak initial load and SEO, needs JS to render anything.
SSR: server renders HTML per request, then hydrates on the client. Good first paint and SEO with dynamic data; higher server cost and TTFB.
SSG: HTML built at deploy time, served from a CDN. Fastest and cheapest; only fits content that does not change per request (or use ISR to revalidate).
Follow-ups they push on- What is hydration and why can it be costly?
- Where does ISR (incremental static regeneration) fit between SSR and SSG?
Red flag Conflating SSR with SSG (request-time vs build-time), or claiming SPAs are inherently bad for SEO without mentioning SSR/prerendering as the fix.
source: web.dev — Rendering on the web ↗ -
What is the virtual DOM, and is it actually faster than direct DOM manipulation?
The virtual DOM is an in-memory tree of lightweight JS objects describing the UI. On a state change, the framework builds a new tree, diffs it against the previous one (reconciliation), and applies the minimal set of real DOM mutations.
It is not magically faster than hand-optimized direct DOM writes — diffing has its own cost. Its value is a declarative model: you describe the target UI and let the framework batch updates and avoid redundant reflows, which is faster than naive re-rendering and far easier to reason about than manual surgery.
Follow-ups they push on- Why do React lists need stable keys during reconciliation?
- How do fine-grained reactive frameworks (Solid/Svelte) avoid a VDOM entirely?
Red flag Asserting the virtual DOM is always faster than direct manipulation. The real win is the declarative programming model plus batched updates.
source: React docs — Preserving and Resetting State (reconciliation) ↗ -
What problem do bundlers and transpilers solve? Distinguish Webpack/Vite from Babel.
A bundler (Webpack, Vite, esbuild, Rollup) builds a dependency graph from your modules and produces a few optimized files — handling code-splitting, tree-shaking, asset imports, and minification. It solves 'too many modules and too many requests' and lets the browser load less.
A transpiler (Babel, the TS compiler, SWC) converts source into a form browsers/runtimes accept: modern JS → older JS, JSX →
createElementcalls, TypeScript → JS.They complement each other: a bundler usually runs a transpiler step. Vite additionally serves native ES modules in dev for instant startup.
Follow-ups they push on- What is tree-shaking and what does it require to work (ESM, side-effect-free)?
- Why is Vite's dev server fast compared to a classic Webpack dev build?
Red flag Treating bundler and transpiler as synonyms. Babel transforms syntax; Webpack/Vite assemble and optimize the module graph.
source: Vite — Why Vite ↗ -
What are the Core Web Vitals, and what does each measure?
Three field metrics for real-user experience, judged at the 75th percentile:
- LCP (Largest Contentful Paint) — loading; time for the largest content element to render. Good ≤ 2.5s.
- INP (Interaction to Next Paint) — responsiveness; the latency of interactions. Good ≤ 200ms. INP became a stable Core Web Vital in 2024, replacing FID.
- CLS (Cumulative Layout Shift) — visual stability; how much content unexpectedly shifts. Good ≤ 0.1.Typical fixes: optimize the LCP image / preload it; cut long tasks for INP; reserve space (width/height, aspect-ratio) for CLS.
Follow-ups they push on- Why did INP replace FID?
- What causes layout shift and how do you prevent it (dimensions, font swap)?
Red flag Citing FID as a current Core Web Vital (it was replaced by INP in 2024), or mixing up which metric covers loading vs responsiveness vs stability.
source: web.dev — Web Vitals ↗ -
What are the essentials of web accessibility (a11y) a frontend engineer must get right?
Start with semantic HTML — native
<button>,<a>,<label>, headings, and landmark elements give you roles, focus, and keyboard behavior for free.Key practices: meaningful
alttext on images (emptyalt=""for decorative ones); every form control associated with a<label>; full keyboard navigation with a visible focus indicator and logical tab order; sufficient color contrast; and ARIA only to fill gaps semantics cannot cover (custom widgets) — never to paper over a non-semantic<div>.Test with keyboard-only, a screen reader, and automated tools (axe/Lighthouse).
Follow-ups they push on- What does 'the first rule of ARIA' (don't use ARIA if a native element exists) mean?
- How do you make a custom dropdown keyboard-accessible?
Red flag Reaching for ARIA first instead of semantic HTML, removing focus outlines without a replacement, or treating alt text as optional.
source: MDN — What is accessibility? ↗ -
Compare cookies, localStorage, and sessionStorage for storing data in the browser.
Cookies (~4KB) are sent to the server with every matching request. Best for auth/session tokens, ideally
HttpOnly(JS cannot read them, mitigating XSS theft),Secure, andSameSite.localStorage (~5–10MB) is JS-only, persists until cleared, and is not sent to the server. Good for non-sensitive client state. Vulnerable to XSS, so never store tokens that must stay secret.
sessionStorage is like localStorage but scoped to a single tab and cleared when it closes.
Follow-ups they push on- Why store auth tokens in HttpOnly cookies rather than localStorage?
- What does SameSite do for CSRF protection?
Red flag Recommending localStorage for auth tokens (readable by any XSS), or thinking localStorage is sent to the server like cookies.
source: MDN — Web Storage API ↗ -
What is the difference between fetch and XMLHttpRequest, and does fetch reject on a 404?
fetchis the modern Promise-based API;XMLHttpRequestis the older event/callback-based one.fetchis cleaner, streams responses, and integrates withAbortControllerfor cancellation.Key gotcha:
fetchonly rejects on network failure, not on HTTP error status. A404or500still resolves — you must checkresponse.ok(orresponse.status) yourself and throw if it is false.response.json()returns a Promise, so you await it twice (the response, then the body).Follow-ups they push on- How do you cancel a fetch request?
- Does fetch send cookies by default cross-origin (credentials)?
Red flag Assuming a 404 lands in the catch block. It does not — fetch resolves; only network errors reject. Forgetting to check response.ok.
source: MDN — Using the Fetch API ↗ -
What is hydration in SSR, and why can it be costly? What problems does it cause?
Hydration is the client-side step where a framework takes server-rendered HTML and attaches event listeners and reconstructs component state, making the static markup interactive. The server sends visible HTML fast (good first paint), then the browser must download the JS, re-run the components, and 'wire up' the existing DOM.
It's costly because you effectively render twice — once on the server, once on the client — and the page can look ready but not respond to clicks until hydration finishes (the 'uncanny valley' / poor INP).
Mitigations: less client JS, partial/progressive hydration, islands architecture (hydrate only interactive bits), streaming SSR, and server components that never ship to the client.
What a strong answer coversHydration = attaching listeners/state to server-rendered HTML to make it interactive.
Work is duplicated: render on server, then re-render/wire-up on client.
Page can appear ready but be unresponsive until hydration completes (hurts INP).
Fixes: islands, partial/progressive hydration, streaming, server components.
Follow-ups they push on- How does an islands architecture (e.g. Astro) reduce hydration cost?
- What is the 'uncanny valley' of a hydrating page?
Red flag Thinking SSR alone makes a page interactive. The HTML is visible immediately, but interactivity waits for hydration.
source: web.dev — Rendering on the web (hydration) ↗ -
What is tree-shaking, and what does your code need to do for it to work?
Tree-shaking is dead-code elimination at the module level: the bundler keeps only the exports you actually import and drops the rest, shrinking the bundle.
It relies on ES modules' static structure —
import/exportare statically analyzable, so the bundler can trace which exports are used. CommonJS (require) is dynamic and resists shaking.For it to work well: use ESM, import named members (not the whole namespace), avoid modules with side effects at import time, and mark packages
"sideEffects": falseinpackage.jsonso the bundler can safely prune. A stray top-level side effect can force a whole module to be kept.What a strong answer coversTree-shaking removes unused exports to reduce bundle size.
Requires static ESM
import/export; CommonJSrequireis too dynamic.Side-effectful modules can't be safely dropped;
"sideEffects": falsesignals safety.Import named members, not
import * as everything.
Follow-ups they push on- Why can CommonJS modules not be tree-shaken reliably?
- What does the package.json "sideEffects" field do?
Red flag Assuming any unused import is automatically dropped. Side effects, CommonJS, or namespace imports can defeat tree-shaking.
source: MDN — Tree shaking ↗ -
What is code-splitting and lazy loading, and how do they improve load performance?
Code-splitting breaks one large bundle into smaller chunks that can be loaded on demand instead of all upfront. Lazy loading is fetching a chunk only when it's actually needed — typically via the dynamic
import()expression, which returns a Promise and tells the bundler to emit a separate chunk.The payoff is a smaller initial bundle: less JS to download, parse, and execute before the page is interactive, which improves load time and INP. Common split points: per-route (load a route's code on navigation) and per-component (a heavy modal/chart loaded on first interaction).
React pairs
React.lazy(() => import('./X'))with<Suspense>for a fallback while the chunk loads.What a strong answer coversCode-splitting = multiple chunks; lazy loading = fetch a chunk on demand.
Dynamic
import()returns a Promise and creates a separate bundle chunk.Shrinks the initial bundle → faster parse/execute → better TTI/INP.
Split by route and by heavy on-interaction components.
Follow-ups they push on- How does React.lazy + Suspense work together?
- What's the risk of splitting too aggressively (many tiny chunks / waterfalls)?
Red flag Lazy-loading everything (request waterfalls, layout shift on load), or splitting code that's needed for first paint and delaying it.
source: MDN — JavaScript modules (dynamic import) ↗ -
What causes Cumulative Layout Shift (CLS), and how do you prevent it?
CLS measures unexpected movement of visible content during loading — content jumping as late-arriving elements push things around. Good is ≤ 0.1 at the 75th percentile.
Common causes: images/videos/ads without reserved space; web fonts swapping in and reflowing text (FOUT); content injected above existing content; and animating layout properties.
Fixes: always set
width/height(oraspect-ratio) on media so the browser reserves the box; reserve space for ads/embeds; usefont-display: optional/swapplus size-matched fallbacks to minimize font reflow; and never insert content above what the user is viewing unless in response to an interaction.What a strong answer coversCLS = sum of unexpected layout shifts; target ≤ 0.1 (p75).
Top cause: media without dimensions — set
width/heightoraspect-ratio.Reserve space for ads/embeds and avoid injecting content above the fold.
Tame font swap (FOUT) with
font-displayand metric-matched fallbacks.
Follow-ups they push on- Why does specifying width and height on an <img> prevent shift even before it loads?
- How can web fonts cause layout shift, and how do you reduce it?
Red flag Omitting image dimensions (relying on CSS alone) so the browser can't reserve space, or inserting banners above current content after load.
source: web.dev — Cumulative Layout Shift (CLS) ↗ -
What is XSS, what are the main types, and how do you defend against it?
Cross-site scripting (XSS) is injecting attacker-controlled script that runs in a victim's page with the site's privileges (reading cookies, DOM, making requests as the user).
Types: stored (malicious input saved server-side and served to others), reflected (script bounced off a request like a search param), and DOM-based (client JS writes untrusted data into the DOM, e.g. via
innerHTML).Defenses: contextual output encoding/escaping (treat data as data, not markup); avoid
innerHTML/dangerouslySetInnerHTMLwith untrusted input — usetextContent; sanitize rich HTML with a vetted library (DOMPurify); set a strong Content-Security-Policy; and mark session cookiesHttpOnlyso injected JS can't read them.What a strong answer coversXSS runs attacker script in the user's session context.
Three types: stored, reflected, DOM-based.
Primary defense: contextual output encoding; prefer
textContentoverinnerHTML.Layer with CSP, HTML sanitization (DOMPurify), and HttpOnly cookies.
Follow-ups they push on- Why does an HttpOnly cookie limit the damage of an XSS?
- How does a Content-Security-Policy mitigate XSS?
Red flag Treating input validation as sufficient. The core fix is output encoding for the right context; CSP and sanitization are defense-in-depth, not a single switch.
source: OWASP — Cross Site Scripting (XSS) ↗ -
What is CSRF, and how is it different from XSS? How do you defend against it?
CSRF (cross-site request forgery) tricks a logged-in user's browser into sending an unwanted state-changing request to your site. It exploits the fact that browsers attach cookies automatically, so a forged request from another site rides the victim's session.
Key difference from XSS: XSS is a *code injection* (attacker runs script in your page); CSRF is a *request forgery* that needs no script on your page — it abuses ambient cookie auth. XSS can defeat most CSRF defenses, so fixing XSS comes first.
Defenses:
SameSitecookies (Lax/Strict) so cookies aren't sent on cross-site requests; anti-CSRF tokens (a per-session secret the attacker can't read); and checkingOrigin/Referer. Avoid using GET for state changes.What a strong answer coversCSRF: forged state-changing request riding the victim's auto-sent cookies.
XSS injects script; CSRF forges a request and needs no script on your page.
Defenses:
SameSitecookies, anti-CSRF tokens, Origin/Referer checks.Never perform state changes on GET; XSS can bypass CSRF tokens, so fix XSS too.
Follow-ups they push on- How does SameSite=Lax block a typical CSRF attack?
- Why can an XSS vulnerability defeat anti-CSRF tokens?
Red flag Conflating CSRF with XSS, or thinking a CSRF token alone is enough when an XSS hole can simply read it.
source: OWASP — Cross Site Request Forgery (CSRF) ↗ -
How does HTTP caching work for assets? Explain Cache-Control, ETags, and cache busting.
Cache-Control is the primary header.
max-age=Nlets the browser use a cached copy without revalidating for N seconds;no-cachemeans 'cache it but revalidate before use';no-storemeans never cache;immutablepromises the file won't change.ETags enable conditional revalidation: the server sends a content hash; the browser later sends
If-None-Match, and the server returns a tiny304 Not Modifiedif unchanged — saving the payload but not the round-trip.Cache busting combines both worlds: give bundled assets a content hash in the filename (
app.a1b2c3.js) and serve them withmax-age=31536000, immutable. When content changes, the filename changes, so you cache forever yet always serve fresh files. Keep the HTML entry point short-lived.What a strong answer coversCache-Control: max-ageskips revalidation;no-cacherevalidates;no-storenever caches.ETag +
If-None-Match→304 Not Modifiedavoids re-downloading unchanged bytes.Cache busting: content-hashed filenames served
immutablelong-lived.Hash the assets, keep the HTML short-lived so new asset URLs are discovered.
Follow-ups they push on- Why is no-cache not the same as no-store?
- Why hash filenames instead of just lowering max-age?
Red flag Thinking `no-cache` means 'don't cache' (it means revalidate), or setting long max-age on un-hashed filenames so users get stale files.
source: MDN — HTTP caching ↗ -
How would you optimize the Largest Contentful Paint (LCP) of a page?
LCP marks when the largest in-viewport element (often a hero image or headline block) renders; good is ≤ 2.5s at p75. Optimize the four phases of its timeline:
- TTFB: fast server / CDN, cache HTML, reduce redirects.
- Resource load delay: make the LCP image discoverable early — put it in the markup (not JS-injected),fetchpriority="high",<link rel="preload">, and *don't* lazy-load it.
- Resource load time: serve a right-sized, modern-format (AVIF/WebP), compressed image over a fast connection.
- Render delay: cut render-blocking CSS/JS so the element can paint.The single biggest lever is usually ensuring the LCP image is requested as early as possible and not deferred.
What a strong answer coversLCP = render time of the largest viewport element; good ≤ 2.5s (p75).
Break it into TTFB, load delay, load time, render delay and attack each.
Don't lazy-load the LCP image; make it discoverable early (
fetchpriority, preload, in-markup).Serve right-sized modern-format images; reduce render-blocking resources.
Follow-ups they push on- Why is lazy-loading the hero image an anti-pattern for LCP?
- How does fetchpriority="high" change the request ordering?
Red flag Lazy-loading or JS-injecting the hero image (the preload scanner can't find it early), or optimizing total page weight while ignoring the LCP element's own request timing.
source: web.dev — Optimize Largest Contentful Paint ↗