MS Stack Ch 6 — Modern frontend baseline
Semantic HTML, ARIA + a11y, the CSS cascade + box model + Flexbox + Grid, modern JavaScript (ES2024), fetch, the event loop. The browser-side foundations every React app stands on.
Published Updated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- Predict CSS precedence without decimal-specificity shortcuts
- Build semantic keyboard-operable controls and a shrinkable grid
- Explain promise/timer ordering and cancel obsolete HTTP work
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 37 sections
Chapter 6 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
React abstracts the DOM. Tailwind abstracts the stylesheet. TypeScript abstracts JavaScript. Vite abstracts the bundler. Four layers of abstraction sit between you and the browser, and every one of them eventually leaks. When a click handler fires twice, when a layout collapses on iOS Safari, when a useEffect runs in a strange order, the answer is almost always in the layer underneath. The layer underneath is the browser: HTML parsed into the DOM, CSS resolved through the cascade into the CSSOM, JavaScript executed on a single thread driven by an event loop. Understanding this layer helps you distinguish a browser behavior from a framework bug.
Shipping-vs-expert depth at this layer is stark. The shipping engineer knows that display: flex exists and reaches for it whenever something needs to sit next to something else. The expert knows that flex containers establish a new formatting context, that min-width: 0 is the unlock for shrinking children inside a flex row that contains long text, that align-items: stretch is the default and that flipping it to center is what made the equal-height cards collapse last Tuesday. The shipping engineer writes async/await and is happy. The expert knows that an await continuation is scheduled as a promise job that the event loop schedules as a microtask, that microtasks drain to exhaustion before the next macrotask, and that this is why their setTimeout(fn, 0) ran after the promise chain they thought was slower.
You finish this chapter when you can open a fresh HTML file, scaffold a small interactive page with semantic markup, layout it with Flexbox and Grid, hydrate it with modern JavaScript and fetch, and explain — without notes — what happens between the user typing the URL and the first pixel appearing on the screen.
Semantic HTML — Pick the right element for the job and know which ones imply ARIA roles for free.
a11y first pass — Run an axe scan, fix focus order, label every interactive control.
CSS cascade — Predict which rule wins between two selectors without opening DevTools.
Flexbox + Grid — Reach for the right one — Flexbox for 1D flow, Grid for 2D layout.
ES2024 fluency — Use destructuring, optional chaining, async iteration, and modules without thinking.
Event loop model — Explain why a microtask runs before a macrotask and why that bit you in production.
Concepts and depth
Semantic HTML elements
HTML elements carry meaning. A <button> is not a styled <div> — it has a default role of button, is focusable, fires on Enter and Space, participates in form submission when its type is submit, and is announced as a button by screen readers. Replacing it with a <div onClick={...}> strips every one of those defaults, and you spend the rest of the project re-implementing them, badly. The cost of using the right element is zero. The cost of using the wrong one is paid forever.
A good page reads like a document outline: <header> for the banner, <nav> for primary navigation, <main> for the single primary content area, <article> or <section> for chunks of content, <aside> for tangential content, <footer> for the page or section footer. Inside, use <h1>–<h6> to express the heading hierarchy — never skip a level just because the design uses a smaller font. Use <ul> / <ol> / <dl> for lists, <figure> + <figcaption> for media, <table> only for tabular data (with <caption>, <thead>, <tbody>, <th scope="col|row">). Forms get <form>, <fieldset>, <legend>, <label> (always associated, either by wrapping or for in HTML (htmlFor in JSX)), and the right <input type="..."> — email, number, tel, url, date, search — so mobile keyboards switch automatically and the browser's built-in validation kicks in.
The gotcha that bites everyone: a <button> inside a <form> defaults to type="submit". Hit Enter inside a text input and the form submits, sometimes refreshing the page and losing state. Always set type="button" on buttons that aren't submit buttons.
Good enough to ship
• Use header / nav / main / footer to outline the page • Pick the right input type and always pair label with input • Never put button behaviour on a div
Expert tier
• Know each element's implicit ARIA role by heart • Use dialog / details / progress / meter where they fit • Audit landmark structure with the accessibility tree, not the DOM tree
Accessibility basics — ARIA roles and focus management
Accessibility is not a feature you bolt on at the end. It is a property of the markup you write today. The two questions to ask of every interactive element are: can a keyboard user reach it and operate it? and can a screen-reader user understand what it is and what it will do? If the answer to either is no, you have a bug.
ARIA — Accessible Rich Internet Applications — gives you role, aria-* properties, and aria-* states to describe non-native widgets to assistive tech. The first rule of ARIA is don't use ARIA. If a native element does the job (<button>, <a href>, <input type="checkbox">, <dialog>), use it. If you must build a custom widget (a combobox, a tab panel, a tree view), follow the WAI-ARIA Authoring Practices pattern exactly — half-implemented ARIA is worse than none, because screen readers announce a control that doesn't actually behave the way they promised the user.
Focus management is where most apps fall apart. When a modal opens, focus must move into it; when it closes, focus must return to the element that triggered it. When a route changes in a SPA, focus is left on whatever the user last clicked, which is often invisible — the fix is to programmatically focus the new page's <h1> (with tabIndex={-1}) on navigation. When a list filters down, the focused row may have disappeared — handle that. The visible focus ring is non-negotiable: never outline: none without providing a :focus-visible style that is at least as obvious.
The screen-reader testing gap
You will never catch most a11y bugs with a visual review. Install a screen reader (NVDA on Windows, VoiceOver on macOS — already there, Cmd+F5) and tab through your page once a week. Manual keyboard and screen-reader checks complement automated scans; neither alone proves accessibility.
Good enough to ship
• Tab through the whole page — every interactive thing is reachable • Visible focus ring on every focusable element • axe DevTools shows zero serious violations
Expert tier
• Focus trap in modals, restore focus on close • aria-live regions for async status updates • Keyboard shortcuts that respect roving tabindex inside composite widgets
CSS — cascade, specificity, and inheritance
The cascade selects a declaration after relevance (for example a matching media query), origin/importance and layers, specificity, scoping proximity, then source order. For low-to-high origin precedence: normal user-agent, user, author, keyframe animations, important author, user and user-agent declarations, then active transitions. Within an origin, later normal layers win and unlayered normal styles beat layered ones; important layer order reverses. Specificity compares the tuple (IDs, classes/attributes/pseudo-classes, types/pseudo-elements) lexicographically, not as base-10 arithmetic: eleven classes do not beat one ID. Inline styles have special precedence within author styles; :where() contributes zero specificity.
Inheritance supplies a parent’s computed value for inherited properties when no winning declaration specifies another value. Many text properties inherit; margins and padding normally do not, and form controls may have their own browser declarations. inherit explicitly takes the parent value; initial uses the property’s specification default; unset chooses inherit for inherited properties and initial otherwise; revert rolls back the current origin, while revert-layer rolls back the current layer. Neither unset nor revert simply means “use the previous stylesheet value”.
Specificity is considered only after origin, importance and layer precedence. Thus a more specific normal selector cannot defeat an important declaration; a higher-priority layer or origin can win without a specificity contest. Prefer intentional layers and low-specificity selectors over escalating IDs and !important.
Good enough to ship
• Use classes, not IDs, for styling • Read specificity from DevTools Styles panel • Avoid !important — only an escape hatch for vendor styles
Expert tier
• Architect with @layer for predictable overrides • Use :where() to drop specificity to zero on reset rules • Reason about cascade origin (user-agent vs author vs user)
CSS box model and box-sizing
Every element is a box: content, padding, border, margin — in that order, outward. By default (box-sizing: content-box), the width you set applies to the content only, and padding and border add on top, which is why a width: 200px; padding: 20px element is actually 240px wide. Set box-sizing: border-box (universally, with *, *::before, *::after { box-sizing: border-box; }) and width now includes padding and border, which is what almost everyone wants. A border-box reset makes component width arithmetic easier; it is a convention, not a requirement of every stylesheet.
Margins collapse vertically between block elements. Two adjacent vertical margins of 20px and 30px don't add to 50px — they collapse to 30px. This is intentional (it makes prose typography look right) and surprising the first time it bites you. These rules concern normal block flow: sibling margins can collapse; a separating border/padding can prevent parent-child collapse. Flex/grid item margins do not collapse with one another. Negative margins require separate arithmetic, not simply the maximum-positive-margin rule. If you want predictable spacing, use Flexbox or Grid with gap instead of margins.
Good enough to ship
• Apply box-sizing: border-box globally • Reach for gap inside flex/grid for spacing • Know padding is inside the border, margin is outside
Expert tier
• Predict margin collapse without opening DevTools • Use logical properties (margin-inline, padding-block) for RTL support • Reason about containing blocks and stacking contexts
Flexbox
Flexbox is a one-dimensional layout system: items flow along a main axis and align on a cross axis. You opt in with display: flex on a container; every direct child becomes a flex item. The container properties you reach for daily: flex-direction (row | row-reverse | column | column-reverse), justify-content (main-axis alignment), align-items (cross-axis alignment), gap (spacing between items, no need for negative margins), flex-wrap (whether items wrap to a new line). On items: flex (shorthand for flex-grow flex-shrink flex-basis), align-self (override the container's align-items for one item).
The unlock that everyone misses: a flex item's min-width defaults to auto, which means the item refuses to shrink smaller than its content. If you have a flex row with a long string of unbreakable text inside, the row overflows. Fix: set min-width: 0 on the flex item. Same for vertical flex with min-height: 0. This removes the automatic minimum-size constraint in the relevant axis; long unbreakable text may still need an overflow or wrapping policy.
The other classic: align-items: stretch is the default, which is why two cards in a row end up the same height even if their content differs. Set it to flex-start if you want each card to size to its own content.
<div class="row">
<div class="card">Short</div>
<div class="card">A much longer paragraph that would otherwise force the row to overflow.</div>
</div>
<style>
.row { display: flex; gap: 1rem; align-items: flex-start; }
.card { flex: 1 1 0; min-width: 0; padding: 1rem; border: 1px solid #ccc; }
</style>Good enough to ship
• Use flex for navbars, toolbars, button rows • Prefer gap for regular inter-item spacing; margins remain useful for individual alignment • Know justify vs align (main vs cross axis)
Expert tier
• Reach for min-width: 0 before someone files a bug • Understand flex-basis vs width and which wins • Use flex-wrap + min/max width for responsive without media queries
CSS Grid
Grid is a two-dimensional layout system: you define rows and columns, then place items into the cells. Reach for Grid when you have a true 2D layout (a dashboard with a header, sidebar, main, footer; a card gallery that wraps; a form with aligned label/control pairs). Reach for Flexbox when you have a 1D flow (a navbar, a button group).
The two killer Grid features: grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)) gives you a responsive card grid with zero media queries — columns auto-add up to the available width, each at least the smaller of 240px and the container width, then expanding to fill. And named grid areas (grid-template-areas: "header header" "sidebar main" "footer footer") let you describe the layout visually in CSS and reflow it for mobile by redefining the areas in a media query, without touching the HTML.
subgrid (supported by current mainstream browser engines; check your target matrix) lets a nested grid inherit the tracks of its parent — finally fixing the case where a child card's rows need to align with its siblings' rows.
<div class="dashboard">
<header>Header</header>
<nav>Sidebar</nav>
<main>Main</main>
<footer>Footer</footer>
</div>
<style>
.dashboard {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
min-height: 100vh;
}
header { grid-area: header; }
nav { grid-area: sidebar; }
main { grid-area: main; }
footer { grid-area: footer; }
@media (max-width: 640px) {
.dashboard {
grid-template-columns: 1fr;
grid-template-areas: "header" "main" "sidebar" "footer";
}
}
</style>Good enough to ship
• Use repeat(auto-fit, minmax(...)) for responsive card grids • Use grid-template-areas for page layouts • Know fr unit and gap
Expert tier
• Use subgrid to align nested cards with parent tracks • Mix auto-fit vs auto-fill correctly (different behaviour at the end) • Use container queries (@container) instead of media queries for component-level responsiveness
CSS custom properties (variables)
Custom properties (--brand: #0066cc) live in the CSSOM, cascade and inherit like regular properties, can be read and written at runtime with element.style.setProperty('--brand', '#ff0000'), and are useful for runtime theme tokens (Tailwind's dark mode, shadcn/ui's theme tokens, your own dashboard's colour palette). You consume them with color: var(--brand, fallback).
The killer pattern: scope custom properties to a parent class or attribute and switch the whole subtree by changing one variable. A dark mode toggle is one line: :root { --bg: white; --fg: black; } :root[data-theme="dark"] { --bg: black; --fg: white; }. Every component that reads var(--bg) updates automatically.
Custom properties are not Sass variables — they are dynamic, they cascade, they can be animated (well, with @property and a registered type). Use them where you would have reached for a CSS-in-JS theme provider five years ago.
Good enough to ship
• Define design tokens as :root --* variables • Toggle themes by swapping data-theme on document.documentElement • Use var() with fallbacks everywhere
Expert tier
• Register typed custom properties with @property for animation • Drive component variants from inherited variables • Animate gradients and shadows via interpolated variables
Responsive design and media queries
Responsive design is layout that adapts to the viewport. The mental model: mobile-first — write the base styles for the smallest screen, then layer on @media (min-width: ...) rules that add complexity as the viewport grows. This avoids the override pile-up you get when you start desktop-first and have to undo styles for mobile.
Pick breakpoints based on the content, not the device. The standard four (640px, 768px, 1024px, 1280px) are fine defaults. The real win in 2026 is container queries (@container (min-width: 480px) { ... }) — they let a component respond to its own container's width, not the viewport, which is what you actually want when a card is dropped into both a sidebar and a main column.
The viewport meta tag is non-negotiable: <meta name="viewport" content="width=device-width, initial-scale=1">. Without it, mobile browsers assume a 980px desktop layout and shrink everything to fit, breaking touch targets and font sizes. Test on a real device the first time, not just Chrome DevTools' responsive mode — iOS Safari has a hundred quirks Chrome will not show you.
Good enough to ship
• Mobile-first with min-width media queries • Viewport meta in every page • Touch targets ≥ 44×44 px
Expert tier
• Container queries over media queries for component design • prefers-reduced-motion, prefers-color-scheme, prefers-contrast respected • Use clamp() for fluid typography instead of step-based breakpoints
Modern JavaScript — let/const, arrow functions, destructuring, spread/rest
HTML and CSS now give our dashboard structure, accessible controls, and a layout that responds to available space. JavaScript adds the behaviour CSS cannot supply: loading data, reacting to input, and updating what the page displays. We will start with the bindings and data transformations those handlers use, then build toward modules, network requests, and the browser's scheduling model.
var is dead. Use const by default, let only when you reassign. const is block-scoped and cannot be reassigned; for (const x of arr) creates a fresh binding per iteration. It is in a temporal dead zone before initialization, not immune to declaration instantiation (“hoisting”). A const object’s properties may still change.
Arrow functions inherit this lexically from the enclosing scope, which makes them convenient for callbacks (arr.map(x => x * 2), setTimeout(() => doThing(), 100)). They have no arguments object, no prototype, and cannot be used as constructors — which is intentional. Reach for a function declaration when you need this to be dynamic (object methods, listeners where this should refer to the event target; class methods needing instance state usually require binding or an arrow closure).
Destructuring (const { name, age } = user, const [first, ...rest] = arr) and spread ({...defaults, ...overrides}, fn(...args)) collapse what used to be five lines of boilerplate into one. Combine with default values (const { theme = "light" } = props) and renaming (const { user: currentUser } = ctx) and you can read function signatures as data contracts.
const config = { host: "localhost", port: 3000, timeout: 5000 };
const { port, timeout = 10000, ...rest } = config;
// port = 3000, timeout = 5000 (provided wins over default), rest = { host: "localhost" }
const merged = { ...config, port: 4000 };
// later spread wins; merged.port === 4000Good enough to ship
• const by default, let only when reassigning • Destructure props, config, API responses • Spread to merge objects and arrays immutably
Expert tier
• Know when arrow vs function — this binding rules • Nested destructure with defaults and rename in one line • Be aware spread is shallow; reach for structuredClone for deep
Template literals, optional chaining, nullish coalescing
Template literals (`Hello, ${name}!`) replace string concatenation and support multiline strings out of the box. Tagged template literals (html`<div>${x}</div>`) hand the parts to a function and are how libraries like lit-html and styled-components work. A tag is just a function. SQL is safe only when that driver parameterizes values rather than interpolating them as SQL syntax; HTML tags likewise need an explicit escaping/sanitization contract.
Optional chaining (user?.address?.street) short-circuits to undefined if any link in the chain is null or undefined. It also works for method calls (callback?.()) and bracket access (arr?.[0]). Use it where you previously wrote user && user.address && user.address.street. The gotcha: it short-circuits on null and undefined, but not on empty strings, 0, or false. That's usually what you want.
Nullish coalescing (value ?? fallback) is || minus the falsy-but-valid bug. port || 3000 falls back to 3000 when port is 0; port ?? 3000 only falls back when port is null or undefined. Use ?? for "did the user provide a value", || for "is this truthy".
const port = userConfig.port ?? 3000; // user can set port: 0
const name = user?.profile?.displayName ?? user?.email ?? "anonymous";
const click = handlers.onClick?.(event); // no-op if not providedGood enough to ship
• ?? for defaults, ?. for safe navigation • Template literals for any string with a variable in it • Multiline string interpolation for prompts and queries
Expert tier
• Verify a tag’s escaping or parameterization contract; syntax alone gives no safety • Know ??= and ?.() and ?.[] forms • Parenthesize when combining ?? with || or &&; mixing them directly is a syntax error
ES modules (import / export)
ES modules are the standard module format in JavaScript. They are static (static import declarations expose dependencies before execution; the host resolves/loads them during module linking), single-instance (the module body executes once and exports are bindings, not copies), and strict-mode by default. The static-ness is what enables tree-shaking — bundlers can prove an export is unused and drop it.
Named exports (export const foo = ...) and default exports (export default Foo) coexist. Prefer named exports: they refactor better (rename works), they keep the import name honest, and they avoid the "what do I call this default" lottery. Dynamic imports (const mod = await import('./foo.js')) return a promise and are the basis for code-splitting and lazy loading in every modern bundler.
ESM and CommonJS differ in resolution and evaluation. ESM has live read-only import bindings, automatic strict mode and top-level await; CommonJS uses require and module.exports. CommonJS exports can be mutable objects, not universal snapshots. Node’s mode depends on extension, package type and supported syntax detection; declare type: "module" or use .mjs deliberately. ESM does not expose CommonJS __dirname; import.meta.url is the portable URL-based starting point.
// math.js
export const add = (a, b) => a + b;
export const PI = 3.14159;
// app.js
import { add, PI } from './math.js';
const lazy = await import('./heavy.js'); // code-splitGood enough to ship
• Named exports by default • Use dynamic import() for code-splitting • Know type="module" on script tags
Expert tier
• Distinguish ESM live bindings from CommonJS exports: a required object can remain shared and mutable • Import maps for browser-native module resolution • Build dual-publish packages (ESM + CJS) without breaking either
fetch, promises, async/await
fetch is the browser's built-in HTTP client. It returns a Promise<Response> that resolves on any HTTP response — including 404 and 500. Network errors and aborts reject the promise; HTTP error statuses do not. The mistake everyone makes the first month: assuming await fetch(url) throws on 500. It doesn't. Always check response.ok and throw yourself.
Promises are state machines: pending → fulfilled or rejected. async/await is syntactic sugar over .then()/.catch() — async functions always return a promise, await pauses the function (not the thread) until the promise settles, and try/catch around await catches both rejections and synchronous throws inside the awaited body. Promise.all([...]) waits for all (fast-fails on first reject); Promise.allSettled([...]) waits for all regardless; Promise.race([...]) settles with the first settlement, including rejection; Promise.any([...]) resolves with the first to fulfil.
The AbortController pattern is the modern way to cancel a fetch: create a controller, pass controller.signal to fetch, call controller.abort() later. This is critical inside React useEffect cleanups — without cleanup, obsolete requests can waste work and overwrite newer results. React 18+ removed the old unmounted-state-update warning; absence of a warning is not proof of correct lifecycle handling.
async function loadUsers(signal) {
const res = await fetch('/api/users', { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
const controller = new AbortController();
try {
const users = await loadUsers(controller.signal);
} catch (e) {
if (controller.signal.aborted) console.log('cancelled');
else console.error('load failed', e);
}Good enough to ship
• Check response.ok before parsing • async/await with try/catch • Promise.all for parallel fetches
Expert tier
• AbortController to cancel and on unmount cleanup • Stream the body with response.body.getReader() • Retry, backoff, and timeout primitives on top of fetch
The event loop — microtasks, macrotasks, rendering
The fetch example pauses a function while the request is pending, but the page still has to respond to clicks and paint loading feedback. What decides when the paused function resumes relative to those jobs? The event loop connects the promise model above to what a user actually sees: finishing a network request does not mean its continuation can interrupt JavaScript already running on the main thread.
One window agent executes JavaScript jobs sequentially; workers have separate agents. A useful event-loop approximation is: pull one task off the macrotask queue, run it to completion, then drain the entire microtask queue to exhaustion, then (if needed) render a frame, then pull the next macrotask. Macrotasks include script execution, setTimeout callbacks, setInterval callbacks, I/O callbacks, and message events. Microtasks include resolved promise callbacks (.then, await continuations) and queueMicrotask calls.
The order trap: Promise.resolve().then(() => console.log('A')) runs before setTimeout(() => console.log('B'), 0) — even with a zero timeout — because the promise callback is a microtask drained at the end of the current task, while the setTimeout callback is a macrotask scheduled for the next iteration. This ordering does not define React batching: that is a separate framework contract. Rendering opportunities and task-source selection are browser-scheduled, so timer-versus-animation-frame order is not universally fixed.
requestAnimationFrame(fn) schedules fn to run before the next paint, which is the right place to do any DOM mutation you want reflected visually in the same frame. setTimeout(fn, 16) is the wrong place — it can fire after the frame deadline and your animation will jank.
Mental model
Think of each tick as: 1 macrotask → drain microtasks → render (if due) → repeat. If you starve the loop with a synchronous 200ms loop, nothing else happens — no main-thread event handlers, paint updates or promise continuations. Network activity itself can still continue. Long-running work belongs in a Web Worker.
Good enough to ship
• Know promises run before setTimeout • Use requestAnimationFrame for animations • Avoid long synchronous loops on the main thread
Expert tier
• Reason about microtask starvation (infinite promise chain blocks render) • Feature-detect scheduler.postTask before using priorities; provide a fallback for unsupported browsers • Move CPU-heavy work to a Web Worker via postMessage
Iterators, generators, and for-await
An iterable is anything with a [Symbol.iterator]() method that returns an iterator (an object with next() returning { value, done }). Arrays, strings, Maps, Sets, and NodeLists are all iterable — which is why for...of works on all of them. Plain objects are not iterable by default; you reach for Object.entries(obj) to get an iterable of [key, value] pairs.
Generators (function*) are a concise way to write iterators — yield pauses, the next .next() call resumes. Async iterators add Symbol.asyncIterator and for await...of, which is how you consume streaming sources cleanly: a paginated API, a server-sent events stream, a Node.js Readable. Response.body is an async iterable — for await (const chunk of res.body) { ... } lets you stream bytes without buffering the full response.
async function* paginate(url, signal) {
let cursor = undefined;
const seen = new Set();
do {
const u = new URL(url, location.href);
if (cursor !== undefined) u.searchParams.set('cursor', cursor);
const res = await fetch(u, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { items, next } = await res.json();
if (!Array.isArray(items) || (next != null && typeof next !== 'string'))
throw new Error('invalid page');
for (const item of items) yield item; // validate item shape for your domain
cursor = next || undefined;
if (cursor && seen.has(cursor)) throw new Error('repeated cursor');
if (cursor) seen.add(cursor);
} while (cursor);
}
for await (const item of paginate('/api/things')) {
process(item);
}Good enough to ship
• for...of over arrays, Maps, Sets, NodeLists • Object.entries / keys / values for plain objects • for await...of for paginated APIs and streams
Expert tier
• Write generators for lazy sequences • Implement Symbol.iterator on custom collections • Combine with AbortController for cancellable streams
DOM APIs you actually use
React buries the DOM, but you still need it for refs, integrations with non-React libraries, and the moments React doesn't cover. The set worth memorising: document.querySelector / querySelectorAll (the latter returns a static NodeList — won't auto-update); element.classList (add / remove / toggle / contains); element.dataset (read and write data-* attributes); element.addEventListener(type, fn, options) with { once, passive, capture, signal } options.
Observers replace polling for DOM state: IntersectionObserver for lazy-load / scroll-spy / virtualised lists; ResizeObserver for elements that need to react to their own size changes (chart libraries depend on this); MutationObserver for watching subtree changes (rare; usually a smell that you should own that subtree).
The signal option on addEventListener (supported by modern engines; check older embedded browsers) is the cleanest cleanup pattern: pass controller.signal, call controller.abort() once to detach every listener at once — perfect for React effects.
const c = new AbortController();
btn.addEventListener('click', onClick, { signal: c.signal });
window.addEventListener('resize', onResize, { signal: c.signal });
// cleanup:
c.abort(); // both listeners detachedGood enough to ship
• querySelector, classList, dataset, addEventListener • IntersectionObserver for lazy load • ResizeObserver for size-aware components
Expert tier
• Use AbortController signal to cleanup listeners • Passive listeners for scroll/touch perf • Event delegation on a root element instead of N listeners
setTimeout, setInterval, requestAnimationFrame
setTimeout(fn, ms) and setInterval(fn, ms) queue a macrotask after at least ms milliseconds. The "at least" matters: browsers clamp to 4ms minimum for nested timers, and a busy main thread can push the actual delay to seconds. Prefer requestAnimationFrame for animation because it is synchronized to rendering opportunities. Timer delivery can be delayed or throttled; an interval is not a frame clock. Use requestAnimationFrame for visuals. For asynchronous polling, schedule the next timeout after the current request settles, rather than allowing an interval to overlap slow requests.
clearTimeout / clearInterval cancel a pending timer. Always cancel timers in cleanup functions, especially inside React effects — otherwise a closure holds a reference to stale state and your "update once per second" code becomes "update 47 times per second after the seventh re-mount". The same applies to event listeners (always remove what you add) and observer callbacks (always disconnect).
For asynchronous polling, let each request settle before arming the next timer. Here poll(signal) is an application function that passes the signal to its request; keep it stable or include it in the effect dependencies.
useEffect(() => {
const controller = new AbortController();
let timer;
let stopped = false;
async function tick() {
try {
await poll(controller.signal);
} catch (error) {
if (!controller.signal.aborted) console.error('poll failed', error);
} finally {
if (!stopped) timer = setTimeout(tick, 5000);
}
}
void tick();
return () => {
stopped = true;
clearTimeout(timer);
controller.abort();
};
}, [poll]);Cleanup stops future scheduling and requests cancellation of the active request. poll must also avoid committing obsolete results; abort cannot undo a server write.
Good enough to ship
• rAF for animation; schedule asynchronous polling after the previous request settles • Always cancel timers in cleanup • Know setTimeout(fn, 0) is not zero
Expert tier
• Drift-corrected interval (compute next from start, not from last fire) • Use Page Visibility API to throttle when tab is hidden • queueMicrotask vs setTimeout vs rAF — pick the right primitive
Worked examples
Example 1 — a fully accessible toggle button
Build a toggle button that announces its state to screen readers, is keyboard-operable, and respects prefers-reduced-motion.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Accessible toggle</title>
<style>
:root {
--bg: #fff; --fg: #111;
--accent: #0066cc; --accent-fg: #fff;
--radius: 6px;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) { --bg: #111; --fg: #eee; }
}
:root[data-theme="dark"] { --bg: #111; --fg: #eee; }
:root[data-theme="light"] { --bg: #fff; --fg: #111; }
body { background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui; padding: 2rem; }
button.toggle {
display: inline-flex; align-items: center; gap: .5rem;
min-height: 44px; padding: .5rem 1rem;
background: transparent; color: var(--fg);
border: 1px solid currentColor; border-radius: var(--radius);
cursor: pointer; transition: background .15s ease;
}
button.toggle[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
button.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
button.toggle { transition: none; }
}
</style>
</head>
<body>
<button type="button" class="toggle" aria-pressed="false" id="dark">
<span aria-hidden="true">🌙</span>
<span>Dark mode</span>
</button>
<script type="module">
const btn = document.getElementById('dark');
const initialDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
btn.setAttribute('aria-pressed', String(initialDark));
document.documentElement.dataset.theme = initialDark ? 'dark' : 'light';
btn.addEventListener('click', () => {
const pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
document.documentElement.dataset.theme = !pressed ? 'dark' : 'light';
});
</script>
</body>
</html>type="button"prevents accidental form submission.aria-pressedmakes the button a toggle; screen readers announce "pressed" or "not pressed".min-height: 44pxis this example’s generous target choice, not a universal WCAG threshold.:focus-visiblefollows browser focus-indication heuristics, often showing a ring for keyboard input; it is not a literal keyboard-only detector.prefers-reduced-motionrespects the user's OS setting.
Example 2 — a responsive dashboard layout with Grid
A three-area layout (header, sidebar, main) that collapses to a single column on mobile, with no JavaScript.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font: 16px/1.5 system-ui; }
.app {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar main";
min-height: 100vh;
}
.app > header { grid-area: header; padding: 1rem; background: #eef; }
.app > nav { grid-area: sidebar; padding: 1rem; background: #f4f4f8; border-right: 1px solid #ddd; }
.app > main { grid-area: main; padding: 1rem; min-width: 0; }
.cards {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}
.card { padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
@media (max-width: 640px) {
.app {
grid-template-columns: 1fr;
grid-template-areas: "header" "main" "sidebar";
}
.app > nav { border-right: 0; border-top: 1px solid #ddd; }
}
</style>
</head>
<body>
<div class="app">
<header>Dashboard</header>
<nav>Sidebar links</nav>
<main>
<div class="cards">
<div class="card">Metric 1</div>
<div class="card">Metric 2</div>
<div class="card">Metric 3</div>
<div class="card">Metric 4</div>
</div>
</main>
</div>
</body>
</html>grid-template-areasdescribes the layout visually — the media query just redefines the areas.repeat(auto-fit, minmax(min(240px, 100%), 1fr))is the responsive-grid-without-media-queries pattern.min-width: 0onmainis what stops long content from forcing horizontal scroll.- Global
box-sizing: border-boxmakes width arithmetic predictable.
Example 3 — fetch with abort and timeout
This helper honors an already-aborted caller signal, removes its listener after completion and avoids including potentially private response bodies in errors. It returns unvalidated JSON; the caller still needs a parser.
async function httpGet(url, { signal, timeoutMs = 8000 } = {}) {
const ctrl = new AbortController();
const forward = () => ctrl.abort(signal.reason);
if (signal?.aborted) forward();
else signal?.addEventListener('abort', forward, { once: true });
const timer = setTimeout(() => ctrl.abort(new Error('timeout')), timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal,
headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', forward);
}
}Exercise: supply a signal that was aborted before calling httpGet. Solution: the new controller is aborted immediately; adding a listener alone would miss the already-fired event. Mock fetch to assert signal.aborted, then test HTTP 404 and malformed JSON separately.
Example 4 — debounced input handler without dependencies
Type-as-you-search inputs need debouncing — wait until the user stops typing, then fire one request. Vanilla JS, ten lines.
function debounce(fn, wait = 250) {
let t;
return function debounced(...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
}
const input = document.querySelector('input[type="search"]');
const status = document.getElementById('status');
const search = debounce(async (q) => {
if (!q) { status.textContent = ''; return; }
status.textContent = 'searching…';
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
status.textContent = `${data.length} results`;
} catch {
status.textContent = 'failed';
}
}, 300);
input.addEventListener('input', (e) => search(e.target.value));clearTimeout(t)before re-arming is what makes it debounced (vs throttled).- The closure captures
tper-debouncer instance, so multiple debouncers don't collide. encodeURIComponenton user input is non-negotiable for query strings.- A live region (
aria-live="polite"on#status) announces result counts. Debouncing alone does not cancel an in-flight request or prevent stale responses: use a request-generation counter or the active-effect pattern in chapter 9 before adopting this sketch.
Hands-on exercises
-
Semantic-only rewrite. Goal: rewrite a div-soup page using semantic HTML and zero ARIA.
- Find a personal project (or a CodePen) that uses
<div>for everything. - Replace each
<div>with the right semantic element (<header>,<nav>,<main>,<article>,<section>,<aside>,<footer>). - Make sure every heading level (
<h1>→<h6>) is in order. - Run WAVE and axe DevTools and fix every violation.
- You're done when: axe shows zero serious or critical violations and you can tab through the page reaching every interactive element with a visible focus ring.
- Find a personal project (or a CodePen) that uses
-
CSS cascade puzzle. Goal: predict which rule wins on paper, then verify.
- Pick five components from a real site (use DevTools).
- For each, screenshot the Styles pane and write down which rule wins and why.
- Toggle off the winning rule — confirm the next rule takes over.
- Try to override the winning rule from a new stylesheet without
!important. - You're done when: you can predict the winner without opening DevTools 8/10 times.
-
Grid dashboard scaffold. Goal: build a 3-column responsive dashboard from scratch.
- Start with a blank HTML file. Add a header, sidebar, main, footer.
- Use
grid-template-areasfor the layout. - Add a card grid in main using
repeat(auto-fit, minmax(min(240px, 100%), 1fr)). - Make it collapse to single column under 640px.
- You're done when: you can resize the browser from 320px to 1920px without any element overflowing or breaking.
-
fetch with retry and abort. Goal: write a
httpGethelper with timeout, retry, and abort support.- Start with the Worked Example 3 helper.
- For this GET-only helper, use three attempts with backoff delays of 200ms then 400ms (start times near 0, 200, 600ms only when requests take negligible time). Keep abort and total deadline checks during backoff.
- Do not retry on 4xx, malformed JSON, caller cancellation or an expired total deadline. Check the signal/deadline, not just the error name: abort reasons can be custom values.
- Write 3 tests with a mocked fetch: success, 500-then-success, persistent 500.
- You're done when: tests pass and the helper is under 60 lines.
-
Event-loop quiz machine. Goal: build a mental model of microtask vs macrotask order.
- In a
<script type="module">, write code that logs1, then schedules2viasetTimeout(_, 0),3viaPromise.resolve().then,4viaqueueMicrotask,5viarequestAnimationFrame, then logs6. - Predict the output order on paper. Then run it.
- If you got it wrong, draw the queue state after each step.
- Try wrapping the body in a button click handler — does the order change?
- You're done when: you can distinguish guaranteed order from browser-dependent rendering timing, rather than claiming one total order for timers and rAF.
- In a
-
Lighthouse hardening. Goal: take a real page from a Lighthouse score of ~80 to ≥95 on Performance and Accessibility.
- Run Lighthouse against a personal page; record the score.
- Fix the top 5 issues (likely: missing alt text, low-contrast text, no
<title>, render-blocking CSS, oversized images). - Add a viewport meta and a meaningful
<title>. - Compress hero images and serve them as
webp/avif. - You're done when: Lighthouse scores ≥95 on both Performance and Accessibility across two runs.
Self-check questions
- Why is a
<button>better than a<div onClick>even if the styling is identical? - Explain the cascade: in what order does the browser resolve which rule wins?
- What is margin collapse, and where does it not happen?
- When do you reach for Flexbox vs Grid? Give one example of each.
- What is the
min-width: 0trick on flex items, and what problem does it solve? - Difference between
||and??. Give a case where they produce different results. - Why does
Promise.resolve().then(...)run beforesetTimeout(..., 0)? - What does
AbortControllersolve, and why do React effects need it? - Difference between ESM and CommonJS — list at least three.
- What does
prefers-reduced-motiondo, and when must you respect it? - Why is
setIntervala bad fit for visual animations? - Walk through the event loop for one tick: macrotask, microtask drain, render — what runs in what order?
- What is event delegation, and when is it preferable to attaching N listeners?
- Explain
IntersectionObserverand one use case that would otherwise need scroll listeners. - Difference between a
for...ofloop and afor...inloop — and why you almost never wantfor...inon an array.
Worked assessment answers
Cascade puzzle: in @layer base, components, .x { color: red !important } in base beats #x { color: blue !important } in components: important layer order reverses before specificity is considered. Without important, components wins. Within the same layer, one ID outranks any number of classes.
Event-loop answer: for the quiz, synchronous 1, 6 precede queued microtasks 3, 4, which are FIFO within that checkpoint. Timer 2 and rAF 5 have no universal relative order. A recursive microtask chain can delay both; rendering does not occur after every task unconditionally.
Layout answer: at 320px, a 240px minimum track plus padding can overflow. minmax(min(240px, 100%), 1fr) caps the minimum at available space, minmax(0, 1fr) permits the main track to shrink, and overflow-wrap: anywhere may still be needed for long tokens. Visual reordering with Grid does not reorder keyboard traversal: keep DOM order meaningful.
Fetch answer: HTTP 404 fulfills fetch but fails ok; malformed JSON rejects during parsing; cancellation can use a custom reason rather than an AbortError. The httpGet helper forwards already-aborted signals and cleans its timer/listener. A retry test must distinguish idempotent transport retry from repeating an unsafe server operation.
Self-check key: (1) native buttons supply semantics and keyboard behavior; (2) cascade precedence comes before specificity; (3) normal-flow vertical margins can collapse, flex/grid item margins do not; (4) Flexbox is one-dimensional, Grid aligns two dimensions; (5) min-width: 0 releases an automatic minimum; (6) 0 || 3000 differs from 0 ?? 3000; (7) promise jobs precede the next timer task; (8) abort stops unwanted client work, not a committed server write; (9) ESM has static declarations/live bindings/strict mode; (10) reduced-motion requests less nonessential movement; (11) intervals are not a frame clock; (12) a microtask checkpoint precedes the next task, rendering is conditional; (13) delegation handles bubbling events at an ancestor, with target filtering; (14) IntersectionObserver reports intersection changes asynchronously, useful for lazy loading; (15) for...of visits iterable values while for...in enumerates enumerable string property keys, including inherited ones.
A zero-error axe/Lighthouse run is useful evidence, not WCAG conformance. Manually test name/role/state, keyboard operation, zoom, reading order and recovery after async errors.
High-signal resources
Official docs
- MDN Web Docs — HTML, CSS, JavaScript — the canonical reference.
- WAI-ARIA Authoring Practices — patterns for custom widgets.
- WHATWG HTML spec — when MDN isn't precise enough.
- CSS Working Group drafts — what's coming next.
Books or courses
- CSS in Depth by Keith Grant (2nd ed., Manning, 2024).
- Eloquent JavaScript by Marijn Haverbeke (eloquentjavascript.net) — free, current.
- web.dev Learn courses — Google's structured tutorials on HTML, CSS, Accessibility, Forms, PWA.
Practitioner posts
- Josh Comeau — An Interactive Guide to Flexbox.
- Jake Archibald — In The Loop (JSConf talk on event loop).
- web.dev — CSS 2024 overview — historical feature overview; check current compatibility tables for your targets.
- Lea Verou — Custom Properties as State.
- Heydon Pickering — Inclusive Components — every common widget done right.
Weekly milestones
- Day 1 — Read the MDN HTML element reference top to bottom (it's shorter than you think). Do Exercise 1.
- Day 2 — Read MDN's CSS Cascade and Specificity pages. Do Exercise 2.
- Day 3 — Do Josh Comeau's Flexbox guide. Read MDN Grid layout. Do Exercise 3.
- Day 4–5 — Read web.dev's JavaScript course. Do Exercises 4 and 5. Answer self-check 1-6.
- Day 6–7 — Watch Jake Archibald's event loop talk. Do Exercise 6. Answer self-check 7-15.
If you finish early, drop into a CodePen and rebuild any single-page site you visit often (a news site front page, a docs site, a settings panel) from scratch in semantic HTML with no JS. The exercise of constraining yourself to the platform is where the reflexes form.
How it shows up in the capstone
The analytics dashboard is, at its bones, a semantic HTML document: a <header> with the app shell, a <nav> sidebar with route links, a <main> containing a grid of <section>s (each a chart card), and an <aside> for filters. Every interactive control — date pickers, drilldown buttons, dropdowns — uses native elements or, when not possible, follows an ARIA pattern verbatim. The whole thing must pass axe with zero violations and be operable with a keyboard only.
Layout is CSS Grid for the page shell, Flexbox for in-card toolbars, container queries for cards that may sit in a sidebar or main column with different widths. Theme tokens (--bg, --fg, --accent, --grid-line) are custom properties so dark mode is a one-attribute swap with no React re-render. fetch is wrapped in the timeout/abort helper from Worked Example 3 and lives behind TanStack Query in chapter 10 — but the helper itself is plain fetch, because TanStack Query manages asynchronous server-state lifecycles around a supplied promise-returning function; it is not restricted to fetch.
You will feel this chapter most when you debug a layout bug at 11pm and the answer turns out to be "the flex item didn't have min-width: 0". The minute that wiring becomes reflex, you stop fighting the browser and start using it. Every later chapter (TypeScript, Vite, React, routing, charts) layers on top of this foundation. The same fetch is what TanStack Query wraps. The same Grid and Flexbox are what your React components render into. The browser event loop runs the tasks and promise jobs React relies on; React controls effect scheduling, so browser queue rules alone do not determine when an effect runs. Without this layer, everything above it is cargo cult.
Next chapter → Ch 7 — TypeScript Previous chapter → Ch 5 — ASP.NET Core
Corrected contracts and failure analysis
A native button already has keyboard activation semantics. Use a stable label plus aria-pressed for a toggle, and keep visual state synchronized with the same boolean. ARIA does not implement behavior. Responsive grids must accommodate zoom, long strings and keyboard focus, not merely a narrow screenshot.
Fetch fulfills for HTTP errors. Check response.ok before parsing, then validate the returned data. An AbortController can cancel a request you no longer need; abort is not a server-side transaction rollback. Clear timeout handles in finally. Microtasks run before the browser gets another rendering opportunity, so recursively scheduling microtasks can starve painting. A zero-delay timer is eligible after a delay, not guaranteed to run immediately. Avoid layout reads interleaved with writes in a tight loop.
Boundary exercise with solution
A theme button has aria-pressed=true but its label flips between “Dark mode” and “Light mode.” What is a clearer contract?
Solution and reasoning
Keep “Dark mode” stable and derive aria-pressed and styling from whether dark mode is active. Alternatively use an action button such as “Switch to light mode” without aria-pressed. Test keyboard activation, focus visibility and persisted state separately.
Source-backed review notes
- Using the Fetch API - Web APIs | MDN — accessed 2026-10-07. Exact supporting passage: “The fetch() function will reject the promise on some errors, but not if the server responds with an error status like 404: so we also check the response status and throw if it is not OK.”
- ARIA: aria-pressed attribute - ARIA | MDN — accessed 2026-10-07. Exact supporting passage: “The aria-pressed attribute indicates the current "pressed" state of a toggle button.”
Version-specific reference contracts
Documentation snapshots accessed 7 October 2026; mutable service/browser documentation is timestamped evidence, not an immutable product-version pin. Exact supporting passages and hashes accompany the review ledger.
- Introduction to the CSS cascade - CSS | MDN — Cascade origins/importance/layers precede specificity; transitions can outrank important values.
- Specificity - CSS | MDN — Specificity is a lexicographic tuple, not base-ten weight arithmetic.
- unset CSS keyword - CSS | MDN — unset chooses inheritance or initial, not a prior layer.
- revert CSS keyword - CSS | MDN — revert rolls back the current origin, not simply to an earlier author declaration.
- JavaScript execution model - JavaScript | MDN — Microtasks drain before the next task; workers have separate agents.
- JavaScript modules - JavaScript | MDN — ESM imports are live bindings and module linking differs from evaluation.
- <dialog> HTML dialog element - HTML | MDN — Native modal dialog supplies modal interaction semantics absent from a role-only div.
Review and execution boundary
The 9 October 2026 follow-up re-read this complete lesson, corrected the polling and summary contradictions, and refreshed the timer guidance, Promise.race contract and postTask availability. Exact offline fixtures are reported separately from real HTTP, React lifecycle and assistive-technology execution, which were not executed in this follow-up.
Browser-native HTML/CSS/JavaScript; feature-detect newer AbortSignal helpers; focused offline browser fixtures only, not browser conformance certification.
Reviewed on 7 October 2026 against the official source snapshots linked below. Whole-current and original source review supports the teaching contracts; focused fixture results do not certify every browser or assistive technology. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.