Skip to content
aviral gupta

// I6.5 · ~45 min · Intermediate

Build: a reading-list client

In this build you write client.js: it talks to a small local server, tells the user what is happening, and never shows an out-of-date list.

Lesson 5 of 5 in I6 Network requests and client storage

End of the module

You will be able to

  • Send requests with a JSON body, and turn a status that is not ok into an ApiError
  • Show every state: Loading…, the list, an empty list, a server problem, bad input
  • Cancel a request that a newer one replaces, give up on a slow one, and tell the two apart
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I6.3. The signal is aborted before fetch starts. What does this print?

    const controller = new AbortController();
    controller.abort();
    try {
      await fetch("http://127.0.0.1:9/books", {signal: controller.signal});
    } catch (error) {
      console.log(error.name);
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. /broken answers with status 500. What does this print? (api is the server’s address.)

    const response = await fetch(api + "/broken");
    console.log(response.ok, response.status);
  3. Practice · Activity 3 of 7

    Fill in the method that makes one signal out of several: it aborts as soon as any of them does.

    const signal = AbortSignal.____([controller.signal, AbortSignal.timeout(1000)]);
    AbortSignal.([controller.signal
  4. Practice · Activity 4 of 7

    render collects what the view shows, in order. What does it show for /broken?

    const load = createListView(api, render);
    await load("/broken");
  5. Practice · Activity 5 of 7

    The title is only spaces. What does this print?

    try {
      await addBook(api, "   ", "Nobody");
    } catch (error) {
      console.log(error.name, error.status, error.message);
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The user clicks Reload while a slow load is still running. What does render show, in order?

    const load = createListView(api, render);
    await Promise.all([load("/slow"), load("/books")]);
  7. Apply · Activity 7 of 7

    Mini-task: with server.js and client.js from the worked example, add the book Emma by Jane Austen, mark it as read (it gets id 2), and try to mark the book with id 9, which does not exist: print No book with id 9. for that ApiError with status 404, and throw any other error on. Then load the list and print what the view showed last.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The client against the local server

main.js starts server.js with one book and uses client.js: it loads the list, adds a book, marks one as read, sends a title that the server refuses, loads a broken route, gives up on a slow one after 50 ms, and finally starts two loads at once.

main.js

import {startServer} from "./server.js";
import {createListView, addBook, markRead} from "./client.js";

const server = await startServer([{id: 1, title: "Dune", author: "Frank Herbert", read: false}]);
const api = server.url;
// In a page, render would write into an element; here it prints.
const load = createListView(api, (text) => console.log(text));

await load();
await addBook(api, "Emma", "Jane Austen");
await markRead(api, 1);
await load();

try {
  await addBook(api, "  ");
} catch (error) {
  console.log(`${error.name} ${error.status}: ${error.message}`);
}

await load("/broken");

const impatient = createListView(api, (text) => console.log(text), 50);
await impatient("/slow");

// The second load cancels the first: only one list is shown.
console.log("-- two loads at once");
await Promise.all([load("/slow"), load("/books")]);

await server.close();

client.js

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

Run it with

node main.js

Output

Loading…
[ ] 1. Dune by Frank Herbert
Loading…
[x] 1. Dune by Frank Herbert
[ ] 2. Emma by Jane Austen
ApiError 400: title is required
Loading…
The server has a problem. Please try again later.
Loading…
The server is too slow. Please try again.
-- two loads at once
Loading…
Loading…
[x] 1. Dune by Frank Herbert
[ ] 2. Emma by Jane Austen
  • Every load starts with Loading…, so the user never stares at an old list without knowing.
  • The 400 carries the server’s own text; the 500 gets a message for the user instead.
  • Of the two loads at once, the first is cancelled and shows nothing: no stale list.

Exercises

Exercise 1 of 3

Check the status

main.js is client.js from the example; server.js is next to it. request returns whatever JSON comes back, even for an error status, so a 500 looks like data. Make it throw an ApiError with the status and the server’s error text when response.ok is false.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    fetch fulfills for a 500 too; response.ok is false then.

  2. Hint 2

    Read the JSON first: it holds the server’s error text in data.error.

  3. Hint 3

    if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  return response.json();
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {request, addBook, ApiError} from './main.js';

/** The error a promise rejects with; the test fails when it fulfills. */
async function caught(promise) {
  try {
    await promise;
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a good request returns the JSON', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    assert.deepEqual(await request(server.url + '/books'), [{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  } finally {
    await server.close();
  }
});

test('a 500 becomes an ApiError with the server text', async () => {
  const server = await startServer([]);
  try {
    const error = await caught(request(server.url + '/broken'));
    assert.ok(error instanceof ApiError, `request threw ${error?.name}, not an ApiError`);
    assert.equal(error.status, 500);
    assert.equal(error.message, 'database is down');
  } finally {
    await server.close();
  }
});

test('a refused title becomes an ApiError 400', async () => {
  const server = await startServer([]);
  try {
    const error = await caught(addBook(server.url, '  '));
    assert.equal(error.name, 'ApiError');
    assert.equal(error.status, 400);
    assert.equal(error.message, 'title is required');
  } finally {
    await server.close();
  }
});

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

Loading, empty and broken

The list view shows nothing while it loads, an empty line for an empty list, and the raw message for a server error. Make it render Loading… before each request, No books yet. for an empty list, and The server has a problem. Please try again later. for an ApiError with a status of 500 or more.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Call render("Loading…") right before the request.

  2. Hint 2

    books.length === 0 decides between the list and No books yet.

  3. Hint 3

    Check error instanceof ApiError && error.status >= 500 before the general message.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    try {
      const books = await request(api + path, {signal});
      render(books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {createListView} from './main.js';

/** The error a promise rejects with; the test fails when it fulfills. */
async function caught(promise) {
  try {
    await promise;
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a list is shown after Loading…', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text))();
    assert.deepEqual(shown, ['Loading…', '[ ] 1. Dune by Frank Herbert']);
  } finally {
    await server.close();
  }
});

test('an empty list says so', async () => {
  const server = await startServer([]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text))();
    assert.deepEqual(shown, ['Loading…', 'No books yet.']);
  } finally {
    await server.close();
  }
});

test('a 500 asks to try again later', async () => {
  const server = await startServer([]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text))('/broken');
    assert.deepEqual(shown, ['Loading…', 'The server has a problem. Please try again later.']);
  } finally {
    await server.close();
  }
});

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

Cancel and give up

The list view waits for every request, however long it takes, and a slow, older answer can arrive after a newer one and be shown again. Make each load abort the one before it (ignore the AbortError), and give up after timeoutMs with The server is too slow. Please try again.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Keep one controller outside load; at the start of load, call controller?.abort() and make a new one.

  2. Hint 2

    AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]) gives one signal for both cases.

  3. Hint 3

    In catch, return quietly for error.name === "AbortError", and render the too-slow message for "TimeoutError".

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show. A new load cancels the one before it.
export function createListView(api, render, timeoutMs = 1000) {
  let controller = null;
  return async function load(path = "/books") {
    controller?.abort();
    controller = new AbortController();
    const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
    render("Loading…");
    try {
      const books = await request(api + path, {signal});
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error.name === "AbortError") return; // a newer load replaced this one
      if (error.name === "TimeoutError") render("The server is too slow. Please try again.");
      else if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export class ApiError extends Error {
  constructor(status, message) {
    super(message);
    this.name = "ApiError";
    this.status = status;
  }
}

// Sends a request and returns the JSON answer; a status that is not ok becomes an ApiError.
export async function request(url, {method = "GET", body, signal} = {}) {
  const response = await fetch(url, {
    method,
    signal,
    headers: body === undefined ? {} : {"Content-Type": "application/json"},
    body: body === undefined ? undefined : JSON.stringify(body)
  });
  const data = await response.json();
  if (!response.ok) throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
  return data;
}

export const formatBook = (book) => `${book.read ? "[x]" : "[ ]"} ${book.id}. ${book.title}${book.author ? " by " + book.author : ""}`;

export const addBook = (api, title, author) => request(api + "/books", {method: "POST", body: {title, author}});

export const markRead = (api, id) => request(`${api}/books/${id}`, {method: "PATCH", body: {read: true}});

// Loads the list and tells render what to show.
export function createListView(api, render, timeoutMs = 1000) {
  return async function load(path = "/books") {
    render("Loading…");
    try {
      const books = await request(api + path);
      render(books.length === 0 ? "No books yet." : books.map(formatBook).join("\n"));
    } catch (error) {
      if (error instanceof ApiError && error.status >= 500) render("The server has a problem. Please try again later.");
      else render(`Could not load the list: ${error.message}`);
    }
  };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {startServer} from './server.js';
import {createListView} from './main.js';

/** The error a promise rejects with; the test fails when it fulfills. */
async function caught(promise) {
  try {
    await promise;
  } catch (error) {
    return error;
  }
  assert.fail('nothing was thrown');
}

test('a newer load cancels the older one', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    const shown = [];
    const load = createListView(server.url, (text) => shown.push(text));
    await Promise.all([load('/slow'), load('/books')]);
    await new Promise((resolve) => setTimeout(resolve, 400)); // longer than /slow takes
    assert.deepEqual(shown, ['Loading…', 'Loading…', '[ ] 1. Dune by Frank Herbert']);
  } finally {
    await server.close();
  }
});

test('a slow answer times out', async () => {
  const server = await startServer([{id: 1, title: 'Dune', author: 'Frank Herbert', read: false}]);
  try {
    const shown = [];
    await createListView(server.url, (text) => shown.push(text), 50)('/slow');
    assert.deepEqual(shown, ['Loading…', 'The server is too slow. Please try again.']);
  } finally {
    await server.close();
  }
});

server.js

// A small local JSON server for the reading list, made with node:http. You do not need to change it.
import {createServer} from "node:http";

export async function startServer(books = []) {
  let nextId = books.length + 1;
  const server = createServer(async (request, response) => {
    const json = (status, value) => {
      response.writeHead(status, {"Content-Type": "application/json"});
      response.end(JSON.stringify(value));
    };
    let body = "";
    for await (const chunk of request) body += chunk;
    const {method, url} = request;
    const match = url.match(/^\/books\/(\d+)$/);
    if (method === "GET" && url === "/books") json(200, books);
    else if (method === "POST" && url === "/books") {
      const {title, author} = JSON.parse(body || "{}");
      if (typeof title !== "string" || title.trim() === "") return json(400, {error: "title is required"});
      const book = {id: nextId++, title: title.trim(), author: author ?? "", read: false};
      books.push(book);
      json(201, book);
    } else if (method === "PATCH" && match) {
      const book = books.find((b) => b.id === Number(match[1]));
      if (!book) return json(404, {error: "no such book"});
      book.read = JSON.parse(body || "{}").read === true;
      json(200, book);
    } else if (url === "/broken") json(500, {error: "database is down"});
    else if (url === "/slow") {
      // answers after 300 ms, unless the client gave up
      const timer = setTimeout(() => json(200, books), 300);
      response.on("close", () => clearTimeout(timer));
    } else json(404, {error: "not found"});
  });
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); // port 0: any free port
  return {
    url: `http://127.0.0.1:${server.address().port}`,
    close: () => new Promise((resolve) => server.close(resolve))
  };
}

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Using the JSON of an error status as data

import {startServer} from "./server.js";

const server = await startServer();
try {
  const response = await fetch(server.url + "/broken");
  const books = await response.json();
  console.log(books.map((book) => book.title));
} finally {
  await server.close();
}

What Node.js prints

TypeError: books.map is not a function

Why, and the fix

fetch fulfilled, because the server answered, but with status 500 and the body {error: "database is down"}, which is an object, not an array. Check response.ok before using the body, and throw an ApiError otherwise, as request does.

Treating a timeout like a replaced request

import {startServer} from "./server.js";

const server = await startServer();
try {
  await fetch(server.url + "/slow", {signal: AbortSignal.timeout(50)});
} catch (error) {
  if (error.name !== "AbortError") throw error;
} finally {
  await server.close();
}

What Node.js prints

The operation was aborted due to timeout

Why, and the fix

A signal from AbortSignal.timeout rejects with a TimeoutError, not an AbortError, so this catch throws it on and the program crashes. Ignore only AbortError (a newer request replaced this one), and tell the user about a TimeoutError: The server is too slow. Please try again.

Reusing an aborted controller

import {startServer} from "./server.js";

const server = await startServer();
const controller = new AbortController();
controller.abort(); // the user cancelled an earlier load
try {
  const response = await fetch(server.url + "/books", {signal: controller.signal});
  console.log(await response.json());
} finally {
  await server.close();
}

What Node.js prints

This operation was aborted

Why, and the fix

An AbortSignal can only be used once: after abort(), every fetch with it rejects at once, so the new load never even reaches the server. Make a new AbortController for each request, as load does with controller = new AbortController().

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

One function for every request

request(url, {method, body, signal}) wraps fetch. With a body, it sends JSON.stringify(body) and the header Content-Type: application/json. fetch only rejects when no answer arrives, so request checks response.ok itself and throws an ApiError with the status and the server’s error text, such as 400 and "title is required". addBook and markRead are one line each on top of it.

Every state on screen

A list view calls render("Loading…") before it starts, so the user sees that something happens. Then it renders the books, "No books yet." for an empty list, or a message that fits the error: a 5xx status is the server’s problem and worth another try later, a 4xx means the request was wrong. The view does not know about the page: render can write into an element, or print, as in this lesson.

Never show a stale answer

When the user clicks Reload twice, the first answer may arrive last and overwrite the newer one. So each load aborts the controller of the one before. AbortSignal.any([controller.signal, AbortSignal.timeout(1000)]) combines that with a time limit. A replaced request rejects with an AbortError, which the view ignores; a slow one rejects with a TimeoutError, which the user should hear about.

Sources

Last reviewed October 5, 2026