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).
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."}
Method: what to do. GET reads, POST creates.
Target: the path (and query) of the resource.
Headers: metadata, Name: value. Names are case-insensitive.
Body: optional. Its format is declared by Content-Type.
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
Method
Meaning
Body
Safe
Idempotent
Harbour API
GET
read a resource
no
yes
yes
GET /api/stalls
POST
create, or run an action
yes
no
no
POST /api/stalls/:id/reviews
PUT
create or replace at a known URL
usually
no
yes
PUT /api/saved/:id
PATCH
change some fields
yes
no
not guaranteed
PATCH /api/stalls/:id
DELETE
remove a resource
no
no
yes
DELETE /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
Code
Meaning
When the Harbour API sends it
200 OK
success, the body has the result
any successful GET
201 Created
a new resource exists
a POSTed review; Location header points at it
204 No Content
success, no body
PUT / DELETE: don't call response.json()
304 Not Modified
your cached copy is still valid
(real APIs) conditional GETs with ETag, handled by the browser
400 Bad Request
malformed request
invalid JSON, an unknown ?tag=
401 Unauthorized
not authenticated
no token, or an invalid one
403 Forbidden
authenticated, not allowed
a vendor's token on another vendor's stall
404 Not Found
no such resource
/api/stalls/pizza
405 · 409 · 415
wrong method · conflicts with current state · wrong body type
DELETE /api/stalls · ordering from a sold-out stall · no Content-Type
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 queue
Microtask queue
No 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
Queued
when the event happens: the timer fires, the user acts, the data arrives
when the promise settles, or at once if it already has
never
Runs
one per turn of the loop
all of them after each task, newcomers included
right 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
loadStalls(): fetch /api/stalls, render all 10.
getJSON(url): reject on a 404 or a 503, with the server's message.
Filters on the server: ?tag=food built with URLSearchParams.
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 happened
What your code sees
Where to handle it
The user should see
Network: offline, DNS, refused, CORS
fetch rejects with a TypeError ("Failed to fetch" in Chrome)
catch
"No connection" + Try again
HTTP error: 4xx, 5xx
resolves, response.ok === false
your helper throws → catch
5xx: Try again · 4xx: what to change
Body isn't JSON
response.json() rejects: SyntaxError
error status: helper ignores the body · 2xx: let it throw (wrong URL)
like an HTTP error
Aborted or timed out
rejects: AbortError / TimeoutError
catch, check error.name
nothing, 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
Loading: "Loading the market…" and aria-busy, on a slow server.
Error: try … catch, the error box, for a 503 and for a dead network.
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.
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
Run the local server (project/server, CORS off) and serve the connected market with live-server; API = http://localhost:3000/api.
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.
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
One AbortController per search; abort the previous one first.
Ignore AbortError. Let real errors through.
Type quickly, then read the Network tab: canceled, canceled, 200.