56 min read

The Frontend Engineer's First Principles

A Causal Reasoning Curriculum — From Browser Internals to Production Systems

"You don't understand something until you can derive it from scratch." This book is not about React. It is not about Next.js. It is not about Tailwind. It is about building the causal reasoning engine in your head so that any framework, any technology, any system — you can look at it and say: I know WHY this exists, WHAT constraint it solves, WHEN it breaks, and WHAT I would have built instead. Claude codes. You think. That's the division of labour in the AI era.


How to Read This Curriculum

Every chapter follows the same causal structure:

  1. The Problem — What existed before this technology/pattern? What was painful?
  2. The Constraint — What physical, architectural, or systemic reality forces this?
  3. The Derivation — If you had to invent the solution from scratch, you'd arrive here.
  4. The Tradeoffs — What does this solution cost? What does it not solve?
  5. The Failure Mode — When does this break? How do you diagnose it?
  6. The Mental Model — One sentence you carry forever.

Do not skip chapters. Each one is a floor that the next one stands on.


Book 0: The Ground Rules — How to Think Like This

Chapter 0: The Constraint-First Mindset

Every technology exists because of a constraint. Not because someone was clever. Because something was physically, mathematically, or architecturally impossible without it. React exists because mutating the DOM directly at scale is unmaintainable. HTTP/2 exists because HTTP/1.1's head-of-line blocking was killing latency. Code splitting exists because the browser parses and executes JS before rendering anything.

Before you learn any technology, ask: what painful constraint does this solve? If you can't answer that, you don't understand the technology — you've just memorised it. Memorisation is replaceable. Derivation is not.

The mental model: Every abstraction is a response to a constraint. Find the constraint, and the abstraction becomes obvious.

Chapter 1: The Stack of Causality

Frontend knowledge is layered. Layer N exists because of a problem that emerged in Layer N-1. The browser's rendering pipeline dictates what JavaScript can and cannot do. JavaScript's execution model dictates why frameworks exist. Framework architecture dictates what state management you need. State management dictates your data fetching strategy. Data fetching dictates your performance profile. Performance profile dictates your Web Vitals score.

If you learn these layers in reverse — starting with React, going backwards — you will forever be copying patterns without knowing why. This curriculum goes bottom-up. Browser first. Pixels first. Constraints first.

The stack:

code
Layer 0: Physics (network latency, CPU/GPU, memory)
Layer 1: Browser Machine (rendering engine, JS engine, compositor)
Layer 2: Document Model (DOM, CSSOM, layout, paint)
Layer 3: JavaScript Runtime (event loop, heap, GC)
Layer 4: Framework Abstractions (React, VDOM, reconciliation)
Layer 5: Application Architecture (state, routing, data fetching)
Layer 6: Performance Engineering (Web Vitals, optimisation)
Layer 7: Real-Time Systems (WebSockets, SSE, streaming)
Layer 8: Data-Heavy Rendering (virtualisation, Canvas, WebGL)
Layer 9: System Design (architecture from blank page)
Layer 10: AI-Era Patterns (streaming UX, non-deterministic output)

Each layer is a chapter group. Each group has chapters that build causally on each other.


Book 1: The Browser as a Machine

The single biggest mistake engineers make: treating the browser as a black box. You type JSX, something renders. The browser is not a black box. It is a specific machine with a specific architecture that makes specific tradeoffs. Until you understand that machine, every performance problem you encounter will feel like magic.

Chapter 2: What the Browser Actually Is

A browser is five things working in concert: a networking layer (fetches resources), a HTML parser (builds a tree from markup), a CSS engine (resolves styles and builds a second tree), a JavaScript engine (executes your code), and a rendering engine (turns the trees into pixels using the GPU). These five things are not independent. They block each other in specific, predictable ways. Understanding which blocks which, and why, is the single most important thing you can know about frontend performance.

The rendering engine sits on top of the GPU but orchestrates work on the CPU. It has two threads that matter: the main thread (runs JS, style calculation, layout, and paint) and the compositor thread (assembles layers and sends them to the GPU). Every performance technique you will ever learn is either: (a) moving work off the main thread, (b) reducing the work on the main thread, or (c) reducing the number of times the compositor has to re-render.

Failure mode: Treating "slow page" as a generic problem. Once you know the browser's architecture, "slow" has a specific cause — is it network? JS execution? Layout thrashing? Paint storms? Slow pages are diagnosable. You need to know the machine to diagnose it.

Mental model: The browser is a pipeline. Identify which stage is the bottleneck.

Chapter 3: The Critical Rendering Path — How HTML Becomes Pixels

Type a URL. A sequence of exactly 7 causally linked events happens before you see a pixel:

  1. DNS resolution — domain → IP address (network round trip)
  2. TCP handshake — establish connection (another RTT)
  3. TLS negotiation — if HTTPS, another 1-2 RTTs (this is why HTTP/3 exists)
  4. HTTP request/response — HTML bytes arrive
  5. HTML parsing → DOM construction — the parser reads bytes sequentially and builds a tree. It pauses whenever it encounters a <script> tag. This is not a bug. This is the design.
  6. CSS parsing → CSSOM construction — happens in parallel with HTML parsing, but the browser will not render anything until CSSOM is complete. CSS is render-blocking. This is not a bug either — it would be visually broken to render content before styles are computed.
  7. DOM + CSSOM → Render Tree → Layout → Paint → Composite

The word "critical" means: these are the steps you cannot skip. You can optimise each one. You cannot eliminate any.

Why does a <script> tag pause HTML parsing? Because JavaScript can call document.write() which inserts HTML, which invalidates the parser's current position. The browser has no choice — it must execute JS before continuing to parse. This is why <script> tags belong at the bottom of <body> or have async/defer.

Why is CSS render-blocking but not parser-blocking? Because CSS does not modify the DOM structure, only its presentation. The parser can keep building the DOM. But the browser cannot display anything until it knows the final computed style of every element.

The causal chain:

code
HTML bytes arrive
  → Parser builds DOM incrementally
    → CSS found → CSSOM built (blocks render, not parse)
    → Script found → Parser pauses → JS executes → Parser resumes
      → DOM + CSSOM complete
        → Render Tree computed (only visible nodes)
          → Layout (calculate position/size of every element)
            → Paint (fill pixels, per layer)
              → Composite (GPU assembles layers → screen)

Failure mode: Putting large CSS files in <link> without preloading. Putting scripts in <head> without defer. Both delay the first pixel by hundreds of milliseconds on slow networks.

Mental model: The CRP is a waterfall. Anything that blocks an upstream stage blocks every downstream stage.

Chapter 4: The Main Thread — The Most Valuable Resource in Frontend

The main thread is one. It does everything: parse HTML, calculate CSS, execute JavaScript, perform layout, paint. Everything you have ever written in React runs on this thread. If you block it, the page freezes. The user cannot scroll. Cannot click. Cannot type.

The browser needs to render at 60fps to feel smooth. That means it has 16.67 milliseconds per frame. In that 16.67ms, it must: receive input events, execute any JS handlers, run any scheduled microtasks/macrotasks, recalculate styles (if anything changed), perform layout (if sizes/positions changed), paint (if appearance changed), and composite.

If your JavaScript takes 50ms to run, the browser misses 3 frames. The user sees a stutter. This is called a long task — any task that blocks the main thread for more than 50ms. Chrome DevTools will flag these.

Why is JS single-threaded? Because the DOM is mutable. If two threads could mutate the DOM simultaneously, you would need locking, which would make JS enormously complex and prone to deadlocks. The browser's designers made a deliberate choice: one thread, no locks, deterministic execution order. You trade parallelism for simplicity.

What you can do:

  • Move non-UI work to Web Workers (separate thread, no DOM access)
  • Break long tasks into smaller chunks using scheduler.yield() or requestIdleCallback()
  • Avoid synchronous operations that trigger layout (see Chapter 14)
  • Minimise JS bundle size (less to parse = less blocking time at startup)

Mental model: The main thread is a single-lane road. Your job is to keep traffic moving. Any work you do is traffic. Make it fast, or move it to a different road (Web Worker).

Chapter 5: The Event Loop — Why JavaScript Behaves the Way It Does

JavaScript executes inside a loop. The loop has three queues:

  1. Call Stack — currently executing synchronous code
  2. Microtask Queue — Promise callbacks, queueMicrotask() — runs after every task, before the browser renders
  3. Macrotask Queue (Task Queue) — setTimeout, setInterval, I/O callbacks, user events

The loop's algorithm is precise:

  1. Execute the current task from the call stack until it is empty
  2. Execute ALL microtasks (the entire queue, including any new ones added during execution)
  3. Optionally render (if 16.67ms has passed and there are visual changes)
  4. Pick the next macrotask and go to step 1

Why does setTimeout(fn, 0) not run immediately? Because it goes into the macrotask queue. The current task must complete, then all microtasks must drain, then the browser can render, then your setTimeout callback runs. "0ms delay" means "after the current task and all microtasks, at minimum."

Why do Promises resolve before setTimeout? Because Promise callbacks are microtasks. Microtasks drain completely before the next macrotask. A Promise.resolve().then(fn) will always run before a setTimeout(fn, 0).

Why does this matter for React? React's setState batches updates within the same event handler (since React 18, it batches across all async contexts too). The batching mechanism leverages the microtask queue. Understanding this tells you why state updates don't immediately reflect in the DOM — they're scheduled.

Failure mode: Recursively resolving Promises in a tight loop. You will starve the rendering step because microtasks never drain. The browser never gets to paint. The page appears frozen even though JS is running.

Mental model: The event loop is a clock. Microtasks are urgent interrupts. Macrotasks are scheduled work. The render step happens between macrotasks. Block the call stack → block everything.

Chapter 6: Memory in the Browser — Heap, Stack, GC, and Leaks

JavaScript memory lives in two places: the stack (primitive values, function call frames — fixed-size, fast, automatically managed) and the heap (objects, arrays, closures, DOM references — dynamic-size, managed by the garbage collector).

The garbage collector (GC) in V8 uses mark-and-sweep with generational collection: it assumes objects created recently are likely to die soon (the generational hypothesis). New objects go into the "young generation" (collected frequently, quickly). Surviving objects are promoted to the "old generation" (collected infrequently, expensively). This is why allocating many short-lived objects is cheaper than allocating a few long-lived ones.

Memory leaks in SPAs are a specific problem. In a traditional multi-page app, every navigation destroys the entire JS context. Memory is freed. In a SPA, the JS context lives forever. A reference that holds a DOM node, an event listener, a timer, or a closure in scope — if not cleaned up — will never be garbage collected. The memory grows. The page gets slower. On mobile devices, the browser eventually crashes the tab.

Common SPA memory leak patterns:

  • Forgotten event listeners — addEventListener on document or window inside a component, never removed on unmount
  • Timers not cleared — setInterval started on mount, never clearInterval'd on unmount
  • Closure over large objects — a callback closed over a large array, registered somewhere global, never released
  • Detached DOM nodes — a DOM element removed from the tree but still referenced in a JS variable

Mental model: In a SPA, you are responsible for your own cleanup. The browser will not do it for you.


Book 2: The Network Is Not Your Friend

Chapter 7: What Happens When You Type a URL

This is not a trivial question. The full causal chain:

  1. Browser checks cache (memory cache → disk cache → Service Worker cache)
  2. DNS lookup — resolves example.com to 93.184.216.34. This involves: browser DNS cache → OS DNS cache → resolver → root nameserver → TLD nameserver → authoritative nameserver. Each uncached lookup adds ~20-120ms.
  3. TCP connection — 3-way handshake (SYN → SYN-ACK → ACK). One full round-trip before any data. This is ~30ms on a good connection, 300ms on mobile 3G.
  4. TLS handshake — for HTTPS, adds 1-2 more round-trips for certificate exchange and key negotiation. This is why HTTPS used to be "slow." TLS 1.3 and QUIC (HTTP/3) reduced this to 0 additional RTTs.
  5. HTTP request — the actual GET request is sent. The server processes and responds.
  6. First byte — the browser receives the first byte of the response (TTFB — Time to First Byte). This is your server's responsibility.
  7. Response streaming — bytes arrive in packets. The browser starts parsing before the response is complete.
  8. CRP begins — as described in Chapter 3.

The insight: most of a page load's time is spent in network operations, not in JS execution. A 3G connection might have 300ms RTT. Three round-trips (DNS + TCP + TLS) = 900ms before a single byte of HTML arrives. The browser has done nothing wrong. The physics of light and copper are the constraint.

This is why:

  • DNS prefetching (<link rel="dns-prefetch">) matters
  • Preconnecting (<link rel="preconnect">) to known origins matters
  • HTTP/2 connection reuse matters
  • CDNs (reducing physical distance = reducing RTT) matter
  • Caching aggressively (avoiding the network entirely) is the best optimisation

Mental model: Optimising frontend performance without understanding the network is like optimising the last mile of a marathon. The earlier miles are where the time is.

Chapter 8: HTTP/1.1, HTTP/2, and HTTP/3 — Why Each Version Exists

HTTP/1.1 (1997): One request per TCP connection at a time. Browsers worked around this by opening 6 parallel connections per domain. Loading 30 resources meant waiting in queues. Head-of-line blocking: if the first request stalls, every other request on that connection waits. The workarounds (domain sharding, CSS sprites, inlining, bundling) were all responses to this one constraint.

HTTP/2 (2015): Binary framing instead of text. Multiplexing — multiple requests on one TCP connection, interleaved. Header compression. Server push. The workarounds from HTTP/1.1 became counterproductive: domain sharding now hurts because you lose multiplexing. Large bundles are less necessary because loading many small files is efficient. HTTP/2 changed the optimal bundling strategy.

BUT: TCP head-of-line blocking remains. If a TCP packet is lost, every HTTP/2 stream on that connection stalls until retransmission. On lossy networks (mobile), HTTP/2 can be slower than HTTP/1.1.

HTTP/3 (2022): Replaces TCP with QUIC (built on UDP). Each stream is independent — a lost packet only blocks that stream. Zero-RTT connection establishment. Built-in TLS 1.3. On mobile networks with packet loss, HTTP/3 can be 2-3x faster than HTTP/2.

What this means for you as an engineer:

  • Are you still domain-sharding? Stop. That was an HTTP/1.1 workaround.
  • Are you bundling everything into one massive JS file to avoid requests? That was also an HTTP/1.1 workaround. With HTTP/2, many smaller files can be better.
  • Does your CDN support HTTP/3? It should.

Mental model: Every HTTP version was designed to solve the performance failure of the previous version. Know the failure mode, and the version's design becomes obvious.

Chapter 9: Caching — The Browser's Survival Mechanism

The fastest request is the one never made. Caching is not an optimisation — it is the fundamental mechanism that makes the web usable.

Browser cache layers (in order of checking):

  1. Memory cache — resources from the current page load, in RAM. Gone on tab close.
  2. Disk cache — persisted across sessions. Governed by HTTP headers.
  3. Service Worker cache — programmatic cache, fully controlled by your JS.
  4. HTTP cache (shared/CDN cache) — servers between you and the origin.

The headers that govern this:

  • Cache-Control: max-age=31536000, immutable — "Cache this for 1 year. It will never change." This is the correct header for hashed asset filenames (like main.abc123.js). The hash changes when the file changes, so the URL changes, so the browser fetches fresh. The old URL is cached forever.
  • Cache-Control: no-cache — "You can cache it, but validate with the server before using it." The browser sends a conditional request; the server responds with 304 Not Modified if unchanged. Saves the bandwidth, not the round-trip.
  • Cache-Control: no-store — "Never cache this." Use for sensitive data, personalised responses.
  • ETag / Last-Modified — validators. The server says "this version is abc123." Next time, the browser asks "do you still have version abc123?" Server says yes (304) or no (200 + new content).

Stale-While-Revalidate: Serve the stale cached version immediately, then revalidate in the background. The user gets instant response; the cache silently refreshes. This is the pattern React Query and SWR implement at the application layer.

Failure mode: Deploying a new index.html that references main.js — but main.js was cached by the browser for a year with a different hash. Users see a broken app. Solution: index.html should have Cache-Control: no-cache. Only hashed assets should have long-lived caching.

Mental model: Cache everything that can be cached. Make sure cache-busting is automatic (content hashing). Never cache HTML with long TTLs.

Chapter 10: CORS and the Same-Origin Policy — Why Browsers Are Paranoid

In 1995, browsers added the ability to execute JavaScript. In 1999, the XMLHttpRequest API was invented, allowing JS to make HTTP requests. Immediately, a security problem: if you are logged into bank.com, and you visit evil.com, can evil.com's JavaScript make a request to bank.com using your cookies? Without restrictions, yes.

The Same-Origin Policy (SOP): a web page's JavaScript can only make requests to the same origin (protocol + domain + port). Any request to a different origin is blocked by default. This is enforced by the browser, not the server.

CORS (Cross-Origin Resource Sharing): a mechanism for servers to say "I trust requests from these other origins." The server responds with Access-Control-Allow-Origin: https://yourfrontend.com and the browser allows the request.

The preflight: For "non-simple" requests (anything other than GET/HEAD/POST with simple headers), the browser first sends an OPTIONS request. "May I make a DELETE request to you from this origin?" The server responds with allowed methods and headers. Then the actual request is sent. This is a hidden extra round-trip you pay for any non-trivial API call.

Why CORS errors happen in development: Your frontend is on localhost:3000, your API is on localhost:4000. Different ports = different origins. Your API server must include CORS headers for localhost:3000. Production often works because both are on the same domain.

Why CORS is a browser policy: CORS errors only happen in browsers. curl, Postman, server-to-server calls — no CORS. Because CORS exists to protect users, not to protect servers.

Mental model: SOP is the browser's way of protecting your session cookies from hostile pages. CORS is the server's way of consciously opting out of that protection for trusted origins.


Book 3: From HTML to Pixels — The Rendering Pipeline

Chapter 11: The DOM — Why a Tree? What Does It Cost?

HTML is a tree by design. Nesting elements (<div> inside <section> inside <body>) represents parent-child relationships. The browser parses HTML and constructs the Document Object Model — a live, mutable, in-memory representation of the document as a tree of node objects.

Why a tree? Because trees are the natural data structure for hierarchical, nested data. Trees allow efficient traversal (find all elements with a class), efficient mutation (append a child), and efficient rendering (a subtree can be invalidated without affecting the rest).

What the DOM costs:

  • Each DOM node is a C++ object in the browser's memory. 1,000 nodes is fine. 100,000 nodes is a serious problem.
  • Querying the DOM (e.g., document.querySelectorAll('.item')) traverses the tree. On a large tree, this is expensive.
  • Mutating the DOM (e.g., element.appendChild(child)) is cheap — it's just a pointer operation. BUT it may trigger style recalculation, layout, and paint, which are expensive.
  • The DOM is the boundary between JavaScript and the rendering engine. Every time you read from or write to the DOM, you cross that boundary. Repeatedly crossing it in a loop is called layout thrashing (see Chapter 14).

Mental model: The DOM is a live data structure. Reading it forces the browser to ensure it's up to date. Writing to it may trigger expensive recalculations. Minimise DOM size. Batch DOM mutations.

Chapter 12: CSS — Why It Blocks, What It Triggers, How It Cascades

CSS is render-blocking. The browser will not display any content until it has fetched and parsed all stylesheets. This is by design: an unstyled flash is a worse user experience than a slightly delayed first paint.

The CSSOM (CSS Object Model): the browser parses all CSS rules and builds a tree parallel to the DOM. Each element's computed styles are determined by: the cascade (which rules apply), inheritance (which properties propagate down), and specificity (which rule wins when multiple apply).

What CSS properties cost:

Not all CSS is equal in rendering cost. The browser's rendering pipeline has three stages: layout (position and size), paint (fill pixels), and composite (layer assembly). Some properties trigger all three. Some only trigger paint. Some only trigger composite. The cost hierarchy:

code
Most expensive:       Layout → Paint → Composite
Medium:                         Paint → Composite
Cheapest:                                Composite

Layout-triggering properties (any change to size or position): width, height, margin, padding, top, left, font-size, display. Changing these forces the browser to recalculate the position of every affected element — potentially the entire page.

Paint-triggering properties (appearance without geometry change): color, background-color, box-shadow, border. Changing these forces the browser to repaint pixels, but not recalculate positions.

Composite-only properties (GPU-only transformations): transform, opacity. These never trigger layout or paint. The GPU moves or fades entire pre-painted layers. This is why animations using transform: translateX() and opacity are 60fps. Animations using left or width are not.

The will-change property: tells the browser "this element will animate." The browser promotes it to its own GPU layer in advance, so animation starts instantly. Use sparingly — each promoted layer uses GPU memory.

Mental model: If it changes geometry, it's expensive. If it only changes appearance, it's medium. If it's transform or opacity, it's free. Design your animations around this hierarchy.

Chapter 13: Layout, Paint, Composite — The Three Phases Decoded

Layout (Reflow): The browser calculates the geometry of every element on the page — its exact pixel position and size. This is the most expensive phase for complex pages. Any change to an element's size, position, font, or display property triggers a layout calculation. In the worst case (full page reflow), the browser must recalculate the geometry of every single element. This can take 10-50ms on complex pages.

Paint: The browser fills pixels for each layout layer. It doesn't paint the entire page at once — it paints in layers (like a stack of transparent sheets). Each layer is a rasterised bitmap sent to the GPU. Paint is expensive because it's pixel-by-pixel work done on the CPU.

Composite: The GPU takes the painted layers and assembles them into the final frame. GPU operations are massively parallel — this is fast. The compositor thread handles this independently from the main thread. This is why transform and opacity animations are smooth even when the main thread is busy: the compositor thread can keep compositing without waiting for JS.

Why layers exist: Separating content into GPU layers means you can move, scale, or fade one layer without repainting others. Scroll position updates move the viewport layer. A fixed header is its own layer that never moves. An animated modal is its own layer composited on top.

Mental model: Layout is geometry. Paint is pixels. Composite is GPU assembly. Reduce layout. Reduce paint. Move animations to composite.

Chapter 14: Layout Thrashing — The Most Common Performance Crime

Layout thrashing is reading and writing to the DOM alternately, in a loop, forcing the browser to perform synchronous layout calculations repeatedly.

Why? The browser batches DOM mutations and performs layout lazily (once per frame, not on every write). But: if you read a layout property (like element.offsetWidth) after a write (like element.style.width = '100px'), the browser is forced to flush its pending layout changes immediately, before it can give you the correct value. Repeat this in a loop and you get dozens of forced synchronous layouts per frame.

Example of thrashing:

javascript
// Bad: reads and writes interleaved
elements.forEach(el => {
  const width = el.offsetWidth;        // forces layout
  el.style.width = (width * 2) + 'px'; // marks layout dirty
});

// Good: batch reads, then batch writes
const widths = elements.map(el => el.offsetWidth); // reads
elements.forEach((el, i) => {
  el.style.width = (widths[i] * 2) + 'px';         // writes
});

This is the first-principles reason for React's virtual DOM: instead of letting you directly mutate the DOM in whatever order your code runs, React batches all mutations together and applies them in one pass, after calculating the minimal diff. The virtual DOM is not about performance in isolation. It is about making layout thrashing structurally impossible in application code.

How to diagnose: Chrome DevTools Performance tab. Look for alternating purple (layout) and green (paint) blocks in rapid succession. That's thrashing.

Mental model: The browser is trying to batch layout work. Don't force it to flush early. Read all the things, then write all the things. Never alternate.


Book 4: JavaScript's Architecture

Chapter 15: Why JavaScript is Single-Threaded (and What We Do About It)

The DOM is not thread-safe. If Thread A is computing layout while Thread B is inserting a node, the layout calculation is invalidated mid-execution. Thread-safe DOM access would require locking every read/write, making concurrent mutations deadlock-prone and development extremely complex.

The designers of JavaScript made a pragmatic decision: one thread, no locks, deterministic execution order. This guarantees that at any moment, only one piece of JS is modifying the DOM.

The cost: long-running computation blocks everything. A 500ms synchronous loop will freeze the page for 500ms. The user cannot scroll, click, or interact. The browser cannot paint. It's all waiting for your loop to finish.

What you can do:

  1. Web Workers: Run JavaScript on a separate thread, completely isolated from the main thread. No DOM access. Communication via postMessage (message passing, not shared memory by default). Use for CPU-heavy tasks: parsing large JSON, image processing, encryption, machine learning inference. React, Svelte, and Angular do their reconciliation on the main thread. Offloading it to a worker is an active research area (React's Concurrent Mode approaches this differently).

  2. scheduler.yield(): (newer browser API) Pause a long task, let the browser render/handle input, then resume. Break a 500ms loop into 50 × 10ms chunks with yields between them. The user sees progress and can interact between chunks.

  3. requestIdleCallback: Schedule non-urgent work during browser idle time. "Do this when you have nothing better to do." Analytics, prefetching, non-critical data processing.

  4. requestAnimationFrame: Schedule visual work to run at the start of the next frame. Never use setTimeout for animations. requestAnimationFrame is synchronised with the browser's paint cycle. setTimeout is not.

Mental model: Single-threaded is a feature, not a limitation, for DOM safety. But it requires you to be deliberate about where you schedule work.

Chapter 16: Async JavaScript — From Callbacks to Promises to async/await

The problem async JS solves: I/O operations (network, disk, timers) take time. If they were synchronous (blocking), the entire page would freeze while waiting for a fetch to complete. Async operations are non-blocking: you start the operation, give the browser a callback to call when it's done, and your code continues executing.

Callbacks (the original model): "When this is done, call this function."

javascript
fetch(url, function(response) {
  parseJSON(response, function(data) {
    renderUI(data, function(result) {
      // callback hell — 3 levels deep, every operation nested
    });
  });
});

Problem: error handling is duplicated at every level. Control flow is inverted — you don't call a function and get a value back; you give up control and wait to be called back. Composing multiple async operations is nightmarish.

Promises (2015): A value that will exist in the future. Operations return a Promise object immediately. You chain .then() and .catch() handlers. The chain is flat, not nested. Error propagation works correctly.

async/await (2017): Syntactic sugar over Promises. Write async code that looks synchronous. Under the hood, every await is a .then(). The function pauses at await, returns control to the event loop, and resumes when the Promise resolves.

javascript
async function loadData() {
  try {
    const response = await fetch(url);  // pause, resume when response arrives
    const data = await response.json(); // pause, resume when parsed
    renderUI(data);
  } catch (error) {
    handleError(error);
  }
}

The key mental model: await does not block the thread. It suspends the current async function, returns control to the event loop, and resumes the function when the awaited Promise resolves. Other code runs in the meantime.

Failure mode: await inside a loop where the operations are independent. Each iteration waits for the previous to complete. You've inadvertently made parallel work sequential.

javascript
// Bad: sequential (each waits for the previous)
for (const id of ids) {
  await fetchUser(id); // total time = N × latency
}

// Good: parallel
await Promise.all(ids.map(fetchUser)); // total time = max(latencies)

Mental model: Async/await makes async code readable. Promises make async code composable. The event loop makes async code non-blocking. All three work together.

Chapter 17: Web Workers — True Parallelism in the Browser

Web Workers run JS on a completely separate OS thread. They have no access to the DOM, window, or document. They communicate with the main thread exclusively via postMessage, which passes data by structured clone (deep copy) or, for large buffers, by transfer (zero-copy ownership transfer).

When to use Web Workers:

  • Parsing large JSON payloads (a 10MB JSON parse on the main thread can block for 200ms)
  • Image manipulation, canvas operations
  • Complex calculations (simulation, compression, encryption)
  • Running ML models in the browser (TensorFlow.js, ONNX)
  • Any CPU-bound work that takes more than ~5ms

The structured clone cost: Passing data to a worker copies it. If you're passing a 100MB ArrayBuffer every frame, the copy itself is expensive. Use Transferable objects: the main thread gives up ownership, the worker takes it. Zero-copy. Fast.

SharedArrayBuffer: Shared memory between threads. Requires cross-origin isolation headers (COOP, COEP). Enables Atomics for synchronisation. Used in WebAssembly multithreading. Advanced — but when you need it, nothing else will do.

Mental model: Workers are other threads with walls. Data passes through the walls via copying or transfer. Use them to avoid starving the main thread of computation.

Chapter 18: The V8 Engine — What JIT Compilation Means for You

V8 (Chrome's JS engine) does not interpret JS line by line. It compiles it. The pipeline:

  1. Parser: source code → AST (Abstract Syntax Tree)
  2. Ignition (interpreter): AST → bytecode, executes immediately. Quick to start, not maximally fast.
  3. Sparkplug (baseline compiler): bytecode → unoptimised machine code. Faster than interpretation.
  4. Maglev / Turbofan (optimising compiler): V8 profiles running code, identifies "hot" functions (called frequently), and compiles them to highly optimised machine code — as fast as C++ in some cases.

The optimising compiler makes assumptions. If a function always receives numbers, it generates numeric-optimised code. If you then pass a string, the assumption is violated. V8 deoptimises: throws away the fast code and falls back to interpreted bytecode. This is called a deoptimisation bailout.

Practical implications:

  • Monomorphic functions (always called with the same argument types) are fast. V8 can generate optimal machine code.
  • Polymorphic functions (called with different argument types) are slower. V8 generates less aggressive optimisations.
  • Megamorphic (called with many different shapes) — V8 essentially gives up on aggressive optimisation.
  • Object shapes (hidden classes): V8 tracks the "shape" of objects (which properties, in what order). All instances of a class share a shape → fast property access. If you conditionally add properties to objects, each variant is a different shape → slower.

What this means in practice: Write consistent, predictable code. Avoid dynamically adding/removing properties from objects. Use TypeScript — the strictness often aligns with V8's optimisation conditions.

Mental model: V8 bets on your code being consistent. The more consistent your types and object shapes, the better it can optimise. Unpredictable code is slow code.


Book 5: Framework Theory — Why Abstractions Exist

Chapter 19: Why React Exists — The Problem Before React

In 2011, Facebook was building increasingly complex UIs. The problem they encountered: as UI complexity grows, the number of state-mutation-to-DOM-update pathways explodes combinatorially. When user action A can affect view B, view C, and view D, and action E can affect B and F, and the views share some state... you end up with spaghetti. Different parts of the UI can be subtly out of sync with each other.

The jQuery-era solution: find the DOM element, mutate it. Every event handler directly manipulated specific DOM nodes. As the app grew, tracking which handler had updated which element became impossible. Bugs appeared where the UI showed stale state. Fixing one thing broke three others.

React's insight (2013): What if you described your UI as a pure function of your data? UI = f(state). Given any state, the UI is deterministic. You never manually update the DOM. You update state, and React figures out what the DOM should look like, and makes it so.

This is a profound shift: from imperative (tell the browser what to do) to declarative (tell the browser what the result should be, let it figure out the steps).

Mental model: React exists to make UI a function of state, eliminating the class of bugs caused by imperative DOM mutation losing track of its own changes.

Chapter 20: The Virtual DOM — What It Is, What It Isn't

The Virtual DOM is frequently misunderstood as React's performance secret. It is not. In isolation, creating a Virtual DOM on every render and diffing it against the previous one is slower than directly mutating the relevant DOM nodes. The Virtual DOM does not make React faster than direct DOM manipulation — for a single targeted update, direct DOM is faster.

What the Virtual DOM actually does:

  1. Makes declarative programming possible: You describe the entire UI, React works out the delta. Without VDOM, every setState would require you to manually specify which DOM nodes to update.

  2. Enables batching: React can collect multiple state updates within one event handler and process them together — one diff, one DOM pass — instead of triggering a layout per update.

  3. Makes layout thrashing structurally impossible: React reads and applies all DOM mutations in one pass. You can't interleave reads and writes (see Chapter 14).

  4. Enables platform independence: The "virtual" DOM is just a JavaScript object tree. React Native renders it to native iOS/Android views. React Three Fiber renders it to WebGL. The rendering target is pluggable.

The VDOM is an overhead, paid for by eliminating a class of bugs. It is a correctness tool that happens to be performant enough for most applications. When it is not fast enough (Netdata-scale data grids, sub-millisecond charting), you bypass it — raw DOM APIs, Canvas, WebGL.

Mental model: The Virtual DOM is not a performance trick. It is an abstraction layer that makes declarative UI possible and prevents a class of bugs. You pay a small performance tax. You buy developer ergonomics and correctness.

Chapter 21: Reconciliation — The Algorithm and Its Cost

When state changes, React calls your component function again, gets a new VDOM tree, and compares it to the previous tree. This is reconciliation (or "diffing").

The naive diff: compare every node in two trees. O(n³) for n nodes — catastrophically expensive. Facebook engineers proved this unusable for any real UI.

React's heuristic diff (O(n)):

  1. Same type, same position: update the props. Never unmount/remount. <div> → <div> = update. <div> → <span> = destroy + create.
  2. Lists: keys are mandatory. Without keys, React matches list items by position. Insert an item at the beginning, and React remounts every item because every item is now in a "new" position. With key, React matches by identity. Only the new item is mounted.
  3. Subtree independence: React can bail out of reconciling a subtree if it knows nothing changed. React.memo and useMemo hook into this — "I promise this component's output only depends on these values; skip it if they haven't changed."

What triggers reconciliation:

  • useState setter called
  • useReducer dispatch called
  • Context value changed
  • Parent component re-rendered (by default, children re-render too)

What triggers unnecessary reconciliation:

  • New object/array/function reference created inline in JSX: <Component style={{ color: 'red' }} /> creates a new object every render, causing React.memo to always see "new props"
  • Context value that is a new object every render causes every consumer to re-render

Concurrent Mode (React 18): Reconciliation can be interrupted. React can pause diffing mid-tree, handle a user input event (for responsiveness), then resume. This is why useTransition exists — it marks a state update as "low priority," allowing high-priority updates (user input) to interrupt it.

Mental model: Reconciliation is React's cost. Keys, memoisation, and Concurrent Mode are the tools to manage it. The algorithm is fast but not free.

Chapter 22: React's Failure Modes — When React Becomes Your Enemy

React is excellent for most UIs. It fails specifically when:

  1. Render frequency × component complexity > 16ms. A live dashboard updating 100 times per second, rendering 100 chart components — React's reconciliation cost per update will exceed your frame budget. Solution: bypass React, use canvas or direct DOM manipulation for the hot path.

  2. Deep component trees with prop drilling. A context value that changes frequently causes every consumer to re-render. Solution: split contexts by update frequency, or use a signal-based library.

  3. Initial bundle size. React itself is ~40KB gzipped. For a marketing page or a simple interactive widget, this is disproportionate overhead. Solution: Preact (3KB), or a lightweight alternative.

  4. First-paint performance. Client-side-only React must download, parse, and execute JS before the user sees anything. On slow networks, this is a blank white screen for seconds. Solution: Server-Side Rendering (SSR) or Static Site Generation (SSG).

  5. Memory pressure on long-running SPAs. React holds a fibre tree in memory for every mounted component. On very large apps with hundreds of simultaneously mounted components, this can be significant. Solution: unmount unused sections, virtual windows for long lists.

The career-defining insight: Knowing when NOT to use React (or when to partially bypass it) is more valuable than knowing React deeply. Anyone can render a component. Knowing that a canvas-based renderer is the right call for a Netdata-style telemetry dashboard — and being able to architect and justify that decision — is what separates senior engineers.

Chapter 23: Server Components — What They Change and Why

The problem they solve: Every React component shipped to the browser includes its code in the JS bundle. A component that uses a 100KB markdown-to-HTML library ships that 100KB library to every user's browser, where it runs on every render. But if that component's output never changes between renders (it's a static blog post), why is the user's browser doing the work?

React Server Components (RSC): Components that run only on the server. They have direct access to databases, file systems, and server-only APIs. They emit pre-rendered HTML and a JSON description of the UI. They never ship their code to the browser. They never re-render on the client.

What this changes:

  1. Zero bundle size for server components. A component that imports a 500KB library — if it's a Server Component — contributes 0 bytes to the client bundle.

  2. Data fetching at the component level without waterfalls. A server component can await a database query directly. No need for API routes, no client-side loading states, no waterfall of fetch-then-render.

  3. The mental model shift: React is no longer a client-side framework. It is a full-stack rendering model. The server renders some components (once, on the server). The client renders others (interactively, in the browser). The boundary between them is explicit ('use client').

What RSC does NOT change: interactivity. Any component that uses useState, useEffect, event handlers, browser APIs — must be a Client Component. Server Components are for non-interactive, data-fetching, content-rendering code.

Mental model: Server Components are a build-time optimisation for non-interactive content. Use them to move data fetching and large dependencies off the client.


Book 6: State — The Hardest Problem in Frontend

Chapter 24: What Is State? (A Precise Definition)

State is any data that: (a) changes over time, and (b) affects what is rendered. This is precise. "Changing over time" excludes constants. "Affects what is rendered" excludes data you compute as a byproduct (that's derived state).

State is not the data in your database. That's server state — data owned by the server, temporarily cached on the client. Most engineers confuse client state with server state. They are fundamentally different:

  • Client state (UI state): what's open, what's selected, what's in a form. Owned by the client. Never needs to be synced with the server. Lives only in this browser session.

  • Server state: data that lives in a database, is fetched over the network, and must be kept fresh. Owned by the server. Your client has a cache of it. It can become stale. Multiple users may change it.

The failure mode of mixing them: putting server data in a Redux store, writing reducers for loading/error/success states, manually invalidating and refetching. You've written hundreds of lines to re-implement what a caching library like React Query does correctly by default.

The full state taxonomy:

code
State
├── Client State (UI)
│   ├── Local       → useState (single component)
│   ├── Shared      → Context, Zustand, Jotai (multiple components)
│   └── URL         → search params, route params (shareable, bookmarkable)
└── Server State
    ├── Cached      → React Query, SWR (fetched, cached, revalidated)
    └── Real-time   → WebSocket/SSE subscription (pushed from server)

Mental model: Identify which category your state belongs to before choosing where to store it. Different categories have different ownership, lifetime, and freshness requirements.

Chapter 25: The State Colocation Principle

State should live as close as possible to where it is used. This is not a stylistic preference. It is a performance and maintainability principle.

If a <Tooltip> component needs to know if it's visible, that state is local to the tooltip. It should be useState inside <Tooltip>. Not in a parent. Not in a global store. Not in context.

If state is lifted unnecessarily to a parent, every state change re-renders the parent and all its children, not just the tooltip. If it's in a global store, every consumer re-renders globally.

The corollary: Only lift state as high as necessary. Only globalise state as a last resort.

The URL exception: State that should be preserved across refreshes, shared via link, or navigated back to — that state belongs in the URL (search params, path params). Form filters, pagination, selected tabs. This is the most underused state location in frontend development.

Mental model: State gravity pulls toward its consumer. Fight the urge to hoist everything. Hoist only when multiple components genuinely need shared access.

Chapter 26: React Query and Why Cache-First Thinking Matters

React Query is not a data fetching library. It is a server-state management library with data fetching built in. The distinction matters.

What React Query manages for you:

  • Caching: the result of a fetch is cached by a queryKey. The same key from any component returns the cached data instantly.
  • Deduplication: if 10 components all useQuery('users') simultaneously, only 1 HTTP request fires.
  • Stale-while-revalidate: return cached data immediately (fast UI), refetch in the background, update when fresh data arrives.
  • Background refetching: when the window regains focus, React Query silently refreshes stale queries. The user always sees fresh data without a loading spinner.
  • Mutation and invalidation: after a POST/PUT/DELETE, you can invalidate specific query keys, causing them to refetch.

Why Redux for server state is wrong: Redux is a client state manager. It has no concept of staleness, no background refetching, no deduplication. Every team that manages server state in Redux ends up writing hundreds of lines of boilerplate to re-implement these features, incorrectly, and with many edge cases.

Mental model: Server state is a cache of the database. Treat it as a cache. Use a library designed for caching. React Query is that library.


Book 7: Routing — Navigation as Architecture

Chapter 27: What Is a Route? (First Principles)

A route is a mapping from a URL to a UI state. That's all it is. The mechanism for resolving that mapping differs between server and client, but the concept is identical.

In a multi-page app (traditional server-rendered): the browser requests a URL, the server returns HTML for that route. Navigation = HTTP request. The browser discards the current page's JS context entirely and loads a new one.

In a single-page app: the browser loads the HTML+JS once. Navigation = the JS router intercepts link clicks, updates the URL (via History API), and renders the appropriate component for that URL. No server request. The JS context persists across navigations.

Why SPA routing was invented: Server-rendered page transitions reload the entire page — full network round-trip, full re-parse, flash of white. For app-like UIs, this felt slow and jarring. SPA routing made transitions instant and preserved state across pages (the shopping cart doesn't disappear when you browse back).

What SPA routing costs:

  • The entire app's JS must be downloaded and parsed before any route is accessible. First load is slower.
  • Deep links (directly accessing a non-root URL) require the server to return the SPA's index.html for all URL patterns. Extra server configuration.
  • SEO: historically, search engine crawlers didn't execute JS. SPAs returned empty HTML. Modern crawlers do execute JS, but SSR/SSG is still more reliable for SEO-sensitive pages.

The hybrid (Next.js, Remix, Astro): SSR/SSG + client-side routing. First load is server-rendered (fast, SEO-friendly). Subsequent navigations are client-side (instant, no reload). Best of both worlds, at the cost of architectural complexity.

Chapter 28: Next.js App Router — What the File System Routing Buys You

File-system routing is a convention: a file at app/users/[id]/page.tsx defines a route at /users/:id. The file system is the routing config. This eliminates a class of boilerplate (no explicit <Route path="/users/:id" component={UserPage} />) and makes the codebase navigable.

The App Router's deeper innovations:

  1. Nested layouts: app/users/layout.tsx wraps all user-related routes. Navigation between /users/1 and /users/2 re-renders only the page, not the layout. The sidebar, header, and any data fetched in the layout persist across navigations.

  2. Parallel routes (@): Render multiple independent sections of a page simultaneously. A dashboard can load @analytics and @recent in parallel, each with independent loading states.

  3. Intercepting routes ((.)): Open a photo in a modal when navigating from a feed, but as a full page when navigated to directly. Same URL, different rendering context depending on entry point.

  4. Loading UI and Suspense integration: loading.tsx in any route segment shows a skeleton automatically while that segment is loading. The layout above it never unmounts. The streaming architecture delivers the shell instantly and streams in content as it resolves.

Mental model: App Router treats the URL as a description of the entire page layout, not just the content. Each segment of the URL maps to a nested layout boundary.


Book 8: Performance Engineering

Chapter 29: Web Vitals — Why Google Cares and What They Measure

Google measures "page experience" and uses it as a ranking signal. Web Vitals are the specific metrics. Understanding why they were chosen gives you insight into what they actually measure.

Largest Contentful Paint (LCP): The time until the largest visible element (image, heading, video poster) finishes rendering. Proxy for "when did the user see the main content?" Target: under 2.5 seconds.

What causes poor LCP: slow server TTFB, render-blocking resources (CSS, JS in <head> without defer), slow image loading (large unoptimised images, no loading="eager" on above-fold images, CDN not used), client-side rendering (HTML is empty; JS must execute before LCP element is painted).

Cumulative Layout Shift (CLS): The sum of unexpected layout shifts during page load. When a late-loading image or ad pushes content down, causing you to click the wrong thing — that's a layout shift. Target: under 0.1.

What causes poor CLS: images without explicit width/height (the browser doesn't know the dimensions until the image loads, reserves no space, then reflows when it arrives), dynamically injected banners above existing content, web fonts that swap to a different metric font after load.

Interaction to Next Paint (INP): The 75th-percentile time from user interaction (click, tap, key) to the next visual update. "When I click something, how quickly does the UI respond?" Target: under 200ms.

What causes poor INP: long tasks on the main thread (JS executing when the user interacts), heavy re-renders triggered by events, non-debounced input handlers.

Mental model: LCP = load speed. CLS = visual stability. INP = interactivity. Each has a specific causal chain. Measure first, then trace the causal chain to the root cause.

Chapter 30: Code Splitting and Lazy Loading — Why and When

Every JavaScript file in your bundle must be: downloaded, decompressed, parsed, and compiled before it can execute. On a slow connection, 1MB of JS can take 5-10 seconds. On a low-end Android phone, parsing 1MB of JS can take 2-5 seconds even on fast connection — CPU is the bottleneck.

Code splitting: instead of one large bundle, split the code into multiple chunks. Load only the chunk needed for the current route on initial load. Load additional chunks as the user navigates.

Dynamic imports (the mechanism):

javascript
// Static import: always in the bundle
import HeavyChart from './HeavyChart';

// Dynamic import: loaded on demand, in its own chunk
const HeavyChart = lazy(() => import('./HeavyChart'));

The granularity question: How small should chunks be? Under HTTP/2, many small chunks are fine (multiplexed, no head-of-line blocking). Under HTTP/1.1, too many chunks = too many requests = slower. The optimal split is by route (different page → different chunk) and by interaction boundary (a modal that opens rarely → load its code when the user clicks to open it).

Prefetching: <link rel="prefetch" href="/chunk-settings.js"> loads a chunk during idle time, before the user navigates to it. When they click "Settings," the chunk is already in the browser cache. The navigation feels instant.

Route-based vs component-based splitting: Next.js does route-based splitting automatically. For large components within a route (a rich text editor, a charting library, a PDF viewer), manual lazy() is appropriate.

Mental model: Only load code the user needs right now. Prefetch code they'll probably need soon. Never load code for pages/features they may never use.

Chapter 31: Images — The Biggest Performance Win Most Engineers Ignore

Images are typically 60-80% of a page's total bytes. Optimising images correctly is 10x more impactful than most JS optimisations.

The image decision framework:

  1. Format: AVIF (best compression, newest) → WebP (excellent compression, wide support) → JPEG (photos, no transparency) → PNG (transparency, screenshots). Use <picture> or Next.js <Image> to serve AVIF/WebP with JPEG/PNG fallbacks.

  2. Size: Never serve a 2000×1500px image in a 300×200px container. The browser downloads all 2000×1500 pixels. The waste is massive. Serve the image at (or near) the display resolution × device pixel ratio. A 2x DPR screen in a 300px container needs a 600px image, not 2000px.

  3. Loading strategy: Images above the fold: loading="eager", fetchpriority="high". The browser should prioritise the LCP image. Images below the fold: loading="lazy". The browser defers download until they enter the viewport.

  4. Dimensions declared: Always specify width and height in HTML (or use aspect-ratio in CSS). Without these, the browser reserves no space for the image. When it loads, everything shifts down. CLS penalty.

  5. CDN with edge resizing: Images should be served from a CDN close to the user. Image CDNs (Cloudflare Images, Imgix, Next.js Image Optimisation API) resize and convert on the fly. You upload once, they serve the optimal format and size per device.

Mental model: Images are the most under-optimised area in most codebases and the highest-ROI optimisation you can make.


Book 9: Real-Time Frontend

Chapter 32: Why Real-Time is Architecturally Different

A standard request-response frontend has a simple mental model: user acts → request sent → response received → UI updated. The data flow is demand-driven. The client controls when data arrives.

Real-time frontends invert this. The server pushes data. Events happen independently of user action. The UI must reflect reality as it changes. This creates new problems:

  • Connection state management: is the connection alive? What do you render when it drops?
  • Message ordering: messages may arrive out of order. Does your UI handle this?
  • Backpressure: the server pushes 1000 events/second. The browser can render 60 frames/second. What do you do with the other 940 events?
  • Reconnection: network drops. You reconnect. What state do you replay? What did you miss?
  • UI consistency: while the server streams an update, the user may be editing the same data. Conflict resolution is now a frontend problem.

Chapter 33: Polling vs Long-Polling vs SSE vs WebSockets — The Decision Matrix

Polling: Every N seconds, make a GET request. Simple. Works everywhere. Expensive: you're making requests even when nothing has changed. Latency = up to N seconds. Use when: infrequent updates, simplicity more important than latency, N > 30 seconds.

Long-Polling: Make a GET request. The server holds it open until data is available (or a timeout). Response arrives → process it → immediately make another request. Simulates push. Latency = near-zero. But: expensive server connections, complex timeout/retry logic. Use when: push semantics needed but WebSocket infrastructure unavailable.

Server-Sent Events (SSE): A single HTTP connection, kept open. The server sends text events (data: ...\n\n) over this connection. One-way: server → client only. Built-in reconnection (browser reconnects automatically if connection drops, sends Last-Event-ID header to resume). Works over HTTP/2 (multiple SSE streams on one connection). Not supported for cross-origin without CORS. Use when: server-to-client only (dashboards, notifications, AI token streaming). This is the correct transport for streaming LLM responses.

WebSockets: Full-duplex, persistent TCP connection. Both client and server can send messages at any time. Not HTTP. Starts with an HTTP upgrade handshake, then switches protocol. Low overhead per message. Complex to scale horizontally (sticky sessions or pub/sub layer required). Use when: bidirectional real-time communication. Collaborative editing, multiplayer games, live chat.

The AI streaming case: SSE is almost always correct. The LLM generates tokens one by one. The server streams them as SSE events. The client receives tokens, appends to the display. One-way communication. SSE is perfect. WebSocket is overkill.

Mental model: One-way push from server → SSE. Two-way real-time → WebSocket. Simple and infrequent → polling.

Chapter 34: Building AI Streaming Frontends — Token by Token

An AI response from a model like Gemini arrives as a stream of tokens (sub-word units of text). The server sends them via SSE as they are generated. The client must render them progressively.

The rendering challenge: appending a token every ~30ms to a React state variable means 30+ setState calls per second. Each triggers a re-render. On complex UI, this can exceed your frame budget.

Solutions:

  1. Buffer tokens, flush per frame: Collect tokens in a ref (not state — refs don't trigger re-renders). On each requestAnimationFrame, move the buffered text into state (one render per frame). Smooth streaming, regardless of token rate.

  2. Direct DOM mutation for the streaming area: Bypass React entirely for the text node being streamed. Use a ref to the DOM element and textContent directly. Zero re-renders during streaming. This is the approach Vercel's AI SDK uses internally.

  3. Optimistic rendering with uncertainty: The user asks a question. Show a loading skeleton immediately. Stream in tokens. If the stream errors, show an error state and offer retry. The UI should feel responsive at every step.

The incomplete Markdown problem: LLM responses are often Markdown. But the stream arrives incrementally. **bold** might arrive as **bo, then ld**. You cannot parse and render incomplete Markdown. Solutions: (a) buffer until a full block is complete (paragraph, code block), then render, (b) use a streaming-aware Markdown parser that handles incomplete input gracefully.

Mental model: AI streaming is a race between token arrival rate and render budget. Decouple the two with buffering and frame-synchronised flushing.


Book 10: Data-Heavy Frontend

Chapter 35: Why 100,000 Rows Kill the DOM

The DOM is not a data structure. It is a rendering structure. Each DOM node is a C++ object. Each node participates in layout, paint, and compositing. Inserting 100,000 <tr> elements into a table:

  • Creates 100,000 C++ objects in the rendering engine
  • Forces the layout engine to calculate the geometry of 100,000 rows
  • Forces the paint engine to rasterise 100,000 rows' worth of pixels (most of which are off-screen)
  • Forces all of these calculations to happen before the user sees a single row

This is not a JavaScript performance problem. It is a rendering engine throughput problem. Even if you populate the DOM instantly, the browser will take seconds to layout and paint 100,000 rows.

Additionally: even after the initial render, 100,000 DOM nodes consume memory, slow down queries (querySelectorAll), and slow down every subsequent layout/paint operation on the page.

The solution: virtualisation. Render only the rows that are visible in the viewport (plus a small buffer above and below). As the user scrolls, add rows entering the viewport and remove rows leaving it. At any time, the DOM contains ~50-100 rows — regardless of how large the dataset is. Performance is constant.

The virtualisation idea, from first principles:

  • The viewport is a window of fixed height, say 800px
  • Each row is, say, 40px tall
  • Therefore ~20 rows are visible at once
  • The total scrollable height is 100,000 rows × 40px = 4,000,000px
  • Create a container of height 4,000,000px (so the scrollbar is correct)
  • Inside it, absolutely position only the ~20-40 visible rows
  • On scroll, calculate which row index is at the top: Math.floor(scrollTop / rowHeight)
  • Re-render only those rows, repositioned to their correct absolute offsets

This is the core idea of react-window, react-virtual, and TanStack Virtual. The library does the math. The idea is geometry.

Mental model: Virtualisation replaces DOM size with a scrolling calculation. O(1) DOM size regardless of dataset size.

Chapter 36: Canvas vs SVG vs DOM vs WebGL — When to Use What

DOM: HTML elements, styled with CSS. Accessible, SEO-indexable, easy to interact with (click handlers, keyboard, focus). Performance: good for tens to hundreds of elements. Poor for thousands or elements that update many times per second.

SVG: Vector graphics in the DOM. Scalable without pixelation. Each shape is a DOM element — accessible, stylable, event-handleable. Performance: good for hundreds of shapes. Poor for thousands (same DOM overhead as HTML). Best for: icons, small diagrams, static charts.

Canvas 2D: Draw pixels directly with a JavaScript API. Not DOM-based — no DOM nodes per shape. Performance: good for thousands to tens of thousands of elements per frame. The GPU is not involved; the CPU draws pixels into a bitmap. Best for: interactive diagrams, 2D game graphics, chart libraries with many data points (Chart.js uses Canvas).

WebGL: GPU-accelerated rendering. The GPU executes shader programs (GLSL code) that run in massively parallel across thousands of GPU cores. Performance: millions of vertices per frame at 60fps. The overhead: complex API, requires understanding of graphics pipeline (vertex shaders, fragment shaders, buffers, uniforms). Best for: Netdata-scale dashboards, 3D visualisations, data sets with millions of points, particle effects.

The decision framework:

code
Element count / update rate
├── < 500 elements, low update rate    → DOM / SVG
├── 500–10,000 elements, any rate      → Canvas 2D
├── > 10,000 elements OR real-time     → Canvas 2D (adequate) or WebGL (optimal)
└── 3D or shading required             → WebGL (Three.js)

Interactivity requirement
├── Standard click/hover/focus         → DOM (native, accessible)
├── Custom hit-testing, hover effects  → Canvas (manual hit detection via geometry)
└── GPU-side interactivity             → WebGL + picking passes

Netdata's approach: Pure WebGL for chart rendering. Tens of thousands of time-series data points per chart, dozens of charts per dashboard, updating multiple times per second. DOM cannot handle this. Canvas 2D would struggle. WebGL runs on the GPU with dedicated memory and parallelism.

Mental model: Use DOM until it hurts. Use Canvas when DOM hurts. Use WebGL when Canvas hurts. Never jump ahead of the pain point — each step up adds complexity.


Book 11: Frontend System Design

Chapter 37: How to Approach Frontend System Design from a Blank Page

Given any frontend system design problem (design a collaborative document editor, design a real-time trading dashboard, design an infinite social feed), apply this framework:

Step 1: Define the core data flow What data does this UI display? Where does it come from? How often does it change? How large is it?

  • Static → SSG
  • Changes occasionally → SSR + ISR
  • Changes per user session → SSR + client hydration
  • Changes in real-time → SSE/WebSocket

Step 2: Identify the rendering strategy What is rendered server-side? What is rendered client-side? What can be static?

  • SEO-sensitive content → server-rendered
  • Interactive widgets → client-rendered
  • Frequently viewed, rarely changed → statically generated + CDN cached

Step 3: Define the state topology What state exists? For each:

  • Who owns it? (server, client, URL)
  • What triggers updates?
  • Who reads it?
  • How stale can it be?

Step 4: Identify performance constraints

  • Expected dataset size (100 rows? 100,000 rows?)
  • Expected update frequency (once per hour? 10 times per second?)
  • Expected user hardware (desktop? low-end mobile?)
  • Network conditions (4G? WiFi? offline required?)

Step 5: Identify failure modes

  • What happens when the API is slow?
  • What happens when the real-time connection drops?
  • What happens when the user has a slow device?
  • What happens when the dataset grows 100x?

Step 6: Make technology decisions with explicit reasoning Not "I'll use React Query" but "Because data is server-owned, updates every 5 minutes, and freshness matters, I'll use React Query with a 5-minute stale time and background refetching."

The pattern to avoid: Making technology choices before understanding constraints. "I'll use Redux" before asking what kind of state you have. "I'll use WebSockets" before asking whether bidirectional communication is needed. Technology should be derived from requirements.

Chapter 38: Error Boundaries and Frontend Resilience

A JavaScript error in a React component tree, without error boundaries, will unmount the entire application. The user sees a blank white screen.

Error boundaries are React components that catch errors in their subtree and render a fallback instead of crashing. They are the try-catch of the React tree.

Resilience strategy:

  • Wrap independent sections of your UI in separate error boundaries. If the "Related Articles" widget throws, the rest of the article page should still render.
  • Every async operation should have an explicit error state. The triangle of states: loading / success / error. Omitting the error state is a product bug.
  • Network errors are not exceptional. On mobile, 10-20% of requests fail or time out. Design for failure as the normal case, not the exception.
  • Retry logic with exponential backoff: a failed request should be retried automatically, with increasing delays. React Query does this. If you're rolling your own, the formula is delay = min(base * 2^attempt, maxDelay) + jitter.

Mental model: A resilient frontend assumes that everything will fail at some point. It degrades gracefully at every layer.


Book 12: Security from First Principles

Chapter 39: XSS — How It Works and Why the DOM Makes It Possible

Cross-Site Scripting: an attacker injects malicious JS into a page that runs in a victim's browser, with the victim's session cookies and permissions.

How: If your code takes user input and puts it into the DOM as HTML, and the input contains <script>alert('xss')</script> or <img onerror="stealCookies()">, the browser executes it. The browser cannot distinguish "this HTML came from the developer" from "this HTML came from the attacker."

DOM XSS vs Reflected XSS vs Stored XSS:

  • Stored: attacker's payload is saved in the database, served to all users (e.g., a forum post with malicious script)
  • Reflected: attacker crafts a URL with a malicious query param; victim clicks it; the server echoes the param into the HTML
  • DOM: the payload never hits the server; client-side JS takes a URL param and sets innerHTML without sanitisation

Prevention:

  • Never use innerHTML, document.write(), or eval() with user-provided data
  • Use textContent instead of innerHTML for user-generated text
  • If you must render user-provided HTML, sanitise it with DOMPurify before insertion
  • Content Security Policy (CSP): a server-sent header that tells the browser which sources of scripts to trust. Content-Security-Policy: script-src 'self' means "only execute scripts from my own domain." Injected inline scripts from attacker-controlled content are blocked.

Mental model: XSS is what happens when you trust user input as code. Treat all user input as data, never as markup or code.

Chapter 40: Auth Tokens in the Browser — Where to Store What

Two storage options for auth tokens:

LocalStorage / SessionStorage:

  • Accessible to any JavaScript on the page
  • If an XSS attack succeeds, the attacker can read your token with localStorage.getItem('token')
  • Persistent across browser restarts (localStorage) or per-tab (sessionStorage)

HttpOnly Cookies:

  • Not accessible to JavaScript at all (document.cookie does not include HttpOnly cookies)
  • An XSS attack cannot steal them — they're invisible to JS
  • Sent automatically with every request to the matching domain
  • Vulnerable to CSRF (see below), mitigated by SameSite=Strict or SameSite=Lax

The verdict: HttpOnly cookies are more secure against XSS. SameSite=Strict prevents CSRF. This combination is the most secure auth storage for session tokens.

When localStorage is acceptable: non-sensitive tokens, public-content JWTs with no sensitive data, or when cookies are architecturally impractical (cross-origin API with no shared parent domain). In all cases: short expiry, refresh token rotation.

Mental model: If you need security, use HttpOnly cookies. If XSS is impossible (you have a very strict CSP and sanitise all inputs), localStorage is workable. Default to cookies.


Book 13: The AI-Era Frontend Engineer

Chapter 41: Why AI Frontends Are Architecturally Different

Standard web apps have a request-response loop with predictable latency and deterministic output. AI-powered features break both assumptions:

Latency is long and variable. An LLM inference call takes 2-20 seconds. A user cannot stare at a loading spinner for 20 seconds. The UX must reflect progress, not just binary loading/done.

Output is non-deterministic. The same query produces different responses. You cannot cache the response. You cannot optimistically render a placeholder that matches. You must design for novel, variable-length content.

Output arrives incrementally. The LLM generates token-by-token. Waiting for the full response before rendering is worse UX than streaming. Every AI feature should stream.

Output may be invalid. An LLM asked to generate JSON may generate malformed JSON. An LLM asked to follow a format may deviate. Your frontend must handle failure gracefully.

The design implications:

  1. Always stream. Show tokens appearing in real-time. Users perceive streaming as faster even when total latency is the same — the first token at 500ms feels faster than the full response at 3000ms.

  2. Skeleton first, content second. Show the chrome (title, layout, action buttons) instantly. Fill the content area as it streams.

  3. Error recovery at every step. The stream may die mid-response. Show "Generation interrupted — retry?" not a crash.

  4. Distinguish confidence states. "Thinking..." → "Generating..." → "Done." If you can expose intermediate results (chain-of-thought), even better.

  5. AI-generated content is untrusted HTML. If you render LLM output as HTML, sanitise it. The LLM might generate <script> tags.

Chapter 42: The Irreplaceable Frontend Engineer

In 2025, AI can write components from a description. AI can convert Figma to code. AI can fix CSS bugs. The tasks that were junior frontend work for the last decade — building UI from designs, implementing standard patterns — are now AI-assisted to near-completion.

What AI cannot do, and will not be able to do without architectural design from a human:

  1. Reason about tradeoffs under constraints you haven't encountered yet. AI knows patterns. You need to know why patterns exist so you can reason about novel situations.

  2. Make the call on when to bypass the framework. "This chart has 500,000 data points updating at 10Hz. React is the wrong tool. We need a WebGL canvas layer with a custom draw loop." This is architectural judgment, not pattern matching.

  3. Own the failure modes. Production incidents are not pattern-matching problems. "LCP is 8 seconds for users in Southeast Asia on 3G" requires reasoning about the full causal chain: CDN edge presence, image sizing, render-blocking resources, server TTFB. This is engineering, not code generation.

  4. Design the system before writing code. A blank page with requirements and constraints. No existing codebase, no patterns to copy. What is the rendering strategy? What is the state topology? What is the performance budget? How does this behave under failure? This is the job that AI augments but cannot replace.

  5. Understand the physics. A browser is a machine. A network is a physical medium with latency. A CPU has a clock speed. A GPU has parallelism. These are not abstractions — they are constraints. Engineering is working within constraints. AI generates code; engineers understand constraints.

The curriculum you just read is your moat. Every chapter is a constraint, a physical reality, a tradeoff. You now know why every technology in the stack exists. You can derive solutions instead of copying them. You can diagnose production problems by reasoning about the pipeline instead of Googling symptoms.

The engineer who understands the browser as a machine, the network as a physical medium, the framework as a tradeoff, and the system as a set of components with failure modes — that engineer is irreplaceable. Not because AI cannot generate code, but because generating code is not engineering.


Appendix: The Blank-Page Playbook

When given any frontend problem from scratch, run this checklist before writing a line of code:

The Six Questions

1. What is the data?

  • What are the entities? (users, posts, messages, time-series metrics)
  • How large is the dataset? (10 items? 100,000? unbounded?)
  • How fresh does it need to be? (stale for 1 hour? real-time?)
  • Who owns it? (server? client? both?)

2. What is the rendering strategy?

  • Does search engines need to index it? → SSR/SSG
  • Does it change per user? → SSR with per-request data
  • Does it never change? → SSG + CDN cache
  • Does it change in real-time? → Client rendering + streaming

3. What is the performance budget?

  • Target device (flagship? low-end mobile?)
  • Target network (4G? WiFi? offline?)
  • LCP target? INP target?

4. What is the state topology?

  • Local UI state: useState
  • Shared UI state: Context / Zustand
  • URL state: query params / path params
  • Server state: React Query / SWR
  • Real-time state: WebSocket / SSE subscription

5. What are the failure modes?

  • API slow/unavailable → loading states, error boundaries, retry
  • Real-time connection drops → reconnection, stale state indicator
  • Large dataset → pagination, virtualisation
  • Slow device → code splitting, deferred rendering

6. What technology is derived from the above?

  • Do not choose technology first. Let constraints choose technology.

Reading Order

code
Book 0: The Ground Rules             (read first, always)
Book 1: The Browser as a Machine     (the foundation)
Book 2: The Network                  (the environment)
Book 3: HTML to Pixels               (rendering pipeline)
Book 4: JavaScript's Architecture    (the language model)
Book 5: Framework Theory             (why React exists)
Book 6: State                        (the central problem)
Book 7: Routing                      (navigation design)
Book 8: Performance Engineering      (Web Vitals, optimisation)
Book 9: Real-Time Frontend           (streaming, WebSocket)
Book 10: Data-Heavy Frontend         (canvas, WebGL, virtualisation)
Book 11: System Design               (blank page → architecture)
Book 12: Security                    (never optional)
Book 13: The AI-Era Engineer         (your career moat)

Each book can be read independently after the first four. But read the first four in order. Everything else stands on them.


This curriculum is not a collection of facts. It is a way of thinking. For every technology you encounter after reading this, ask: what constraint does this solve? What does it cost? When does it fail? If you can answer those three questions, you own the technology — it does not own you.