Nearly everything on the web is one exchange, repeated: a client asks for an address, a server answers with a status, headers and usually a body, and the browser decides what to do with it. Most baffling web bugs misread that exchange — state the server never received, a response the browser fetched but refused to let your code read, a check that ran on the wrong side of the wire. This guide is its shape: what an address names, what happens between a URL and a finished page, how state survives a protocol with no memory, and which decisions the client cannot make.
Each chapter opens with the short version. Tap one to read the detail.
What an address actually names
~2 min
Six parts, two of which settle security arguments: an origin is scheme, host and port together, so a different port is a different origin — and the fragment after # never leaves the browser.
Left to right: scheme, host, an optional port, path, the query after ?, and the fragment after #. The URL object exposes each — protocol, hostname, port, pathname, search, hash — plus origin, which is scheme, domain and port taken together.
That last grouping is the one to memorize, because the browser writes its security rules in terms of origins, not domains — and the standing mistake is to compare the wrong one. Sibling subdomains like app.example.com and api.example.com, both over HTTPS, share a registrable domain — a site — but they are two different origins.
const u = new URL(address); // …//app.example.com:8443/reports?year=2026#summary
u.port; // "8443" a different port is a different origin
u.hash; // "#summary" never leaves the browser
u.origin; // scheme + host + port, together
The fragment runs the other way. The browser keeps it and never puts it in the request, applying it locally once the response arrives — so a server log, a server-rendered route and server-side analytics are all blind to #summary. Route on the hash and the server sees one URL for every screen.
Rule: when something is blocked or a cookie goes missing, write both addresses out as scheme, host and port — the answer is nearly always a comparison of origins, not of domains.
One request, one answer, and no memory of you
~2 min
The server keeps nothing between two requests. So the method is a public promise about what repeating one does, and the status code is a contract the rest of the stack acts on without asking you.
The method is not a naming convention. GET and HEAD are safe — they must only retrieve. PUT and DELETE change things but are idempotent: repeating one lands you in the same place. POST is neither, which is why a double submission creates two orders.
The misconception is that choosing between them is taste. It is a promise the rest of the stack believes: GET is marked safe and cacheable, so fetching one is treated as free of consequence — and a link like /items/12/delete is eventually followed by something that is not a person.
And because the protocol is stateless, the server remembers nothing between requests; each one carries its own context again.
Status codes read the same mechanical way. The class sets the meaning — informational, success, redirect, client fault, server fault — and among redirects the detail that bites is the method. A 303 tells the client to fetch the new location with GET; 307 and 308 must not change the method, while the older 302 may. That is what makes post/redirect/get work:
POST /orders → 303 See Other, Location: /orders/1841
GET /orders/1841 → 200 OK
# answer the POST with 200 instead, and a refresh re-submits the order
Two client errors get swapped constantly: 401 means we do not know who you are; 403 means we do, and you still may not.
Rule: pick the method by whether repeating it must be harmless, and the redirect code by whether the method has to survive the hop.
Headers decide how the body is read
~2 min
A body is just bytes. The headers say what those bytes are, who they were chosen for, and how long the answer may be reused — and getting the middle one wrong serves one visitor another's page.
The declared type wins: a browser acts on Content-Type, not the file extension, and where that header is missing or wrong it may inspect the content and guess instead. The guessing is the vulnerability — a user-uploaded file served as text/plain can be sniffed as a document and executed. X-Content-Type-Options: nosniff stops it, and also blocks a response requested as a script or stylesheet whose declared type does not match.
Negotiation is the other half. The client proposes with Accept, Accept-Language and Accept-Encoding, each entry optionally weighted with a quality value such as gzip;q=0.8; the server treats these as hints and returns one of several representations.
Here is the trap: that representation does not belong to the visitor who asked. The next shared cache along the path files it under the URL, and Vary is what adds the deciding request headers to the cache key. Suppose a page reads Accept-Language and omits Vary — the first visitor's German copy is filed under the URL, and the next reader is handed it in German, with nothing logged anywhere.
Reuse is two questions, and the directive names mislead. max-age counts seconds from when the response was generated, so a copy that sat in a shared cache arrives partly spent. no-cache does store the response, revalidating before each reuse; no-store is the one that keeps nothing.
Rule: if a request header changed the response, name it in Vary — otherwise you have told every cache in the path that this answer suits everyone.
Keeping state on a protocol with no memory
~2 min
A cookie is the one piece of browser state that rides on every matching request — which is why its scoping is looser than an origin. Everything else you store never leaves the client unless your code sends it.
Each Set-Cookie header sets one cookie; the browser returns every matching one in a single Cookie header on every request. That is the whole session mechanism — the server has no memory, so the client re-supplies its identity constantly, and each cookie is a permanent tax on request size.
The scoping rule surprises people, because it runs opposite to the instinct. Omit Domain and the cookie stays on the exact host that set it; add Domain=example.com and it widens to that domain and all its subdomains. The narrow behavior is the default; the attribute loosens.
Lifetime: with neither Expires nor Max-Age it is a session cookie, and where both appear Max-Age wins.
The three security attributes do different jobs; none substitutes for another. HttpOnly hides the cookie from document.cookie, so injected script cannot read it; Secure restricts it to HTTPS; SameSite decides whether it travels on cross-site requests and defaults to Lax, with the value None rejected unless Secure is set too.
One asymmetry ties back to origins: cookie scope ignores the port. Suppose a dev server on example.com:8000 sets one — it is readable on example.com:9000, an origin the browser otherwise treats as foreign.
Everything else stays put. localStorage is per origin and survives a restart; sessionStorage is per tab and dies with it; both hold strings only and both are synchronous. A storage event notifies the other tabs of that origin, never the tab that made the change.
Rule: put in a cookie only what the server must see on every request; anything else belongs in storage the network never touches.
The policy, and the thing everyone blames for it
~2 min
The browser will happily send your cross-origin request; what it withholds is the response. The permission headers let a server grant a read, are enforced only inside browsers, and are not an access-control system.
The same-origin policy restricts what a script may read across origins: fetch reaches the origin the page came from, and anything further needs permission. It does not stop the request going out.
That is why so much cross-origin traffic looks unaffected. Images, scripts, stylesheets and form submissions cross origins by design — which is precisely how cross-site request forgery works. The request travels, carrying cookies, and the attacker never needs to read the reply.
The permission mechanism is CORS, for cross origin resource sharing: response headers naming the origins allowed to read, enforced by the browser.
Some requests go straight out — GET, HEAD or POST, only safelisted headers, and a content type of application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else earns a preflight: an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers, which the server must approve before the real one is sent. Suppose you move a form post to Content-Type: application/json and add one custom header: every call quietly becomes two.
Credentials need both sides to agree: credentials: "include" on the client and Access-Control-Allow-Credentials: true on the server. A wildcard Access-Control-Allow-Origin: * cannot be combined with them.
The last misconception is the expensive one: these headers protect the person at the browser, not your service. Anything that is not a browser ignores them completely.
Rule: those headers decide what a page's script may read; who is allowed to act is still a check the server performs on every request.
The head is read before anything appears
~2 min
Two lines at the top decide how the rest is interpreted: the doctype picks the rendering mode, and the encoding declaration must be found inside the first 1024 bytes. After that, the element you pick is behavior, not decoration.
Browsers use the doctype to decide whether to render a document in quirks mode or no-quirks mode. Missing or malformed, and you get quirks mode, which emulates browsers from the late 1990s. Read the verdict directly: document.compatMode is "CSS1Compat" in no-quirks mode and "BackCompat" in quirks. A page that looks subtly wrong in every browser at once is a good candidate.
<!doctype html>
<meta charset="utf-8" /> <!-- must sit entirely within the first 1024 bytes -->
<title>Orders</title> <!-- no more than one per document -->
The encoding declaration has a byte budget, not just a position: it must appear entirely within the first 1024 bytes and its value must match utf-8. Push it below a block of inlined script and the parser has already started guessing. A meta with name and content carries document metadata; with http-equiv it acts as a pragma directive, standing in for a header the response could have sent.
In the body the principle repeats: markup is meaning, not appearance. Heading rank expresses depth in the outline, and the specification is explicit that these elements do not determine visual presentation — choose h3 because it sits beneath an h2, never because you wanted smaller text. An article stands on its own; a section is a thematic part of something larger.
Rule: doctype, encoding and title first — then pick every element for what it means, because the browser has already assigned behavior by the time you reach for CSS.
Forms, and the side of the wire validation must live on
~2 min
A form's contract is small and unforgiving: only named controls are submitted, the encoding decides whether a file can travel, and type changes what counts as valid. The browser's own checks are a courtesy to honest users, nothing more.
Start with what actually arrives. If an input has no name, or an empty one, its value is not submitted — it can be present and perfectly correct and still never reach the server. Disabled controls, unchecked checkboxes and unchecked radio buttons are not sent either, while a readonly control is. Nor can the default encoding carry a file: a form containing type="file" needs enctype="multipart/form-data" rather than application/x-www-form-urlencoded.
Then what the browser checks. The type changes the rules — email and url carry their own format constraints — and required, pattern, min, max, minlength, maxlength and step each raise a separately named flag on the control's validity state, which is what lets you tell "too short" from "wrong format".
A styling trap follows immediately. Suppose you write input:invalid { border: red } — :invalid matches a required field nobody has typed in yet, so a pristine form paints as a wall of errors on first load.
And the part that is not optional: constraint validation does not remove the need for validation on the server. The documentation says so outright and lists the ways round it — editing the markup in developer tools, novalidate on the form, a hand-crafted request that never involved the form, and values set by script that some checks never see.
Rule: use the browser's validation to help people type, then validate again on the server as though the form had never existed.
The tree is not your HTML
~2 min
The browser parses your markup once into a tree; from then on the tree is the truth and view-source stays frozen. Two habits cause most bugs here: assuming a collection is a snapshot, and using innerHTML where text was meant.
The Document Object Model (DOM) is a live object representation built from the markup — the markup is the seed, not the state. Not every node is an element: text and comments are nodes too, so childNodes and children disagree on any document written with indentation.
Collections come in two kinds, and mixing them up is a classic mistake. querySelectorAll returns a static snapshot, taken when you called it. getElementsByTagName and getElementsByClassName return a live collection that updates as the document changes, so a loop removing matching elements walks a list shrinking underneath it.
Moving is not copying: appending a node already in the document relocates it — a node cannot be in two places at once. Use cloneNode for a copy.
el.innerHTML = userName; // "<img src='x' onerror='…'>" runs the handler
el.textContent = userName; // inserted as literal text
innerHTML parses its input as HTML, which makes it an injection sink. It will not execute an injected <script> — exactly what makes it feel safe — but an event-handler attribute on a broken image runs immediately.
A shadow root is a second tree hanging off an element: outer CSS and document.querySelector cannot reach inside, and a custom element's name must contain a hyphen. Slotted content is the exception — it renders inside the shadow tree but still belongs to the outer document, so the page's own selectors and styles apply to it.
Rule: ask whether a value is text or markup before writing it, and whether a collection is a snapshot or a live view before looping over it.
How an event travels, and where to listen
~2 min
An event runs down to its target, fires there, then travels back up — three phases, not one. Nearly every event bug confuses where something started with where you are listening, or delegates an event that never bubbles.
The three phases are capturing, at-target and bubbling, reported as eventPhase. A listener joins the bubbling phase unless you ask for capture.
That is why two properties exist. target is where the event originated; currentTarget is the element whose listener is running. In a handler attached to a parent they differ, and most "why is this the wrong element" confusion is this.
The difference is the whole point of delegation: one listener on an ancestor serves descendants that did not exist when it was attached, so a list can be re-rendered without rebinding anything.
Two methods sound interchangeable and are not. stopPropagation halts the event's travel; preventDefault cancels the default action, and only where the event is cancelable. Stopping propagation on a link click does not stop the navigation; preventing the default does not stop the event reaching the parent.
form.addEventListener("focusin", (e) => {
e.target; // the field that received focus
e.currentTarget === form; // where we happened to listen
});
Delegation has one hard limit: some events do not bubble. focus does not bubble and is not cancelable, so a delegated focus listener on a form never fires — which reads as a dead handler with no error anywhere. Its bubbling twin focusin is the one to delegate.
Rule: before delegating, check that the event bubbles; before reaching for stopPropagation, ask whether you meant to cancel the action instead.
The page's timeline, and what a simulated navigation costs
~2 min
DOMContentLoaded means the document is parsed, not that the page is ready; load waits for everything and is usually the wrong hook. And a page can come back from the back button without loading at all.
DOMContentLoaded fires once the HTML is fully parsed and deferred scripts have run. It does not wait for images, subframes, async scripts or stylesheets. load waits for all of it, and using it where DOMContentLoaded belongs is called out in the documentation as a common mistake. One subtlety: the event does not wait for stylesheets, but a deferred script does — so your code can run later than the name suggests.
Going back is a different story. The back/forward cache holds a whole page in memory, including its JavaScript heap, and restores it rather than reloading it. Nothing re-runs; pageshow fires with persisted set instead. Suppose your setup sits in a load handler: on a back navigation it simply never runs. An unload listener, meanwhile, disqualifies the page from that cache altogether.
The third kind of movement is the one your code invents. pushState and replaceState change the address bar without a navigation and without fetching anything, the new URL must be same-origin, and neither fires popstate. That event fires when the user goes back or forward, or history.back() is called — a distinction that produces either a dead back button or an infinite loop when it is missed.
You give up more than the URL: title, scroll position and focus are no longer handled for you.
Rule: a real navigation gives you title, scroll, focus and history for free; the moment you simulate one, you have taken on all four.
Asking the server from script
~2 min
fetch resolves for a 500 as cheerfully as for a 200, sends no cookies cross-origin by default, and hands back a body you may read exactly once. Underneath, a service worker can sit as a proxy you install.
Internalize this first: the promise rejects on network failure, not on an HTTP error status. A 404 or a 500 resolves normally, so a try/catch alone reports a server error as a success. response.ok — true only for the 200 range — is the check.
const res = await fetch("/api/orders", { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404 and 500 arrive here
const orders = await res.json(); // body is now spent
The body is a stream and is consumed by reading; call clone() before you need it twice.
Cookies are the next surprise. credentials defaults to same-origin, so a cross-origin request carries none until you ask for include — and the server must still agree, which is where the permission headers return. Setting mode: "no-cors" is not the escape hatch it looks like: it returns an opaque response with status 0 and an unreadable body. To cancel in flight, pass an AbortController signal and handle the rejection named AbortError.
A service worker sits below all this as a proxy between page and network, on its own thread, with no access to the document. It requires a secure context — localhost excepted, so you can develop — and the path it is served from caps the scope it controls. The lifecycle trap: the page that registers a worker is not controlled by it until a reload.
Rule: check response.ok before touching a body — the promise resolving only tells you the network worked.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.