The previous lesson delivered half a promise: Nómada Tasks now survives F5. But Marta, Iván and Lucía still each have their own localStorage, with their own version of the board, with no way of seeing one another. The other half —making the board the same one for the whole team— requires the data to live on a server and the browser to know how to talk to it without reloading the page. That is AJAX, and the modern tool for doing it is fetch. In this lesson you will learn what AJAX changed on the web, review the HTTP you actually need (verbs, status codes and headers), master fetch and the Response object with its most famous trap —a 404 does not reject the promise—, send data as JSON and as FormData, build URLs with parameters, understand CORS well enough not to lose an afternoon, and write js/data/tasks-api.js, the network sibling of the local repository. And you will finally retire those readSimulatedBacklog() and saveSimulatedReport() functions from 05-06 that faked latency with setTimeout.

Contents

  1. What AJAX is and what it changed
  2. XMLHttpRequest, the ancestor
  3. HTTP in ten minutes: verbs, paths, status codes and headers
  4. fetch: the minimal request
  5. The Response object and the body methods
  6. The fundamental trap: fetch does not reject on 404 or 500
  7. Sending data: method, headers and body
  8. Sending FormData and files
  9. Query parameters with URL and URLSearchParams
  10. CORS: why the browser blocks you
  11. Authentication: Authorization and credentials
  12. Nómada Tasks: js/data/tasks-api.js
  13. Real practice: json-server on your machine
  14. Common Mistakes and Tips
  15. Exercises
  16. Conclusion

  1. What AJAX is and what it changed

AJAX stands for Asynchronous JavaScript And XML, a name coined in 2005 that today is half a lie: hardly anyone uses XML —JSON is used instead— and the technique applies to far more than XML. What the acronym names is still current: asking the server for data from JavaScript, without reloading the page, and updating only the part of the DOM that changes.

Compare the two models:

Classic web (no AJAX) Web with AJAX
When you press "mark as done" The browser submits a form and reloads the whole page JavaScript sends a request in the background
What travels back A complete HTML document A few hundred bytes of JSON
Interface state Lost: scroll, focus, filters Preserved
User perception White flash, waiting The card changes and that's it
Server work Render the whole page Return the data

What AJAX makes possible, in Nómada Tasks terms: Marta presses the advance-status button, the card moves column, and neither the scroll nor the assignee filter nor the focus is lost. The cycle state → render → event → new state → render that you built in 06-06 is still in charge; the only thing that changes is that part of the "new state" now arrives from the server.

And there is something that does not change and is worth saying early: the server is still in charge. An AJAX request is no more secure than a form; the user can craft whatever they like from the console. All client-side validation is a courtesy; the real one is on the server.

  1. XMLHttpRequest, the ancestor

Before fetch there was XMLHttpRequest (XHR), available since the early 2000s. You will see it in old code and it is worth recognizing:

// XHR style: events and numeric states, no promises
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.tallernomada.example/v1/tasks');
xhr.onload = function () {
  if (xhr.status >= 200 && xhr.status < 300) {
    const tasks = JSON.parse(xhr.responseText);   // parsing done by hand
    console.log(tasks.length);
  } else {
    console.error('HTTP error', xhr.status);
  }
};
xhr.onerror = function () { console.error('Network failure'); };
xhr.send();

It is the callback style from 05-05 taken to the extreme: no promises, no async/await, and the famous readyState with its five numeric values. The comparison:

XMLHttpRequest fetch
Model Events and callbacks Promises (05-06)
Syntax open + send + onload One call that returns a promise
JSON parsing Manual (JSON.parse(responseText)) await response.json()
HTTP errors (404, 500) You check xhr.status It does not reject either: you check response.ok
Cancellation xhr.abort() AbortController (07-03)
Upload progress Yes, progress event No (download only, with streams)
Response streaming Limited Yes, response.body is a ReadableStream
Built-in timeout Yes, xhr.timeout No, you have to build it (07-03)

Today you use fetch except in two cases: when you need an upload progress bar for a large file, or when you are maintaining old code. The two gaps in fetch —timeout and cancellation— are covered by AbortController, and that is the next lesson.

  1. HTTP in ten minutes: verbs, paths, status codes and headers

An HTTP request has four parts: a verb, a path, some headers and sometimes a body. The response has a status code, its own headers and its body.

The verbs

Verb What it means Does it carry a body? Idempotent? In Nómada Tasks
GET Read a resource No Yes List tasks, read one task
POST Create a new resource Yes No Create a task
PUT Replace an entire resource Yes Yes Save a task with all its fields
PATCH Modify part of a resource Yes It depends Change only the status
DELETE Delete a resource Usually not Yes Remove a task

Idempotent means that repeating the same request leaves the system in the same state as doing it once. A PUT of task 6 with the same data, a thousand times, leaves one task 6 with that data. POST a thousand times creates a thousand tasks. That distinction looks theoretical until in 07-03 you decide which requests can be retried without fear.

Resource paths

A REST API names things, not actions, and lets the verb say what to do with them:

GET    /v1/tasks               → the whole collection
GET    /v1/tasks?status=done   → the filtered collection
GET    /v1/tasks/6             → one specific item
POST   /v1/tasks               → create a new one (the server assigns the id)
PUT    /v1/tasks/6             → replace the whole of 6
PATCH  /v1/tasks/6             → change part of 6
DELETE /v1/tasks/6             → delete 6

A common antipattern is POST /v1/createTask or GET /v1/deleteTask?id=6. The second one is especially bad: a GET must not change anything, and any search engine or browser prefetcher could fire it.

The domain https://api.tallernomada.example/v1 that we will use throughout this lesson is fictional. The .example TLD is reserved by IANA precisely for documentation and will never resolve. In section 13 you will set up a real server on your machine to practice with.

The status codes

They are grouped into families, and knowing the families is more than enough:

Family Meaning The ones you will see
1xx Informational 101 Switching Protocols (you will see it in WebSockets, 07-04)
2xx Success 200 OK, 201 Created (after a POST), 204 No Content (after a DELETE)
3xx Redirection 301 permanent, 304 Not Modified (cache)
4xx Client error: the request is wrong 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity, 429 Too Many Requests
5xx Server error: the request was fine, the server failed 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout

The boundary between 4xx and 5xx is what governs your error handling: a 4xx is fixed by the client (correct the data, sign in, stop insisting); a 5xx is not fixed by the client (retry later and report it). Two pairs that are often confused:

  • 401 Unauthorized means "I do not know who you are" (the credential is missing or expired). 403 Forbidden means "I know who you are and you may not". The first one calls for signing in; the second one does not.
  • 204 No Content is a success with no body. Calling .json() on it throws, because there is nothing to parse. You will bear that in mind in deleteTask().

The headers

Header Who sets it What for
Content-Type Whoever sends a body What format the body is in: application/json, multipart/form-data
Accept The client What formats it understands back: application/json
Authorization The client The credential: Bearer <token>
Cache-Control Both How it may be cached
Content-Length Whoever sends a body Size in bytes (the browser sets it)
ETag / If-None-Match Server / client Resource version, to save transfer

The most frequent beginner mistake with headers: sending a JSON body without Content-Type: application/json. Many servers interpret it as plain text and return a baffling 400.

  1. fetch: the minimal request

fetch(url, options) returns a promise that resolves with a Response object as soon as the response headers arrive. With what you know from 05-06, it reads effortlessly:

// With async/await, which is how you will always write it
async function list() {
  const response = await fetch('https://api.tallernomada.example/v1/tasks');
  const tasks = await response.json();
  console.log(tasks.length);
}

Two awaits, and each waits for something different. This is what is hardest at first:

sequenceDiagram
    participant JS as Your code
    participant B as Browser
    participant S as API

    JS->>B: fetch(url)
    B->>S: GET /v1/tasks
    Note over B,S: Network latency
    S-->>B: 200 OK + headers
    B-->>JS: ✅ promise resolved with Response
    Note over JS: The BODY has not arrived yet
    JS->>B: response.json()
    B-->>JS: (reads the body, parses it)
    B-->>JS: ✅ second promise resolved with the data

The first promise resolves with the headers; the body may still be arriving. That is why response.json() is another asynchronous operation and needs its own await. Forgetting it produces the most common error of all:

const response = await fetch(url);
const data = response.json();          // ✗ the await is missing
console.log(data.length);              // undefined  ← it is a Promise, not an array

  1. The Response object and the body methods

Response describes the complete response:

Member Type What it contains
ok boolean true if the status is between 200 and 299
status number The code: 200, 404, 500
statusText string The text: 'OK', 'Not Found'
headers Headers The headers, with get, has, entries
url string The final URL (after redirects)
redirected boolean Whether there was a redirect
type string 'basic', 'cors', 'opaque'
bodyUsed boolean Whether the body has already been consumed

And the body methods, all asynchronous because they all return promises:

Method Returns When
json() The parsed value JSON responses. Throws if the body is not valid JSON
text() string HTML, CSV, text, or to debug what actually arrived
blob() Blob Images, PDFs, any binary
arrayBuffer() ArrayBuffer Low-level binary (you will use it in 07-07 with Wasm)
formData() FormData multipart or urlencoded responses
const response = await fetch('https://api.tallernomada.example/v1/tasks');

console.log(response.ok);                              // true
console.log(response.status, response.statusText);     // 200 'OK'
console.log(response.headers.get('content-type'));     // 'application/json; charset=utf-8'

for (const [name, value] of response.headers) {
  console.log(name, '=', value);
}

Header names are case-insensitive: headers.get('Content-Type') and headers.get('content-type') are the same thing.

One rule that catches people out: the body can only be read once. It is a stream, and once consumed, that is that.

const response = await fetch(url);
const text = await response.text();
const data = await response.json();   // ✗ TypeError: body stream already read

If you need to read it twice —for example, to try json() and, if it fails, look at the text() to debug— clone it first:

const response = await fetch(url);
const copy = response.clone();        // ← clone BEFORE reading

try {
  return await response.json();
} catch {
  console.error('The response was not JSON. Actual content:', await copy.text());
  throw new Error('Uninterpretable response');
}

That pattern will save your day when a proxy returns an HTML error page where you expected JSON.

  1. The fundamental trap: fetch does not reject on 404 or 500

This is the thing to know about fetch, and the one that has produced the most broken code:

The fetch promise only rejects if the request never completed: network down, DNS that does not resolve, blocked by CORS, request cancelled. If the server responds —even if it responds 404, 403 or 500— the promise resolves normally.

// ✗ Broken: it looks right and it is not
async function brokenList() {
  try {
    const response = await fetch('https://api.tallernomada.example/v1/taskz');   // misspelled path
    const data = await response.json();     // the server returned 404 with an error JSON
    return data;                            // returns { error: 'Not found' } as if they were tasks
  } catch (error) {
    console.error('I never get here because of a 404');
  }
}

The catch does not run. response.json() parses the error body without complaining, and your application carries on with rubbish. The fix is to always check ok:

// ✓ Correct
async function list() {
  const response = await fetch('https://api.tallernomada.example/v1/tasks');

  if (!response.ok) {                                   // ← the line that cannot be missing
    throw new Error(`HTTP ${response.status} ${response.statusText}`);
  }
  return response.json();
}

The table that explains the reasoning behind the design:

Situation Does the fetch promise…? How you detect it
200 OK resolves response.ok === true
404 Not Found resolves response.ok === false, status 404
500 Internal Server Error resolves response.ok === false, status 500
No connection / DNS fails rejects TypeError: Failed to fetch
Blocked by CORS rejects TypeError, with detail only in the console
Request cancelled rejects AbortError (07-03)

The logic of the design is defensible: fetch promises to make the HTTP request, and a 404 is a successfully made HTTP request whose answer is "it does not exist". That it is defensible does not stop it being the number-one cause of network bugs. In section 12 we will encapsulate the check once and for all so we never forget it again.

Notice as well the detail in the last diagnostic row: when CORS blocks a request, JavaScript receives a generic TypeError with no details. The real reason only appears in the browser console. It is deliberate —giving details would leak information across origins— and it means that the console is your only source of truth when facing a CORS failure.

  1. Sending data: method, headers and body

The second parameter of fetch is an options object. To create a task:

const newTask = {
  title: 'Service the screen-printing press',
  assignee: 'Iván',
  priority: 'high',
  tags: ['screen-printing', 'maintenance'],
  estimatedHours: 4,
  dueDate: '2026-10-10'
};

const response = await fetch('https://api.tallernomada.example/v1/tasks', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',      // what I am sending
    'Accept': 'application/json'             // what I expect back
  },
  body: JSON.stringify(newTask)              // ← the body is ALWAYS text or binary
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);

const created = await response.json();
console.log(created.id);                     // 7  ← the id is assigned by the server (R1)

Four points:

  • body is never an object. If you pass it { title: '…' } directly, the browser converts it with String() and sends '[object Object]', exactly the same disaster as in localStorage. JSON.stringify is compulsory.
  • JSON.stringify uses the toJSON of your instances, so you can pass it a Task directly and it will travel complete, private fields included. 05-03 paying off once again.
  • The id is assigned by the server, not the client. It is rule R1 of the project, and in a system with several users it is the only way not to collide.
  • fetch does not throw if the server rejects the data. A 422 Unprocessable Entity with the list of invalid fields arrives as a normal response; you have to check ok and read the error body.

The options you will use most:

Option Values What for
method 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' The verb (defaults to 'GET')
headers Object or Headers The headers
body string, FormData, Blob, URLSearchParams The body
credentials 'omit', 'same-origin', 'include' Whether cookies travel
mode 'cors', 'same-origin', 'no-cors' Cross-origin policy
cache 'default', 'no-store', 'reload' Use of the HTTP cache
signal AbortSignal Cancellation (07-03)
redirect 'follow', 'error', 'manual' What to do with 3xx

  1. Sending FormData and files

When there is a file involved —Iván wants to attach a photo of the sketch— JSON is no use: it is text. You use FormData, which you already know from 06-07:

const form = document.querySelector('#new-task');
const data = new FormData(form);             // takes every field that has a name
data.append('attachment', fileInput.files[0]);
data.append('source', 'web');

const response = await fetch('https://api.tallernomada.example/v1/tasks', {
  method: 'POST',
  body: data                                 // ← NO Content-Type!
});

Do not set Content-Type when you send FormData. The browser generates it on its own, and adds the boundary —a random separator— that the server needs in order to split the body into parts. If you write it yourself, the boundary is missing and the server cannot read anything. It is a baffling failure, because the code "looks more complete" with the header in place.

A quick comparison of the three body formats:

Format Content-Type Do you set it? When
JSON application/json Yes The normal case in an API
FormData multipart/form-data; boundary=… No Files, or forms as they come
URLSearchParams application/x-www-form-urlencoded No Old APIs, simple forms
// URLSearchParams as the body: the browser sets the right Content-Type
const body = new URLSearchParams({ status: 'done', reviewer: 'Marta' });
await fetch('https://api.tallernomada.example/v1/tasks/6', { method: 'PATCH', body });

  1. Query parameters with URL and URLSearchParams

Building the query string by hand is an endless source of encoding bugs:

// ✗ Fragile: what if the text has a space, an & or an accented letter?
const url = `https://api.tallernomada.example/v1/tasks?assignee=${assignee}&text=${text}`;
// With assignee = 'Lucía' and text = 'press & roller' → broken URL

The URL and URLSearchParams classes encode for you:

const url = new URL('https://api.tallernomada.example/v1/tasks');
url.searchParams.set('assignee', 'Lucía');
url.searchParams.set('status', 'pending');
url.searchParams.set('text', 'press & roller');
url.searchParams.set('limit', 20);

console.log(url.toString());
// https://api.tallernomada.example/v1/tasks?assignee=Luc%C3%ADa&status=pending&text=press+%26+roller&limit=20

const response = await fetch(url);           // fetch accepts a URL object, no toString() needed

The useful members of URLSearchParams:

Method What it does
set(key, value) Sets the value, replacing any that were there
append(key, value) Adds another value with the same key (?tag=a&tag=b)
get(key) / getAll(key) Reads the first one / all of them
has(key) Whether it exists
delete(key) Removes it
toString() The encoded string, without the ?

A helper you will use in the project, skipping empty filters:

/** Builds an API URL with only the parameters that have a value. */
function apiUrl(path, params = {}) {
  const url = new URL(path, BASE);
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null || value === '') continue;   // empty filter = no filter
    url.searchParams.set(key, value);
  }
  return url;
}

apiUrl('/v1/tasks', { assignee: 'Iván', status: '', text: null });
// https://api.tallernomada.example/v1/tasks?assignee=Iv%C3%A1n

That new URL(path, BASE) with two arguments resolves relative paths against a base, exactly as an <a href> does. Very handy for not having to concatenate slashes.

  1. CORS: why the browser blocks you

Sooner or later you will write a perfect request and the console will say something like:

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

CORS (Cross-Origin Resource Sharing) is the mechanism that decides whether a page from one origin may read responses from another origin. By default, the browser applies the same-origin policy: you can send requests to other origins, but not read their responses unless that server authorizes it.

Why does it exist? Because the browser automatically sends the user's cookies. Without this policy, any malicious page you visited could request https://yourbank.example/api/balance with your cookies and read the response.

There are two kinds of cross-origin request, and the difference matters a lot:

Simple request: the browser sends it directly and then decides whether to let you read the response. It counts as simple if it uses GET, HEAD or POST, and its headers are the ordinary ones, with a Content-Type limited to text/plain, multipart/form-data or application/x-www-form-urlencoded.

sequenceDiagram
    participant P as Page<br/>localhost:3000
    participant B as Browser
    participant S as api.tallernomada.example

    P->>B: fetch('https://api…/v1/tasks')
    B->>S: GET /v1/tasks<br/>Origin: http://localhost:3000
    S-->>B: 200 OK<br/>Access-Control-Allow-Origin: http://localhost:3000
    B->>B: Does the header authorize my origin?
    B-->>P: ✅ Response delivered

Request with preflight: anything else —PUT, PATCH, DELETE, or a POST with Content-Type: application/json, or an Authorization header— forces the browser to ask first with an OPTIONS request.

sequenceDiagram
    participant P as Page<br/>localhost:3000
    participant B as Browser
    participant S as api.tallernomada.example

    P->>B: fetch(url, { method: 'PATCH', headers: {…} })
    Note over B: PATCH + JSON Content-Type<br/>⇒ preflight required
    B->>S: OPTIONS /v1/tasks/6<br/>Origin: http://localhost:3000<br/>Access-Control-Request-Method: PATCH<br/>Access-Control-Request-Headers: content-type
    S-->>B: 204 No Content<br/>Access-Control-Allow-Origin: http://localhost:3000<br/>Access-Control-Allow-Methods: GET, POST, PATCH, DELETE<br/>Access-Control-Allow-Headers: content-type<br/>Access-Control-Max-Age: 86400
    B->>B: Authorized ✅
    B->>S: PATCH /v1/tasks/6 (the real request)
    S-->>B: 200 OK + Access-Control-Allow-Origin
    B-->>P: ✅ Response delivered

That extra OPTIONS explains two things that puzzle people: why two entries appear in the Network tab for a single call, and why the failure sometimes happens "before" the server sees your real request.

The headers the server controls:

Response header What it authorizes
Access-Control-Allow-Origin Which origin may read (https://app.taller.example or *)
Access-Control-Allow-Methods Which verbs are allowed
Access-Control-Allow-Headers Which headers the client may send
Access-Control-Allow-Credentials Whether cookies may be sent (true)
Access-Control-Expose-Headers Which response headers your JavaScript may read
Access-Control-Max-Age How many seconds to cache the preflight

And the conclusion that saves whole afternoons:

CORS cannot be fixed from the client. Not with mode, not with headers, not with tricks. The browser applies it according to what the server responds. There are only three legitimate ways out: the server adds the headers, you put a proxy on your own origin that forwards the requests, or you serve the API and the web app under the same origin.

Two nuances that get misread:

  • mode: 'no-cors' does not switch CORS off. It gives you an opaque response: status 0, ok false and an unreadable body. It is for very specific cases (precaching in a service worker), not for bypassing anything.
  • "Allow CORS" browser extensions only disable the check on your machine. Your code will still be broken for everybody else. Use them, at most, to diagnose.
  • Access-Control-Allow-Origin: * is incompatible with credentials. If you send cookies, the server has to name your exact origin.

  1. Authentication: Authorization and credentials

There are two ways for the API to know who you are.

A token in the Authorization header, the usual pattern for APIs:

const response = await fetch('https://api.tallernomada.example/v1/tasks', {
  headers: { 'Authorization': `Bearer ${token}` }
});

Cookies, which the browser manages on its own. With fetch you have to ask for them explicitly when the API is on another origin:

credentials Behavior
'same-origin' Default: cookies only if the URL is same-origin
'include' Cookies always, cross-origin included (requires Access-Control-Allow-Credentials: true)
'omit' Never sends cookies
await fetch('https://api.tallernomada.example/v1/tasks', { credentials: 'include' });

Which one to choose? Picking up the warning from 07-01:

Token in localStorage HttpOnly cookie
Can an XSS read it? Yes, all of it No, JavaScript cannot see it
Is it sent automatically? No, you set it on every request Yes, by the browser
CSRF risk Low It exists; mitigated with SameSite and anti-CSRF tokens
Expiry You manage it The server does, with Max-Age

The recommendation has not changed: for a real user session, an HttpOnly; Secure; SameSite=Lax cookie, issued and validated by the server. If your API forces a token in localStorage —which is common— assume that an XSS is account theft and treat XSS prevention (06-02: textContent instead of innerHTML) as part of authentication security.

Three more rules, short and non-negotiable:

  • HTTPS always. Over http://, headers and bodies travel readable by anyone on the network. A token sent over HTTP is a public token.
  • Never put API keys in client code. Everything that reaches the browser is visible. If a key must stay secret, your server makes the request, not the page.
  • On a 401, clear the local session and send the user to sign in. Retrying with an expired token only generates noise.

  1. Nómada Tasks: js/data/tasks-api.js

You can now write the network sibling of LocalRepository. The same responsibility —translating between the outside world and the model— through a different medium.

// js/data/tasks-api.js
import { Task } from '../model/task.js';

/**
 * Fictional example API. The .example TLD is reserved and NEVER resolves:
 * to practice for real, start the json-server from section 13 and change this
 * constant to 'http://localhost:3000'.
 */
const BASE = 'https://api.tallernomada.example/v1';

const JSON_HEADERS = {
  'Content-Type': 'application/json',
  'Accept': 'application/json'
};

/** Joins the base with the path and adds only the parameters that have a value. */
function buildUrl(path, params = {}) {
  const url = new URL(BASE + path);
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null || value === '') continue;
    url.searchParams.set(key, value);
  }
  return url;
}

/**
 * A single `ok` check for the whole application: written once, impossible to forget.
 * In 07-03 this function will grow into `fetchJson`, with its ApiError.
 */
async function check(response) {
  if (response.ok) return response;
  const detail = await response.text().catch(() => '');
  throw new Error(`HTTP ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}`);
}

/** GET /v1/tasks → array of Task instances (not of plain objects). */
export async function listTasks({ assignee, status, text } = {}) {
  const response = await fetch(buildUrl('/tasks', { assignee, status, text }), {
    headers: { 'Accept': 'application/json' }
  });
  await check(response);
  const plain = await response.json();
  return plain.map((data) => Task.fromJSON(data));           // ← same boundary as in 07-01
}

/** GET /v1/tasks/:id */
export async function getTask(id) {
  const response = await fetch(buildUrl(`/tasks/${id}`), {
    headers: { 'Accept': 'application/json' }
  });
  await check(response);
  return Task.fromJSON(await response.json());
}

/** POST /v1/tasks → 201 Created with the task already given an id (R1: the server assigns it). */
export async function createTask(data) {
  const response = await fetch(buildUrl('/tasks'), {
    method: 'POST',
    headers: JSON_HEADERS,
    body: JSON.stringify(data)               // if `data` is a Task, its toJSON() serializes the whole of it
  });
  await check(response);
  return Task.fromJSON(await response.json());
}

/** PATCH /v1/tasks/:id → partial changes; PUT would replace the whole task. */
export async function updateTask(id, changes) {
  const response = await fetch(buildUrl(`/tasks/${id}`), {
    method: 'PATCH',
    headers: JSON_HEADERS,
    body: JSON.stringify(changes)
  });
  await check(response);
  return Task.fromJSON(await response.json());
}

/** DELETE /v1/tasks/:id → normally 204 No Content, with NO body to parse. */
export async function deleteTask(id) {
  const response = await fetch(buildUrl(`/tasks/${id}`), { method: 'DELETE' });
  await check(response);
  return true;                               // ← no response.json(): a 204 has no body
}

Four decisions worth underlining:

  • The model boundary is respected. The module returns Task instances, not plain objects, exactly as LocalRepository did. The rest of the application does not know where the data comes from. That is the point of having a data/ folder.
  • check is written only once. The trap from section 6 is neutralized by encapsulating it, not by remembering it.
  • deleteTask does not call json(). A 204 No Content has no body and json() would throw.
  • There is no try/catch here. This module translates and propagates; the one that decides what to show the user is the view. How to classify and present those errors is precisely the subject of 07-03.

And this is how the simulated functions from 05-06 are finally replaced:

// js/app.js — before
// const backlog = await readSimulatedBacklog();    // setTimeout faking latency

// js/app.js — now
import { listTasks } from './data/tasks-api.js';
import { LocalRepository } from './data/local-repository.js';

const repository = new LocalRepository();

async function start() {
  // 1 · Paint whatever is in local storage straight away: the interface does not wait for the network
  const local = repository.load();
  if (local !== null) { view.update({ board: local }); }

  // 2 · Fetch the truth from the server and refresh
  const tasks = await listTasks();
  const board = new Board('Taller Nómada', tasks);
  view.update({ board });
  repository.save(board);                    // ← the local copy is brought up to date
}

start();

There is the pattern that makes an application feel fast: local first, network afterwards. The storage from 07-01 and the network from this lesson do not compete; they complement each other. (That naked await, with no try/catch and no loading state, is still half-finished: you will complete it in the next lesson.)

  1. Real practice: json-server on your machine

api.tallernomada.example does not exist. To practice you need a real server, and the quickest one to set up is json-server, which turns a JSON file into a complete REST API.

# 1 · Create a folder for the test server
mkdir nomada-api && cd nomada-api

# 2 · Start it straight away with npx (nothing permanent needs installing)
npx json-server --watch db.json --port 3000

With this db.json, which is the project's canonical backlog:

{
  "tasks": [
    { "id": 1, "title": "Redesign the multipurpose room", "assignee": "Iván",
      "priority": "high", "status": "in-progress", "tags": ["design", "space"],
      "estimatedHours": 12, "dueDate": "2026-09-30", "reviewer": "Marta" },
    { "id": 2, "title": "Migrate the workshop website", "assignee": "Lucía",
      "priority": "medium", "status": "pending", "tags": ["web"],
      "estimatedHours": 8, "dueDate": "2026-10-15", "reviewer": "Iván" },
    { "id": 3, "title": "Bookbinding catalog", "assignee": "Iván",
      "priority": "medium", "status": "pending", "tags": ["design", "bookbinding"],
      "estimatedHours": 8, "dueDate": "2026-10-05", "reviewer": "Marta" },
    { "id": 4, "title": "Screen-printing storeroom inventory", "assignee": "Lucía",
      "priority": "low", "status": "done", "tags": ["workshop", "inventory"],
      "estimatedHours": 3, "dueDate": "2026-09-12", "reviewer": "Marta" },
    { "id": 5, "title": "Prepare the open day", "assignee": "Marta",
      "priority": "high", "status": "in-progress", "tags": ["event"],
      "estimatedHours": 6, "dueDate": "2026-11-20", "reviewer": "Lucía" },
    { "id": 6, "title": "Carpentry workshop quote", "assignee": "Iván",
      "priority": "high", "status": "pending", "tags": ["carpentry", "purchasing"],
      "estimatedHours": 5, "dueDate": "2026-09-05", "reviewer": "Marta" }
  ]
}

That file immediately gives you every route you need:

curl http://localhost:3000/tasks
curl http://localhost:3000/tasks/6
curl "http://localhost:3000/tasks?assignee=Iván&status=pending"
curl -X POST http://localhost:3000/tasks \
     -H "Content-Type: application/json" \
     -d '{"title":"Service the press","assignee":"Iván","estimatedHours":4}'
curl -X PATCH http://localhost:3000/tasks/6 \
     -H "Content-Type: application/json" -d '{"status":"in-progress"}'
curl -X DELETE http://localhost:3000/tasks/6

You only have to change one line in your module:

const BASE = 'http://localhost:3000';        // ← instead of the fictional API

And json-server already sends Access-Control-Allow-Origin: *, so you will not be fighting CORS while you learn. Other options for practicing: https://jsonplaceholder.typicode.com (a simulated read-only public API), https://httpbin.org (returns your own request, ideal for inspecting headers) or https://httpstat.us/500 (returns whatever code you ask for, perfect for testing the next lesson's error handling).

Common Mistakes and Tips

  • Forgetting to check response.ok. The number-one error. A 404 arrives as a success and your application processes an error message as if it were data.
  • Forgetting the await on response.json(). You get a Promise and everything you do with it gives undefined.
  • Passing an object as body. It becomes '[object Object]'. Always JSON.stringify.
  • Setting Content-Type when sending FormData. It breaks the boundary and the server cannot read the body.
  • Calling .json() on a 204. There is no body; it throws. Check status === 204 or Content-Length.
  • Reading the body twice. TypeError: body stream already read. Use response.clone() before the first read.
  • Concatenating parameters by hand. A space, an & or an accent breaks the URL. Use URL and URLSearchParams.
  • Trying to fix CORS from the client. You cannot. Read the console, talk to whoever maintains the API, or set up a proxy.
  • Using mode: 'no-cors' to "bypass" CORS. It returns an opaque, unreadable response.
  • Putting an API key in the client's JavaScript. It is public from the moment it is downloaded.
  • Tip: always look at the Network tab in DevTools. Verb, status code, headers, body sent and received, and the preflight OPTIONS. Almost every network problem is diagnosed there in thirty seconds.
  • Tip: use await response.text() when json() fails. You will see whether the server returned an HTML error page instead of JSON.
  • Tip: encapsulate fetch in a single module. Never call it loose from the view. That way the ok check, the URL base and the headers live in one place, and in 07-03 you will be able to add timeouts and retries without touching anything else.
  • Tip: in the browser, copy(await (await fetch(url)).json()) in the console copies the response to the clipboard. Very useful for inspecting formats.

Exercises

Exercise 1 — The wrapper you cannot forget. Write fetchJson(url, options = {}) that: performs the fetch; if response.ok is false, reads the body as text and throws an Error whose message includes the status, the statusText and that text; if the status is 204 returns null; and otherwise returns await response.json(). Then rewrite listTasks and deleteTask using it, and check that the code comes out shorter.

Exercise 2 — Task search with parameters. Write searchTasks({ text, assignee, statuses, sortBy, page }) that builds the URL with URLSearchParams, omitting empty values. statuses is an array (['pending', 'in-progress']) and must generate ?status=pending&status=in-progress. Pagination uses _page and _limit, the json-server parameters. Return { tasks, total }, reading the total from the X-Total-Count header.

Exercise 3 — Syncing the local board with the server. Write sync(repository, api) that: loads the local board; asks the server for the list of tasks; and returns a report { onlyLocal, onlyServer, inBoth, matching } comparing by id, where matching is the number of tasks present on both sides with the same status. Do not modify anything yet: just report. Use Map and the array methods from 04-05.

Solutions

Solution 1

export async function fetchJson(url, options = {}) {
  const response = await fetch(url, options);

  if (!response.ok) {
    // The error body usually carries the useful detail; if it cannot be read, we carry on regardless
    const detail = await response.text().catch(() => '');
    throw new Error(`HTTP ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ''}`);
  }

  if (response.status === 204) return null;                  // no body

  const type = response.headers.get('content-type') ?? '';
  if (!type.includes('application/json')) {
    throw new Error(`Expected JSON and got "${type}"`);       // proxy, HTML sign-in page, CDN error…
  }
  return response.json();
}
export async function listTasks(filters = {}) {
  const plain = await fetchJson(buildUrl('/tasks', filters), { headers: { Accept: 'application/json' } });
  return plain.map((d) => Task.fromJSON(d));
}

export async function deleteTask(id) {
  await fetchJson(buildUrl(`/tasks/${id}`), { method: 'DELETE' });   // 204 → null, without breaking
  return true;
}

The content-type check is what prevents the worst kind of failure: a corporate proxy or a sign-in screen returns HTML with status 200, and without that line json() would throw an incomprehensible SyntaxError. This function is the seed of the complete fetchJson you will build in 07-03.

Solution 2

export async function searchTasks({ text = '', assignee = null, statuses = [],
                                    sortBy = 'dueDate', page = 1, perPage = 20 } = {}) {
  const url = new URL(`${BASE}/tasks`);

  if (text) url.searchParams.set('q', text);                       // json-server free-text search
  if (assignee) url.searchParams.set('assignee', assignee);
  for (const status of statuses) url.searchParams.append('status', status);   // append, not set
  url.searchParams.set('_sort', sortBy);
  url.searchParams.set('_page', page);
  url.searchParams.set('_limit', perPage);

  const response = await fetch(url, { headers: { Accept: 'application/json' } });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const plain = await response.json();
  return {
    tasks: plain.map((d) => Task.fromJSON(d)),
    total: Number(response.headers.get('X-Total-Count') ?? plain.length)
  };
}

The difference between set and append is the key: set replaces, so a loop with set would leave only the last status. And watch out for X-Total-Count: on an API from another origin, that header will only be readable if the server exposes it with Access-Control-Expose-Headers. With json-server on localhost there is no problem.

Solution 3

export async function sync(repository, api) {
  const local = repository.load();
  const localTasks = local === null ? [] : local.tasks;
  const remoteTasks = await api.listTasks();

  const localById  = new Map(localTasks.map((t) => [t.id, t]));
  const remoteById = new Map(remoteTasks.map((t) => [t.id, t]));

  const onlyLocal  = localTasks.filter((t) => !remoteById.has(t.id)).map((t) => t.id);
  const onlyServer = remoteTasks.filter((t) => !localById.has(t.id)).map((t) => t.id);
  const inBoth     = localTasks.filter((t) => remoteById.has(t.id)).map((t) => t.id);

  const matching = inBoth.filter((id) => localById.get(id).status === remoteById.get(id).status).length;

  return { onlyLocal, onlyServer, inBoth, matching, conflicts: inBoth.length - matching };
}
console.log(await sync(repository, api));
// { onlyLocal: [7], onlyServer: [], inBoth: [1,2,3,4,5,6], matching: 5, conflicts: 1 }

The two Maps turn the comparison into constant-cost operations instead of walking the remote array for every local task. And notice what the result reveals: there is a conflict. What to do about it —who wins when local and server disagree— is a product decision, not a technical one, and you will come back to it in 07-04 when we talk about simultaneous editing.

Conclusion

Nómada Tasks now knows how to talk to a server. You understand what AJAX means and why it changed the web: asking for data in the background and updating only what changes, preserving scroll, focus and filters, with a few hundred bytes of JSON instead of a complete HTML document. You know XMLHttpRequest well enough to recognize it, and you know that fetch replaces it in everything except upload progress, and that its two gaps —timeout and cancellation— are filled by AbortController.

You have the HTTP that gets used every day: the verbs with their meaning and their idempotence (GET, PUT and DELETE yes, POST no), the paths that name resources rather than actions, the five families of status codes with the decisive boundary between the 4xx the client fixes and the 5xx it does not, and the Content-Type, Accept and Authorization headers. You have mastered fetch and its double wait —the first promise brings the headers, the second the body—, the Response object with ok, status, headers and the five body methods, and the rule that the body is read only once unless you clone it. And you have engraved the trap that defines this API: fetch does not reject on 404 or on 500; it only rejects if the request never completed. That is why the response.ok check is not remembered, it is encapsulated.

You know how to send data with method, headers and bodyJSON.stringify compulsory, and Content-Type forbidden when the body is FormData—, how to build URLs with URL and URLSearchParams telling set apart from append, and how to explain CORS: the same-origin policy, the simple request versus the OPTIONS preflight that doubles the entries in the Network tab, the Access-Control-* headers that the server decides, and the conclusion that saves whole afternoons —it cannot be fixed from the client, and mode: 'no-cors' only gives you an opaque response. And you are clear about the security side: HTTPS always, tokens preferably in an HttpOnly cookie and not in localStorage, no secret key in client code, and no trust whatsoever in browser-side validation.

In code, the module js/data/tasks-api.js with listTasks, getTask, createTask, updateTask and deleteTask, which returns Task instances and not plain objects, exactly as LocalRepository did: the data/ layer is a boundary, and the rest of the application does not know whether the data comes from disk or from the network. Plus a json-server on localhost:3000 with the canonical backlog so you can practice against a real server, because api.tallernomada.example is and will remain fictional.

That said: that code works only when everything goes well. And on a network nothing goes well all the time. The workshop wifi drops in the middle of a POST; the API takes fifteen seconds and Marta presses the button three times; the server returns a 503 because a deployment is under way; Iván types in the search box and eight requests are fired, of which the second-to-last arrives first, leaving results on screen that do not match what he typed. None of those situations is covered by what you have written today: there is no timeout, no cancellation, no retries, no loading state and no way of telling the user what happened. That is exactly what separates a demo from an application, and it is the subject of Robust Requests: Errors, Timeouts and AbortController, where AbortController —introduced in passing in 06-04— will finally take its rightful place.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved