By the end you'll know
- How JavaScript reacts to what people do, with events
- How to fetch data with
asyncandawait, and what a failed request really looks like - Why
fetchdoesn't fail on a server error, and how to check - How to give up on a slow request with a timeout
- Why
innerHTMLwith data you didn't write is a security hole - How to tell people what happened, including people using screen readers
- What progressive enhancement is, and what it protects
Before you start
- You need: Lessons 1 and 2. Some JavaScript helps but isn't required; every line is explained. Python 3, and Node.js with Playwright for the test script (optional).
- The example: Juniper & Rye, the made-up bakery. The scripts, the server and every line of output below are real.
The short version
- Scripts react to events: a click, a form submission, a page finishing loading.
fetchonly fails when the network does. A500from the server is a successful fetch with a failed answer. Checkres.ok.- Requests need a timeout.
AbortControllerlets a page give up and say so, instead of showing "Loading" forever. - Never put data into
innerHTML. Version 1 did, and code hidden in a menu item ran.textContentshows it as text. - Tell people what happened, in the page, in a
role="status"region that screen readers announce. Never claim success you haven't seen. - Build on HTML that works without JavaScript. The sign-up form worked with JavaScript turned off. The menu didn't.
What JavaScript is for
Lesson 2's page was complete without any JavaScript: a reader can see it, a keyboard can use it, and the sign-up form sends itself to the server when submitted. JavaScript adds what HTML can't do on its own:
- React without reloading the page: show "Signing you up..." and then a thank-you, in place.
- Fetch data after the page loads: today's menu, from
menu.json. - Change the page: add the menu items, show an error, disable a button while it works.
All three show up in the bakery's script. The page now has an empty menu that the script fills, and two status areas for messages:
<ul class="menu" id="menu"><li>Loading today's bakes...</li></ul>
<p class="status" id="menu-status" role="status"></p>
...
<p class="status" id="signup-status" role="status"></p>
role="status" makes each paragraph a live region: when its text changes, screen readers announce the new text without moving the reader's place on the page. Without it, a message appearing on screen is silent to someone who can't see it.
Version 1: works on a good day
// Version 1: works on a good day.
fetch(window.MENU_URL || "menu.json")
.then((r) => r.json())
.then((items) => {
document.getElementById("menu").innerHTML = items
.map((i) => `<li><span class="item">${i.name}</span> <span class="price">$${i.price.toFixed(2)}</span></li>`)
.join("");
});
document.querySelector("form").addEventListener("submit", (e) => {
e.preventDefault();
fetch("/signup", { method: "POST", body: new FormData(e.target) });
alert("Thanks for signing up!");
});
What it does:
fetch(...)asks the server for the menu and returns a promise: a placeholder for an answer that will arrive later..then(...)says what to do when it does. (window.MENU_URLlets the test script point the page at a different menu file; on the real site it's justmenu.json.)r.json()turns the answer's body into JavaScript objects..map(...)turns each menu item into a line of HTML, andinnerHTMLputs that HTML into the list.addEventListener("submit", ...)runs a function when the form is submitted.e.preventDefault()stops the browser's normal submission (which would load a new page), so the script can send it instead.new FormData(e.target)collects the form's fields, andfetchsends them withPOST.alert(...)pops up a thank-you.
On a good day, it all works. That's the trap: most code is only ever tried on good days.
A server that fails on purpose
The lesson's server serves the bakery site, plus three menu files that go wrong in different ways, and a sign-up endpoint:
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == "/menu-broken.json":
return self.reply(500, {"error": "database unavailable"})
if self.path == "/menu-slow.json":
time.sleep(6)
self.path = "/menu.json"
super().do_GET()
def do_POST(self):
if self.path != "/signup":
return self.reply(404, {"error": "not found"})
# ...read the email from the form; reply 201 with a thank-you,
# or 400 if it isn't an email address. A browser submitting the form
# without JavaScript gets an HTML thank-you page instead.
/menu-broken.jsonanswers500 Internal Server Error, like a server whose database is down./menu-slow.jsonwaits six seconds, then sends the normal menu./menu-evil.jsonis a normal file with a menu item someone has tampered with. Its name contains HTML:Rye <img src=x onerror="window.injected = 'ran'; document.title = 'Injected!'">. If the browser treats that as HTML, it will try to load an image calledx, fail, and run theonerrorcode, which sets a flag and changes the page's title. Harmless here; in a real attack, the code would steal a session or change a form.POST /signupaccepts a sign-up. The full server, with how it reads the form, is in the practice folder.
Testing on bad days
A Playwright script loads each version of the page in a real browser and puts it through six situations: a good day, the three broken menus, a sign-up whose network connection fails, and a visit with JavaScript turned off. Its core:
const page = await browser.newPage();
page.on('dialog', (d) => { notes.alerts.push(d.message()); d.dismiss(); });
page.on('console', (m) => { if (m.type() === 'error') notes.errors.push(m.text()); });
await page.addInitScript((u) => { window.MENU_URL = u; }, menuUrl);
await page.goto(BASE + version + '.html');
await page.waitForTimeout(7000);
// then read the menu, the status message, window.injected and the page title
page.on('dialog')records anyalert()and closes it.page.on('console')records errors written to the browser's console, which visitors never see.addInitScriptsetswindow.MENU_URLbefore the page's own script runs, so each test can point the page at a different menu file.waitForTimeout(7000)waits long enough for the slow server to answer.- For the failed sign-up,
page.route('**/signup', r => r.abort('connectionrefused'))makes the browser's request fail as if the server were unreachable. - For JavaScript off, a new browser context with
javaScriptEnabled: falsefills in the form and submits it the old-fashioned way.
(Your machine), with the lesson's server running:
node scenarios.js
Version 1's results
==== v1
-- good day
menu ["Country sourdough $8.50","Dark rye $9.00","Cardamom bun $4.25"]
status ""
-- server error (500)
menu ["Loading today's bakes..."]
status ""
console Failed to load resource: the server responded with a status of 500 (Internal Server Error) | items.map is not a function
-- slow server (6 s)
menu ["Country sourdough $8.50","Dark rye $9.00","Cardamom bun $4.25"]
status ""
-- code hidden in a menu item
menu ["Country sourdough $8.50","Rye $9.00"]
status ""
injected code from the menu data ran; page title is now "Injected!"
console Failed to load resource: the server responded with a status of 404 (File not found)
-- sign-up, server reachable
status ""
alerts ["Thanks for signing up!"]
-- sign-up, network fails
status ""
alerts ["Thanks for signing up!"]
-- sign-up, JavaScript turned off
landed on /signup: "Thanks, ada@example.com is on the bake list."
menu ["Loading today's bakes..."]
Three real problems:
The server error left the page stuck on "Loading" forever. The only sign of trouble was in the console: items.map is not a function. Here's why. fetch doesn't treat a 500 as a failure: the network worked, and the server answered. So the code carried on, and r.json() read the server's error message, {"error": "database unavailable"}. That's an object, not a list, and objects have no .map. The script crashed, nothing caught it, and the visitor saw "Loading today's bakes..." with no end.
The code hidden in the menu ran. The tampered item's name went straight into innerHTML, so the browser read it as HTML: it created an <img>, the image failed to load (that's the 404 for /x), and the onerror code changed the page's title to "Injected!". The item shows as just "Rye", because the rest of its name became an invisible element. This is cross-site scripting (XSS): someone gets their code to run in your page, with your visitors' sessions. It happens wherever data you didn't write (a menu from a database, a customer's name, a review) goes into innerHTML.
The failed sign-up said "Thanks". Version 1 sends the request and shows the thank-you straight away, without waiting to hear back. When the network failed, the customer was told they'd signed up, and they hadn't. A page that claims success it hasn't seen is worse than one that shows an error: nobody knows to try again.
Two results are better than they look. The slow server just meant a six-second wait, then the menu. And with JavaScript off, the sign-up still worked: the form has action="/signup", so the browser submitted it normally and the server answered with a thank-you page. But the menu stayed on "Loading" forever, because only a script ever replaces it.
Version 2: works on a bad day too
// Version 2: works on a bad day too.
const menu = document.getElementById("menu");
const menuStatus = document.getElementById("menu-status");
async function getJSON(url, ms = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms); // give up after ms
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`server answered ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}
getJSON is a small helper every request goes through:
asyncandawaitare another way to write promises:await fetch(...)pauses this function until the answer arrives, without freezing the page. It reads top to bottom, like ordinary code, and errors can be caught withtryandcatch.AbortControllercan cancel a request.setTimeoutcallscontroller.abort()aftermsmilliseconds (3 seconds by default), and passingcontroller.signaltofetchconnects the two. If the timer fires first,fetchfails with anAbortError.if (!res.ok) throw ...is the line version 1 was missing.res.okis true only for statuses from 200 to 299. Anything else becomes an error, so the code that calledgetJSONcan deal with it.finally { clearTimeout(timer) }cancels the timer however the request ended, so it can't fire later.
function showMenu(items) {
menu.replaceChildren(...items.map((i) => {
const li = document.createElement("li");
const name = document.createElement("span");
name.className = "item";
name.textContent = i.name; // text, never HTML
const price = document.createElement("span");
price.className = "price";
price.textContent = `$${Number(i.price).toFixed(2)}`;
li.append(name, " ", price);
return li;
}));
}
showMenu builds each list item as elements instead of a string of HTML. textContent sets an element's text, and the browser treats it as text, always: angle brackets are shown, not run. replaceChildren swaps the list's contents in one step. It's longer than one innerHTML line, and it can't be tricked.
async function loadMenu() {
menuStatus.textContent = "";
menuStatus.classList.remove("error");
try {
showMenu(await getJSON(window.MENU_URL || "menu.json"));
} catch (err) {
console.error("menu failed:", err.name, err.message);
menu.replaceChildren();
menuStatus.classList.add("error");
menuStatus.textContent = err.name === "AbortError"
? "The menu is taking too long. "
: "The menu didn't load. ";
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "Try again";
retry.addEventListener("click", loadMenu);
menuStatus.append(retry);
}
}
loadMenu tries to load and show the menu. If anything goes wrong, it removes "Loading", says what happened in plain words (different words for "too slow" and "didn't load"), and adds a Try again button that runs loadMenu again. It also logs the details to the console for whoever is debugging.
const form = document.querySelector("form");
const signupStatus = document.getElementById("signup-status");
form.addEventListener("submit", async (e) => {
e.preventDefault(); // we'll send it ourselves
const button = form.querySelector("button");
button.disabled = true;
signupStatus.textContent = "Signing you up...";
try {
const res = await fetch(form.action, { method: "POST", body: new FormData(form) });
const body = await res.json();
signupStatus.textContent = res.ok ? body.message : `Sorry: ${body.error}`;
if (res.ok) form.reset();
} catch {
signupStatus.textContent = "Sorry, that didn't go through. Please try again.";
} finally {
button.disabled = false;
}
});
loadMenu();
The sign-up handler:
- Disables the button while it works, so an impatient second click doesn't sign someone up twice, and re-enables it in
finally, whatever happened. - Says "Signing you up..." straight away, in the status region, so people know something is happening.
- Waits for the answer before saying anything else, and shows the server's own message: its thank-you, or its reason for refusing.
- Catches network failures and says so plainly.
- Uses
form.action, the same address the form uses without JavaScript, so there's one place to change it.
Version 2's results
==== v2
-- good day
menu ["Country sourdough $8.50","Dark rye $9.00","Cardamom bun $4.25"]
status ""
-- server error (500)
menu []
status "The menu didn't load. Try again"
console Failed to load resource: the server responded with a status of 500 (Internal Server Error) | menu failed: Error server answered 500
-- slow server (6 s)
menu []
status "The menu is taking too long. Try again"
console menu failed: AbortError signal is aborted without reason
-- code hidden in a menu item
menu ["Country sourdough $8.50","Rye <img src=x onerror=\"window.injected = 'ran'; document.title = 'Injected!'\"> $9.00"]
status ""
-- sign-up, server reachable
status "Thanks, ada@example.com is on the bake list."
alerts []
-- sign-up, network fails
status "Sorry, that didn't go through. Please try again."
alerts []
-- sign-up, JavaScript turned off
landed on /signup: "Thanks, ada@example.com is on the bake list."
menu ["Loading today's bakes..."]
- The server error now ends with a clear message and a way to try again, instead of "Loading" forever.
- The tampered menu item is shown as text, angle brackets and all. Ugly, but harmless, and the ugliness is a clue that someone tampered with the data. Nothing ran.
- The failed sign-up is reported as a failure. No more
alert, either: messages appear in the page, where screen readers announce them.
Two results are trade-offs, not wins:
- The slow server. Version 2 gave up at three seconds, though the menu would have arrived at six. Version 1 waited and got it. A timeout is a choice: too short, and slow networks see errors they didn't need to; too long, and people stare at "Loading". The Try again button makes a short timeout forgivable. The right number comes from measuring real requests, which Lesson 9 does.
- JavaScript off. The sign-up still works, because the HTML form works on its own. The menu still doesn't.
The server's log recorded one more thing, from the slow test:
Exception occurred during processing of request from ('127.0.0.1', 52400)
BrokenPipeError: [Errno 32] Broken pipe
(Two lines from a longer error.) When version 2 gave up, the browser closed the connection. Six seconds later, the server tried to send the menu to a connection nobody was holding. A broken pipe in a server's log usually means exactly this: a client gave up first. Seen often, it's a sign that responses are slower than clients are willing to wait.
Progressive enhancement
The sign-up form worked with JavaScript off because of how it was built: HTML first, JavaScript on top. The form does its job with no script; the script makes it nicer (no page reload, a status message, a disabled button). That approach is called progressive enhancement, and it protects more than people who turn JavaScript off:
- Scripts fail to load on bad connections, behind corporate filters, or when a third-party file they depend on is down.
- A JavaScript error anywhere on the page can stop everything after it.
- Search engine crawlers and link previews may not run scripts at all (Lesson 1).
The menu is the opposite: it exists only if a script runs. That's why neither version showed it without JavaScript, and it's the problem the next lesson solves: Next.js can put the menu into the HTML on the server, so it's there before any script runs.
In real systems
- Every request needs three outcomes: success, failure and too slow. Design all three before writing the happy path.
- Frameworks help with XSS, not everywhere. React and Next.js treat text as text by default. They have an escape hatch,
dangerouslySetInnerHTML, named to make you stop and think. Search a codebase for it, and forinnerHTML. - A Content Security Policy (a response header) can tell browsers to refuse inline code like the
onerrorabove. It's a second line of defence, not a replacement fortextContent. - Watch the console in production. Tools like Sentry collect errors from visitors' browsers. Version 1's
items.map is not a functionwould have shown up there, the only place it did. - Validate on the server too. The browser's
type="email"andrequiredchecks are for convenience. Anyone can send the server anything; the server decides what's valid.
Spot the problem
Work out what's wrong before you read the explanation.
"Our reviews look great." A café's site shows customer reviews from its database on the home page. To keep the reviews' line breaks and bold text, a developer builds each one as an HTML string and sets it with innerHTML. A month later, the café's admin notices that logging in to the site's dashboard from the home page sometimes signs her out, and then finds a new staff account she didn't create. A "review" from a stranger contained a hidden <img> with an onerror that sent the admin's session to another site. What's wrong? Data the café didn't write went into innerHTML, so the browser ran code hidden in it, in the café's own page, with the admin's login. Three fixes: show user-supplied content with textContent, or with a framework that escapes it, and if formatting is truly needed, run it through a sanitizer built for that (like DOMPurify) first; add a Content Security Policy so inline code like onerror is refused even if something slips through; and treat every field that came from outside as untrusted, however harmless it looks.
Keep this
| Do | Instead of | Because |
|---|---|---|
if (!res.ok) throw | Trusting every answer | fetch doesn't fail on a 500 |
AbortController and a timeout | Waiting forever | "Loading" must end in something |
textContent, createElement | innerHTML with data | Data can carry code |
| Wait for the answer, then report it | Assuming success | A false "Thanks" hides a failure |
role="status" messages in the page | alert() | Screen readers announce them; they don't block the page |
| Disable the button while sending | Allowing double clicks | No duplicate sign-ups or orders |
| HTML that works first | A page that needs scripts | Scripts fail more often than you'd think |
| Test the bad days | Trying it once and shipping | Good days hide every bug here |
Where you are now
You can make a page react to people, fetch data with async and await, handle server errors, slow responses and failed networks, keep untrusted data from running as code, report what happened in a way everyone gets, and build on HTML that works without scripts. You've also seen the limit of doing all this in the browser: anything a script adds isn't in the page until the script runs. The next lesson moves to Next.js, where the bakery's pages, menu included, can be built on the server.
Check yourself
Version 1 stuck on 'Loading' after a 500. Why didn't fetch fail?
What did the tampered menu item do in version 1?
Which line keeps version 2 safe from that menu item?
Why is version 1's sign-up worse than just failing?
Version 2 gave up on the slow menu at 3 seconds. Is that right?
Why did the sign-up work with JavaScript turned off?
Key terms
- Event: something that happens on the page, like a click or a submission, that code can react to.
- Promise: a placeholder for a result that arrives later.
asyncandawait: a way to write code that waits for promises, top to bottom, withtryandcatchfor errors.fetch: the browser's way to make HTTP requests from JavaScript; it fails only when the network does.res.ok: true when a response's status is between 200 and 299.AbortController: a way to cancel a request, used here for timeouts.- Cross-site scripting (XSS): getting code to run in someone else's page, often through data put into
innerHTML. textContent: sets an element's text, always as text, never as HTML.- Live region (
role="status"): an area whose changes screen readers announce. - Progressive enhancement: building HTML that works on its own, then adding JavaScript to improve it.
Go deeper
- MDN: using the Fetch API (opens in a new tab), and AbortController (opens in a new tab).
- OWASP: cross-site scripting prevention cheat sheet (opens in a new tab).
- Try: change version 2's timeout to 8 seconds and rerun the slow test. What does the visitor see, and for how long?
- Try: in version 1, replace
innerHTMLwith building elements andtextContent, like version 2, and rerun the tampered-menu test. Doeswindow.injectedstill get set?