Web development basics · Lesson 3 of 11 · about 35 minutes

JavaScript in the browser

JavaScript makes a page respond: it reacts to clicks and form submissions, fetches data, and changes what's on screen. This lesson writes the bakery's script twice. Version 1 works on a good day. Tested on bad days in a real browser, it hung on "Loading" forever when the server failed, ran code hidden in the menu data, and thanked a customer for signing up when the request never arrived. Version 2 handles all of it, in about forty more lines. Then what neither version can do without JavaScript, which sets up Lesson 4.

By the end you'll know

  • How JavaScript reacts to what people do, with events
  • How to fetch data with async and await, and what a failed request really looks like
  • Why fetch doesn't fail on a server error, and how to check
  • How to give up on a slow request with a timeout
  • Why innerHTML with 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.
  • fetch only fails when the network does. A 500 from the server is a successful fetch with a failed answer. Check res.ok.
  • Requests need a timeout. AbortController lets 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. textContent shows 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:

HTML
<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

JS
// 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_URL lets the test script point the page at a different menu file; on the real site it's just menu.json.)
  • r.json() turns the answer's body into JavaScript objects.
  • .map(...) turns each menu item into a line of HTML, and innerHTML puts 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, and fetch sends them with POST.
  • 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:

PYTHON
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.json answers 500 Internal Server Error, like a server whose database is down.
  • /menu-slow.json waits six seconds, then sends the normal menu.
  • /menu-evil.json is 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 called x, fail, and run the onerror code, 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 /signup accepts 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:

JS
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 any alert() and closes it.
  • page.on('console') records errors written to the browser's console, which visitors never see.
  • addInitScript sets window.MENU_URL before 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: false fills in the form and submits it the old-fashioned way.

(Your machine), with the lesson's server running:

BASH
node scenarios.js

Version 1's results

TEXT
==== 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

JS
// 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:

  • async and await are 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 with try and catch.
  • AbortController can cancel a request. setTimeout calls controller.abort() after ms milliseconds (3 seconds by default), and passing controller.signal to fetch connects the two. If the timer fires first, fetch fails with an AbortError.
  • if (!res.ok) throw ... is the line version 1 was missing. res.ok is true only for statuses from 200 to 299. Anything else becomes an error, so the code that called getJSON can deal with it.
  • finally { clearTimeout(timer) } cancels the timer however the request ended, so it can't fire later.
JS
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.

JS
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.

JS
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

TEXT
==== 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..."]
Both versions, every situation. Select the grid to enlarge it.
  • 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:

TEXT
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 for innerHTML.
  • A Content Security Policy (a response header) can tell browsers to refuse inline code like the onerror above. It's a second line of defence, not a replacement for textContent.
  • Watch the console in production. Tools like Sentry collect errors from visitors' browsers. Version 1's items.map is not a function would have shown up there, the only place it did.
  • Validate on the server too. The browser's type="email" and required checks 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

DoInstead ofBecause
if (!res.ok) throwTrusting every answerfetch doesn't fail on a 500
AbortController and a timeoutWaiting forever"Loading" must end in something
textContent, createElementinnerHTML with dataData can carry code
Wait for the answer, then report itAssuming successA false "Thanks" hides a failure
role="status" messages in the pagealert()Screen readers announce them; they don't block the page
Disable the button while sendingAllowing double clicksNo duplicate sign-ups or orders
HTML that works firstA page that needs scriptsScripts fail more often than you'd think
Test the bad daysTrying it once and shippingGood 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

  1. Version 1 stuck on 'Loading' after a 500. Why didn't fetch fail?

  2. What did the tampered menu item do in version 1?

  3. Which line keeps version 2 safe from that menu item?

  4. Why is version 1's sign-up worse than just failing?

  5. Version 2 gave up on the slow menu at 3 seconds. Is that right?

  6. 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.
  • async and await: a way to write code that waits for promises, top to bottom, with try and catch for 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