Programming Interactivity · Lecture 8

Over the
wire.

HTTP · fetch · JSON · async UI state · POST · CORS · races

Where we left off · Lecture 5

The data was hard-coded. Today it comes from a server.

Lecture 5

Today

Today's idea

The server owns the data. The page holds a copy, and asks.

01 · CLIENTfetch()the browser, your JS
02 · SERVER/api/…validates, stores, decides
03 · RESPONSE200 + JSONstatus, headers, body

Shared, persistent, trusted data lives on the server. The page is one client of many.

The plan · 3 hours

Five acts, one market, one build

00Whya server
01HTTPrequests, responses, status codes
02Async & fetchevent loop, promises, Response
03Async UI stateloading, error, empty
☕Break15 min
04Sending dataPOST, validation, 201 / 422
05Origins & racesCORS, AbortController
06Buildconnect the market to the API

Five short exercises, then 45 minutes connecting the market to the API. Materials: 08-fetch: git pull now. For the build: a GitHub account, and Node for live-server (node --version).

GET 200
act 01

HTTP

0:05 – 0:23the protocol under every fetch
requestresponseURLmethodsstatus codesheadersNetwork panel

HTTP/1.1 · a request

A request is text: a line, headers, a body

POST /api/stalls/taco-bike/reviews HTTP/1.1
Host: localhost:3000
Content-Type: application/json
Accept: application/json
Content-Length: 66
⏎ an empty line ends the headers
{"author":"Ana","rating":5,
 "text":"Best tacos on the waterfront."}
  1. Method: what to do. GET reads, POST creates.
  2. Target: the path (and query) of the resource.
  3. Headers: metadata, Name: value. Names are case-insensitive.
  4. Body: optional. Its format is declared by Content-Type.

HTTP/1.1 · a response

A response: a status, headers, a body

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/reviews/r17
Cache-Control: no-store

{
  "id": "r17",
  "stallId": "taco-bike",
  "author": "Ana",
  "rating": 5,
  "text": "Best tacos on the waterfront.",
  "createdAt": "2026-10-16T19:42:07.311Z"
}
  1. Status: a three-digit code and a reason phrase. The code is the contract; the phrase is for humans.
  2. Headers: Content-Type says how to read the body; Location says where the new resource lives.
  3. Body: here, the review as the server stored it, with fields the client didn't send.

URL

A URL, and the part called the origin

https://schememarket.examplehost:443port/api/stallspath?tag=food&sort=pricequery#vendorsfragment

origin = scheme + host + port → https://market.example

Query: key-value pairs for the server (filters, sorting, pages). Build it with URLSearchParams: it encodes &, spaces and # for you.

Fragment: never sent to the server. Origin: the unit of browser security, back in Act 5.

Methods

Five methods cover almost every API

MethodMeaningBodySafeIdempotentHarbour API
GETread a resourcenoyesyesGET /api/stalls
POSTcreate, or run an actionyesnonoPOST /api/stalls/:id/reviews
PUTcreate or replace at a known URLusuallynoyesPUT /api/saved/:id
PATCHchange some fieldsyesnonot guaranteedPATCH /api/stalls/:id
DELETEremove a resourcenonoyesDELETE /api/reviews/:id

Safe: no side effects, so it can be cached, prefetched, retried. Idempotent: sending it twice has the same effect as once, so it can be retried after a timeout.

Status codes

The first digit is the category; a dozen codes do most of the work

CodeMeaningWhen the Harbour API sends it
200 OKsuccess, the body has the resultany successful GET
201 Createda new resource existsa POSTed review; Location header points at it
204 No Contentsuccess, no bodyPUT / DELETE: don't call response.json()
304 Not Modifiedyour cached copy is still valid(real APIs) conditional GETs with ETag, handled by the browser
400 Bad Requestmalformed requestinvalid JSON, an unknown ?tag=
401 Unauthorizednot authenticatedno token, or an invalid one
403 Forbiddenauthenticated, not alloweda vendor's token on another vendor's stall
404 Not Foundno such resource/api/stalls/pizza
405 · 409 · 415wrong method · conflicts with current state · wrong body typeDELETE /api/stalls · ordering from a sold-out stall · no Content-Type
422 Unprocessable Contentwell-formed, but invalid valuesa rating of 7, a 3-character review
429 Too Many Requestsrate limited(real APIs) wait for Retry-After
500 · 503server bug · temporarily unavailablechaos on (control page)

JSON · revisited

The body is text. JSON has six types.

Strings, numbers, booleans, null, arrays, objects. Nothing else crosses the wire.

Dates travel as ISO 8601 strings in UTC. Convert on the client, display in the user's time zone.

A typed language on the server doesn't make your data typed: validate what arrives, on both sides.

Request lab · live

Talk to the API without writing JavaScript

DevTools · Network

Every request, its headers, its body and its timing

Right-click a row → Copy as fetch or Copy as cURL. Throttling (Slow 4G, Offline) shows your loading and error states.

Quick check · status codes

Which status should the server answer?

  1. GET /api/stalls/pizza
  2. A POSTed review was stored
  3. A review with "rating": 7
  4. DELETE /api/reviews/r3 worked
  5. PATCH without a token
  6. The database is down
1 · 404   2 · 201 + Location   3 · 422
4 · 204 (nothing to say)   5 · 401   6 · 503, or 500
Which of the six could succeed if the client simply sent the same request again later? Only 6.
exercise

Exercise 1 · solo · 6 min

Read the wire

  1. Probe the Harbour Market API with the request lab: write the requests yourself.
  2. Questions 1–6 in class (totals, a rating, statuses, a header); 7–8 if you finish.
  3. Keep api.html open in another tab: it's the contract.

08-fetch → Exercise 1 · Read the wire. Each answer checks itself when you leave the field.

await
act 02

Async & fetch

0:23 – 0:54asynchronous JavaScript, then HTTP from it
event looppromises.then / .catchasync / awaitfetch()response.okURLSearchParams

Why asynchronous

JavaScript runs on one thread, shared with the page

One call stack. Your scripts, your event handlers and the page's rendering take turns on the main thread.

The browser waits, not your code. Network, timers and file reads run outside JavaScript; when they finish, a callback is queued.

A promise is the handle to a result that doesn't exist yet: pending, then fulfilled with a value or rejected with an error.

Quick check · predict the output

Five lines. In which order do the letters print?

A E D B C
Synchronous code first (A, E). Then microtasks, the promise callbacks (D). Then one task, the timer (B). The network answers last (C).
A 0 ms timer means "as soon as possible after the current code", never "now". Step through it on the next slide.

Event loop lab · step through

Call stack, browser, two queues

Event loop · the queues

Three buckets: task, microtask, now

Task queueMicrotask queueNo queue
What
  • a <script>, a module
  • setTimeout, setInterval
  • user events: click, input, keydown
  • network and I/O: fetch, XHR, WebSocket
  • postMessage, worker messages
  • .then, .catch, .finally callbacks
  • the rest of an async function after await
  • queueMicrotask(fn)
  • MutationObserver callbacks
  • the new Promise(fn) executor
  • el.click(), dispatchEvent
  • forEach, map, sort callbacks
Queuedwhen the event happens: the timer fires, the user acts, the data arriveswhen the promise settles, or at once if it already hasnever
Runsone per turn of the loopall of them after each task, newcomers includedright now, on the current stack

Between tasks the browser may render; requestAnimationFrame callbacks run just before that paint.

Quick check · which queue?

Seven letters. In which order?

C A G D F E B
No queue: the executor (C) and button.click() (A) run synchronously, then G. Microtasks in order: D, F, then E, which D queued: the loop drains the queue, newcomers included. Then one task: B.
A real click is a task: its A would print after all of this, never in the middle of the script.

Promises · the chain

.then, .catch, .finally

.then(f) returns a new promise: f's return value fulfils it, a returned promise is waited for, a throw rejects it.

.catch(f) handles a rejection from any step before it. .finally(f) runs after either outcome.

No .catch anywhere: Uncaught (in promise) in the console.

async / await

The same code, two styles

.then / .catch

async / await

async/await is syntax over promises: an async function returns one, await waits for one and throws if it rejects. Independent requests: start both, then await Promise.all([a(), b()]).

fetch()

Two awaits: first the head, then the body

The first promise resolves as soon as the status and headers arrive. The body may still be downloading: json() reads the rest of the stream and parses it, so it's a second promise.

Quick check · fetch and errors

The stall doesn't exist. What happens?

render is called with { error: "not_found", message: … }. No exception: a 404 is a response, and fetch resolves.
fetch rejects only when there is no usable response: network down, DNS failure, CORS blocked, aborted. Then it's a TypeError (or an AbortError).
So every caller checks response.ok. Write it once, in a helper.

A helper

One helper for every request

getJSON

HttpError

A 404 or a 503 now rejects, with error.status and the server's error body in error.body. Every request in the project goes through it.

Live · a script, a preview, a Network tab

fetch, in the browser

The project

One module talks to the network

Lecture 5

Lecture 8

exercise

Exercise 2 · solo · 12 min

Load the market

  1. loadStalls(): fetch /api/stalls, render all 10.
  2. getJSON(url): reject on a 404 or a 503, with the server's message.
  3. Filters on the server: ?tag=food built with URLSearchParams.

08-fetch → Exercise 2 · Load the market. The Network tab is next to the console. Bonus at home: sorting.

…
act 03

Loading, error, empty

0:54 – 1:15time and failure are state
request lifecyclestatustry / catch / finallyfailure modesaria-busyretry

The lifecycle of a request

Every list from a server has four states

loading

Request in flight. Say so; keep the old data visible if there is some.

ready

The data. What we've rendered since Lecture 5.

error

No data. A sentence, and a way out: Try again.

empty

Success with []. Not an error: "No stalls yet".

Designs usually show only ready. The other three are where apps feel broken: a blank list on a slow connection, a spinner that spins forever after an error.

State

One status field, so impossible states can't happen

Three booleans

One status

A union of string literals is the standard way to model this: TypeScript checks it, and libraries such as TanStack Query expose exactly status: "pending" | "error" | "success".

The pattern

Set, render, request, set, render

Two renders: one before the request, so "Loading…" appears at once; one after, whatever happened.

The error twice: the details to the console for you, a sentence in state.error for the person using the page.

Retry is a second call: the function always starts by setting the state it needs.

The pattern · rendering

render() draws every status

statusLine is a role="status" region; errorText a role="alert". Both stay in the HTML; only their text changes, so screen readers announce it.

Try again hides itself when loading starts. Move focus first: statusLine.focus(); loadStalls();

Failure modes

Four ways to fail, three of them reject

What happenedWhat your code seesWhere to handle itThe user should see
Network: offline, DNS, refused, CORSfetch rejects with a TypeError ("Failed to fetch" in Chrome)catch"No connection" + Try again
HTTP error: 4xx, 5xxresolves, response.ok === falseyour helper throws → catch5xx: Try again · 4xx: what to change
Body isn't JSONresponse.json() rejects: SyntaxErrorerror status: helper ignores the body · 2xx: let it throw (wrong URL)like an HTTP error
Aborted or timed outrejects: AbortError / TimeoutErrorcatch, check error.namenothing, if you aborted it on purpose

Without a timeout, a request to a hanging server can stay pending for minutes: fetch(url, { signal: AbortSignal.timeout(8000) }).

Accessibility · perceived performance

Tell everyone what's happening, including screen readers

aria-busy

aria-busy="true" marks a region as being updated. Support varies: a hint, not a substitute for a status message.

role="status"

A polite live region: "Loading the market…", then "10 stalls", announced without moving focus.

role="alert"

For the error: announced immediately. Keep the element in the page; change only its text.

keep what you have

Reloading? Keep the old list visible and dimmed rather than blanking the page.

skeletons

Grey placeholder cards of the right size: no layout shift when the data arrives.

don't flash

A response in 50 ms needs no spinner. Show it after ~300 ms, or use a skeleton.

Quick check · the forever spinner

The server answers 503. What does the user see?

"Loading…", forever. getStalls throws, the function stops at the await, the last two lines never run.
And Uncaught (in promise) HttpError: 503 in the console: nobody handled the rejected promise.
exercise

Exercise 3 · solo · 12 min

Slow and broken

  1. Loading: "Loading the market…" and aria-busy, on a slow server.
  2. Error: try … catch, the error box, for a 503 and for a dead network.
  3. Try again: the button reloads, the error box disappears at once.

08-fetch → Exercise 3 · Slow and broken. The checks run your page against a slow, a failing and an offline server. Bonus: empty.

Break. 15 min.

After the break: sending data, CORS, races, then the build on a real server.

POST
act 04

Sending data

1:30 – 1:54the page writes to the server
methodbodyContent-TypeFormData201422idempotency

fetch(url, init)

A POST: a method, a header, a body

POST /api/stalls/taco-bike/reviews HTTP/1.1
Host: localhost:3000
Content-Type: application/json
Content-Length: 66

{"author":"Ana","rating":5,"text":"…"}

The second argument maps one-to-one onto the request. body is a string you produce; fetch doesn't serialise objects for you.

Three bugs, three status codes

What the server receives when you forget something

body: review

An object as the body is converted with String(). The server receives [object Object].

400 · The body is not valid JSON

no Content-Type

A string body is sent as text/plain;charset=UTF-8. The server won't guess.

415 · Unsupported Media Type

rating: "5"

Every form value is a string. The API's contract says number.

422 · rating: A number, not a string

All three are visible in the Network panel's Payload tab before you read a line of server code.

From a form

FormData → an object → the types the API wants

bodyContent-Type if you don't set one
JSON.stringify(x)text/plain;charset=UTF-8: set application/json yourself
new FormData(form)multipart/form-data (files)
new URLSearchParams(x)application/x-www-form-urlencoded
file (a Blob)the file's type

After a 201

Render what the server stored, not what you sent

Pessimistic (today)

Wait for the 201, then render the response body: it has the id, the createdAt, the trimmed text. The page shows the truth.

Optimistic

Render immediately, send in the background, roll back if it fails. Feels instant; needs a temporary id and an undo path.

422 Unprocessable Content

The browser validates for speed. The server validates because it must.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/json

{
  "error": "validation_failed",
  "fields": {
    "rating": "A whole number from 1 to 5.",
    "text": "10–280 characters."
  }
}

Double submits

Click twice, get two reviews: POST is not idempotent

Client: disable the control while pending; re-enable in finally.

Server: an Idempotency-Key header (a UUID per submission); repeats are ignored. Payment APIs support it.

Design: make it idempotent where you can: PUT /api/saved/mezcal, not POST /api/saved.

The other methods

PUT, PATCH, DELETE, and a header for auth

No token or an invalid one: 401. A valid token that may not edit this stall: 403. A real token never goes in your source: it comes from a login.

exercise

Exercise 4 · solo · 13 min

Leave a review

  1. POST the review as JSON, rating a number; render the server's copy.
  2. 422: each message under its field. Anything else: a sentence. Keep their text.
  3. One click, one review: disabled while posting, re-enabled in finally.

08-fetch → Exercise 4 · Leave a review. Read your own request in the Network tab before asking for help.

CORS
act 05

Origins & races

1:54 – 2:11where a request may go, and when it comes back
same-origin policyCORSpreflightrace conditionsAbortControllersecrets

The same-origin policy

A page may send simple requests anywhere. Reading the response is the question.

Page at http://localhost:3000 fetches…Same origin?Why
http://localhost:3000/api/stallsyesscheme, host and port match
http://localhost:8080/api/stallsnodifferent port
https://localhost:3000/api/stallsnodifferent scheme
http://127.0.0.1:3000/api/stallsnodifferent host, even if it's the same machine
https://api.open-meteo.com/v1/forecastnoallowed anyway: that server sends CORS headers

Why: your browser holds cookies for your bank. Without this rule, any page you visit could read bank.example/account with your session.

CORS · Cross-Origin Resource Sharing

The server says who may read its responses

Browserpage at localhost:8080
Serverlocalhost:3000
preflight: "may I?"
OPTIONS /api/stalls/taco-bike/reviews · Origin: http://localhost:8080 · Access-Control-Request-Method: POST · …-Headers: content-type
204 · Access-Control-Allow-Origin: http://localhost:8080 · Allow-Methods: GET, POST, PUT, PATCH, DELETE · Allow-Headers: Content-Type, Authorization
"yes"
the real request
POST /api/stalls/taco-bike/reviews · Origin: http://localhost:8080
201 · Access-Control-Allow-Origin: http://localhost:8080 · {…}
readable

Simple (GET, HEAD, or a POST with a form or text body, no custom headers): no preflight, it reaches the server. A JSON body, PUT / PATCH / DELETE or Authorization: preflight first.

Live · break it, then fix it on the server

The error message, and where the fix goes

  1. Run the local server (project/server, CORS off) and serve the connected market with live-server; API = http://localhost:3000/api.
  2. Reload. The page says "no connection"; the console says:

Access to fetch at 'http://localhost:3000/api/stalls' from origin 'http://localhost:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

  1. Restart: node server.js --cors http://localhost:8080, with the exact origin in the address bar (127.0.0.1 ≠ localhost). Reload: ten vendors.

Fix it on the server. mode: "no-cors" doesn't.

Race lab · responses arrive out of order

Search as you type: which response wins?

AbortController · AbortSignal

Cancel the request you no longer need

The aborted fetch rejects with an AbortError; its render never runs. The browser cancels the request: no more bytes are downloaded.

AbortSignal.timeout(ms): a timeout. AbortSignal.any([a, b]): whichever comes first.

Also for: leaving a page or closing a dialog while its data is loading.

exercise

Exercise 5 · solo · 6 min

Search without races

  1. One AbortController per search; abort the previous one first.
  2. Ignore AbortError. Let real errors through.
  3. Type quickly, then read the Network tab: canceled, canceled, 200.

08-fetch → Exercise 5 · Search without races. Bonus at home: debounce.

Today, in one slide

The page is a client

HTTP

Method, URL, headers, body. Status, headers, body. Read it in the Network panel first.

async & fetch

One thread; callbacks wait in queues, microtasks first. A 404 resolves: check response.ok, in one helper.

async UI state

loading · ready · error · empty, in one status. Retry is loadStalls() again.

sending

Content-Type, JSON.stringify, real types. 201: render the server's copy. 422: field errors.

origins & trust

Cross-origin reads need the server's CORS headers. Keys in browser code are public; server data is user input: textContent.

time

Responses arrive out of order: abort what's obsolete. Disable what mustn't run twice.

Until next time

Homework & reading

Homework

  • Finish connecting the market, with chaos on: a pull request to harbour-market-starter.
  • The bonus cases: 2 (sort), 3 (empty), 5 (debounce).
  • One or several feature requests on top of it, in a second PR, for review.
  • Next class opens with 3-minute demos of the features.

Reading

Build · 45 min · individually

Connect the market to the API

  1. api.js: request() and HttpError; nothing else calls fetch.
  2. state: the stalls come from GET /api/stalls; data.js goes.
  3. What people see: loading, an error with Try again, ten stalls with ratings.
  4. Chaos: all of it with chaos on (control page).
# fork, clone, branch
git switch -c connect-the-market
npm start        # localhost:8080

# your market, chaos, reset:
harbour-api.lopin.me

# your code: starter/js/
# hand in: a pull request

Repository: github.com/nlopin/harbour-market-starter. Steps and checks: Connect the market.

Then · homework · a pull request for review

The market has feature requests

  1. Pick one or several open issues in the starter repository.
  2. A new branch on top of your connected market; one PR, with Closes #N for each issue it does.
  3. Done means done with chaos on. The PR description says how you tested it.
  4. Answer the review: reply to comments, push fixes to the same branch.
AI

You may use AI to write the code. You answer for every line in the PR: be ready to explain it in the review.

review

PRs may also be checked with AI, alongside a human review.

Issues: github.com/nlopin/harbour-market-starter/issues

build

Build · until 3:00

Connect the market

  1. Minute 12: getJSON("/stalls") gives ten stalls in the console.
  2. Minute 30: ten vendors on the page, loading and error handled.
  3. Minute 42: ratings, chaos on, push, open a PR.

Brief: Connect the market. API: api.html.

Thank you

Over the
wire.

exercises · playgrounds · the server · the briefs →
github.com/nlopin/programming-interactivity-exercises