This is the first of four parts. It covers the ground you need before anything else makes sense: HTML, CSS, JavaScript, forms, responsive design, and the network underneath all of it.
The aim is not definitions — those are a search away. The aim is the mental model: the difference between having used a feature and knowing why it behaves the way it does.
Semantic HTML means choosing elements based on what the content is, not what it looks like.
A <button> is a button because it does button things: it takes focus, responds to the keyboard, and announces as a button. A <div onclick> can look identical and comes with none of that.
Three audiences read your markup besides the rendering engine:
- Assistive technology builds its navigation from the accessibility tree.
<nav>, <main>, <header> and <article> become landmarks a screen reader user can jump between. A page of <div>s is a flat wall with nothing to aim at.
- The browser hands you behaviour you would otherwise rebuild badly —
<form> gives you submit-on-Enter and native validation, <details> gives you a disclosure widget with no JavaScript, <a href> gives you middle-click-to-open-in-a-new-tab.
- Parsers, search engines among them, read the document outline to work out what the page is about.
The common instinct runs backwards: reach for <div>, then patch the gaps with role and tabindex. Start from the semantic element, drop to a generic one only when nothing fits, and add ARIA last. It is a repair tool, not a building material.
Headings are the other regular mistake. <h1>–<h6> describe the document outline, not font size. Skipping <h1> to <h4> because the smaller text looks better is a real accessibility bug. Structure with the right level, style with CSS.
The DOM is the in-memory tree the browser builds from your parsed HTML. It is neither your source file nor the pixels on screen — it is a live object model between the two.
That distinction explains a few things:
- "View source" and "inspect element" disagree. The first shows the original HTML, the second shows the DOM as it stands now, after scripts have changed it.
childNodes and children disagree. Every node is an object — elements, text, comments — and the first includes text nodes while the second does not. Whitespace between tags becomes a real text node, so an element with three visible children may report seven.
- The DOM is not what gets painted. It combines with the CSSOM to build the render tree, and that is what gets laid out.
Touching the DOM is cheap or expensive depending on what you touch. Changing a property is cheap. Reading offsetHeight or getBoundingClientRect forces the browser to compute layout before it can answer.
Alternating reads and writes in a loop makes it recalculate every time. That is layout thrashing, and batching all reads before all writes is the fix.
The cascade settles which value wins when several rules target the same element and property. It asks three questions in order:
- Origin and importance. Weakest to strongest: user-agent normal, user normal, author normal, author important, user important, user-agent important. Note that
!important inverts the layers.
- Specificity. Among rules of equal origin and importance, the more specific selector wins.
- Source order. If specificity ties, the later rule wins.
This is why load order matters, and why a utility class can lose to a more specific selector even though it comes later in the file.
Reaching for !important is usually the sign of a cascade fight you have not diagnosed. Once the order is clear, a cleaner selector wins it instead.
Specificity is a three-part tuple, (a, b, c): a counts IDs, b counts classes, attributes and pseudo-classes, c counts elements and pseudo-elements. Compare left to right, so one ID beats any number of classes.
1#sidebar a /* (1, 0, 1) — wins */
2.nav .link /* (0, 2, 0) */
3nav a /* (0, 0, 2) */
Inline styles sit above all three parts. !important sits above inline styles.
Three rules are less obvious:
* and the combinators (>, +, ~, descendant space) contribute nothing.
:not() and :is() add nothing themselves but inherit the specificity of their most specific argument — so :not(#id) quietly scores as an ID.
:where() is always zero, whatever is inside it. That makes it the right tool for defaults you intend to be overridden.
Keep specificity flat and low. High specificity is a debt: every selector that wins by force makes the next one harder to write.
Every element is four concentric areas — content, padding, border, margin. The part that causes bugs is how width is interpreted, controlled by box-sizing.
1/* content-box (the default): padding and border are added on top */
2.a { box-sizing: content-box; width: 200px; padding: 20px; border: 2px solid; }
3/* occupies 244px */
4
5/* border-box: width is the final number */
6.b { box-sizing: border-box; width: 200px; padding: 20px; border: 2px solid; }
7/* occupies 200px */
Almost every codebase sets border-box globally, because sizing that matches the number you asked for is worth more than matching the original specification. Margin sits outside the box either way and is never counted in width.
Margin collapsing is the other classic surprise. Vertical margins between adjacent block elements collapse to the larger of the two rather than adding. It happens only vertically, only between block-level boxes in normal flow, and it is prevented by padding, a border, or a new formatting context — flex and grid children never collapse.
Flexbox lays items out along a single axis. flex-direction sets the main axis, justify-content distributes along it, align-items aligns across it, and align-content handles wrapped lines.
Understanding usually shows up in the flex shorthand:
1flex: 1; /* 1 1 0% — ignore content size, share space equally */
2flex: auto; /* 1 1 auto — start from content size, share what is left */
With a basis of 0, every item starts from nothing and space divides proportionally, so items end up equal regardless of content. With auto, content size is honoured first and only the leftover is distributed.
flex-shrink is the one people forget. Items shrink by default, and flex items carry an implicit min-width: auto that stops them shrinking below their content — which is exactly why a long string blows out of its container instead of wrapping. Setting min-width: 0 on the item clears a whole family of overflow bugs.
Grid works in two dimensions: define rows and columns, place items into the cells.
1.gallery {
2 display: grid;
3 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
4 gap: 1rem;
5}
fr is a share of leftover space, so 1fr 2fr splits it one to two.
minmax(200px, 1fr) lets a track flex between a floor and a ceiling.
auto-fill vs auto-fit catches almost everyone. Both create as many tracks as fit. auto-fill keeps the empty ones, holding the grid stable; auto-fit collapses them so remaining items stretch.
- Implicit tracks appear when you place more items than declared cells, sized by
grid-auto-rows and grid-auto-columns.
Rule of thumb: grid for the skeleton of a page or component, flexbox for the contents of a region. A grid cell holding a flex container is one of the most common things you will write.
Seven primitives — string, number, bigint, boolean, undefined, symbol, null — plus object, which covers arrays, functions, dates and everything else.
The distinction that matters daily: primitives are immutable and copied by value; objects are held by reference. Copying a variable that holds an object copies the reference, so two variables can point at the same thing.
Four specific behaviours are worth memorising, mostly because they are inconsistent rather than deep:
typeof null returns "object" — a bug old enough that fixing it would break the web.
NaN is of type number and is not equal to itself. NaN === NaN is false; test with Number.isNaN().
undefined is what the language hands you when nothing was assigned. null is what you assign deliberately to mean "nothing here".
== applies coercion rules convoluted enough that the practical advice is to use === and convert explicitly.
JavaScript runs on a single thread with one call stack. When the stack empties, the event loop takes the next queued work and runs it to completion.
There are two queues, and the ordering between them is the whole story:
- Macrotasks —
setTimeout, setInterval, I/O, UI events — run one per turn of the loop.
- Microtasks — promise callbacks,
queueMicrotask, MutationObserver — drain completely after each macrotask, before the next one and before rendering.
1console.log(1);
2setTimeout(() => console.log(2), 0);
3Promise.resolve().then(() => console.log(3));
4console.log(4);
5
6// 1, 4, 3, 2
Synchronous code first (1, 4), then the microtask queue drains (3), then the timeout (2).
The same reasoning explains why setTimeout(fn, 0) does not run immediately: it cannot run until the stack is clear, it queues behind pending microtasks, and the spec clamps deeply nested timeouts to roughly four milliseconds. Zero means "as soon as reasonably possible", not "now".
A promise represents the eventual result of an async operation. It is pending until it settles — fulfilled with a value or rejected with a reason — and once settled it never changes.
.then returns a new promise, which is what makes chaining work: whatever you return from one .then becomes the fulfillment value of the next. A single .catch at the end catches errors from any step above it. .finally runs either way, passing the value or error through untouched.
Three behaviours surprise people:
- The executor runs synchronously. The function you hand to
new Promise runs immediately; only the callbacks are deferred.
.then callbacks always run as microtasks, never synchronously, even when the promise has already resolved.
- A rejection with nothing to catch it produces an unhandled rejection.
async/await is syntax over promises. An async function always returns a promise. await suspends the function until the awaited promise settles.
The word "pause" misleads people. await does not block the thread — it suspends that one function, hands control back to the event loop, and schedules the continuation as a microtask. Everything else carries on.
The mistake worth watching for is awaiting inside a loop when the operations do not depend on each other:
1// Sequential — each request waits for the one before it
2for (const id of ids) {
3 results.push(await fetchUser(id));
4}
5
6// Concurrent — all requests start, then you wait once
7const results = await Promise.all(ids.map(fetchUser));
Sequential is right when a step genuinely needs the previous result. When the steps are independent, the loop turns one wait into a queue of them.
A plain <form> with correct markup gives you submit-on-Enter, a submit event, native validation and accessible labelling before you write a line of script.
Four things are non-negotiable to get that for free:
- A
<label> for every input, either wrapping it or linked through for and id.
- A
type that matches the data (email, tel, url, number, date), which changes the mobile keyboard and brings validation with it.
- Validation attributes —
required, min, pattern, minlength — which drive both the browser's checks and the :invalid / :valid pseudo-classes.
- A
name on every field, because names become the keys when the form serialises. A field without one is simply not submitted.
That markup keeps working when JavaScript fails to load. It is the baseline you enhance from, not a fallback you bolt on.
Script goes on top for what the browser does not do alone: custom validation messaging, submitting without a reload, fields that appear and disappear.
event.preventDefault() on submit stops the native navigation so you can take over with fetch.
new FormData(formElement) reads every named field at once and handles file inputs and multipart encoding.
- The Constraint Validation API —
checkValidity(), setCustomValidity(), the validity object — lets you hook into the browser's validation rather than rebuilding it.
- Controlled vs uncontrolled, in React terms, is simply whether state drives the input's value or the DOM holds it until you read it on submit.
Enhance rather than replace. Keep the markup semantic so the form still works when your script does not.
Media queries apply styles conditionally, most often on viewport width. The usual strategy is mobile-first: base styles for the smallest screen, then layer enhancements with min-width.
1.layout { display: grid; gap: 1rem; } /* base: one column */
2
3@media (min-width: 48rem) {
4 .layout { grid-template-columns: 1fr 1fr; } /* wider: two */
5}
Mobile-first wins for practical reasons: the base case is the simplest, each query adds rather than overrides, and the least capable devices download the least CSS.
Width is not the only thing you can ask about. prefers-color-scheme drives dark mode. prefers-reduced-motion tells you somebody has asked for less animation and should always be honoured. hover and pointer distinguish a mouse from a touchscreen.
Much of this no longer needs a media query at all. clamp() handles fluid type, minmax() with auto-fit handles fluid grids, and flex wrapping adapts on its own — all continuously, rather than jumping at thresholds. Container queries go further, letting a component respond to its own container's width, which is what you actually want for a component used in several slots.
Breakpoints still earn their place for page-level layout. They are simply no longer the only tool.
A client sends a method, path, headers and sometimes a body; the server returns a status code, headers and sometimes a body.
Methods carry meaning, and that meaning determines what is safe to retry:
| Method | Does | Idempotent |
|---|
GET | Reads, no side effects | Yes |
POST | Creates or triggers | No |
PUT | Replaces | Yes |
PATCH | Updates part | No |
DELETE | Removes | Yes |
Idempotent means repeating it leaves you in the same state — which is why a client can safely retry after a timeout, and generally cannot retry a POST.
Status codes group by first digit:
- 2xx succeeded.
- 3xx redirects —
301 permanent, 302/307 temporary, 304 not modified.
- 4xx blames the client —
400 malformed, 401 not authenticated, 403 authenticated but not allowed, 404 missing, 429 rate limited.
- 5xx blames the server.
HTTP is stateless. Any sense of continuity — being logged in, having a cart — is rebuilt on every request from cookies, tokens or a session.
Caching works at two levels: freshness and validation.
Freshness is controlled by Cache-Control:
max-age=N — lifetime in seconds.
no-cache — the most misread value. It means cache but revalidate before reusing, not "do not cache". That one is no-store.
private — browser caching yes, shared CDN caching no.
immutable — will not change during its lifetime, so the browser will not revalidate even on reload.
stale-while-revalidate — serve a slightly stale copy now, refresh in the background.
Validation is the fallback once freshness expires. The server sends an ETag (a fingerprint) or Last-Modified. The browser echoes it back as If-None-Match or If-Modified-Since, and an unchanged resource gets a 304 with no body.
The difference in cost is the point. A fresh max-age hit saves the entire round trip, because the browser never asks. Validation only saves the body. That gap is why long max-age values matter for static assets.
A CDN is a set of edge servers that cache your content near your users. Physics puts a floor under how fast data crosses the planet, so serving Delhi from Mumbai rather than Virginia removes most of the round trip on its own.
Distance is not the only benefit:
- Origin offload — cached responses never reach your server.
- Origin shielding — a mid-tier cache so a wave of edge misses does not all hit the origin at once.
- TLS termination at the edge — the expensive part of connection setup happens close to the user.
Underneath, a CDN is a cache following the same freshness and validation rules as the browser, which is why Cache-Control — particularly public and s-maxage — is what steers it.
HTTP/1.1 had a structural problem: one request at a time per connection. Browsers opened around six parallel connections per origin, which helped but left head-of-line blocking, where one slow response stalls everything behind it.
HTTP/2 fixes this with:
- Multiplexing — many requests and responses over one connection as independent interleaved streams, removing the six-connection ceiling.
- Binary framing — faster to parse, and what makes multiplexing possible.
- HPACK header compression — repeated cookies and user-agent strings stop costing full size every request.
The catch: HTTP/2 still runs over TCP, and TCP guarantees ordered delivery, so a single lost packet stalls every stream at the transport layer. HTTP/3 moves to QUIC over UDP precisely to fix this. HTTP/2 fixed the application layer; HTTP/3 fixed the transport layer.
Latency is round-trip time, and it is the part of network performance more bandwidth cannot fix. The techniques divide in two.
Cut the number of round trips — fewer requests, HTTP/2 multiplexing, reused connections, and resource hints:
dns-prefetch resolves DNS ahead of time.
preconnect opens the TCP and TLS handshake early.
preload fetches something critical before the parser reaches it.
prefetch picks up resources for a likely next navigation while idle.
Send fewer bytes — Brotli where supported and gzip where not, responsive images so a phone does not download a desktop file, code splitting so the initial payload carries only the first screen.
The framing that helps most: separate make the network faster (CDN, HTTP/2, compression) from need the network less (caching, prefetching, smaller payloads). Most of the real wins are in the second group.
The tension is genuine: long cache lifetimes make things fast, but then how do you ship a change?
Content hashing is the answer the industry settled on. Name each file after a hash of its contents, so app.js becomes app.4f8a2c.js. When content changes the filename changes, so it is a new URL the cache has never seen and there is nothing to invalidate. The old file can keep a year-long max-age plus immutable, because nothing will request it again. The HTML referencing these files is served with a short or no-cache policy.
The alternatives, and what they trade:
- Versioned query strings (
app.js?v=2) do the same job more simply, but some intermediary caches treat query strings inconsistently.
- Validation-based (
ETag, Last-Modified) pays a round trip on every request in exchange for always-current content. Suits content that changes unpredictably.
- Active purging — telling the CDN to evict something — is the last resort, needed for anything you cannot rename: a fixed API endpoint, or the HTML entry point.
The cheapest invalidation is the one you never perform. Hashed immutable assets behind a short-lived HTML shell mean you almost never invalidate anything — you just change the name.
That is the foundation. None of it is advanced, but all of it is load-bearing: nearly every confusing bug further up the stack turns out to be one of these behaviours acting exactly as specified.
Tier 2 picks up with accessibility, state management, component design and rendering strategies.