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
- What AJAX is and what it changed
XMLHttpRequest, the ancestor- HTTP in ten minutes: verbs, paths, status codes and headers
fetch: the minimal request- The
Responseobject and the body methods - The fundamental trap:
fetchdoes not reject on 404 or 500 - Sending data:
method,headersandbody - Sending
FormDataand files - Query parameters with
URLandURLSearchParams - CORS: why the browser blocks you
- Authentication:
Authorizationandcredentials - Nómada Tasks:
js/data/tasks-api.js - Real practice:
json-serveron your machine - Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
XMLHttpRequest, the ancestor
XMLHttpRequest, the ancestorBefore 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.
- 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/v1that we will use throughout this lesson is fictional. The.exampleTLD 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 Unauthorizedmeans "I do not know who you are" (the credential is missing or expired).403 Forbiddenmeans "I know who you are and you may not". The first one calls for signing in; the second one does not.204 No Contentis a success with no body. Calling.json()on it throws, because there is nothing to parse. You will bear that in mind indeleteTask().
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.
fetch: the minimal request
fetch: the minimal requestfetch(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
- The
Response object and the body methods
Response object and the body methodsResponse 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 readIf 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.
- The fundamental trap:
fetch does not reject on 404 or 500
fetch does not reject on 404 or 500This is the thing to know about fetch, and the one that has produced the most broken code:
The
fetchpromise 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.
- Sending data:
method, headers and body
method, headers and bodyThe 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:
bodyis never an object. If you pass it{ title: '…' }directly, the browser converts it withString()and sends'[object Object]', exactly the same disaster as inlocalStorage.JSON.stringifyis compulsory.JSON.stringifyuses thetoJSONof your instances, so you can pass it aTaskdirectly and it will travel complete, private fields included. 05-03 paying off once again.- The
idis 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. fetchdoes not throw if the server rejects the data. A422 Unprocessable Entitywith the list of invalid fields arrives as a normal response; you have to checkokand 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 |
- Sending
FormData and files
FormData and filesWhen 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 });
- Query parameters with
URL and URLSearchParams
URL and URLSearchParamsBuilding 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 URLThe 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() neededThe 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%A1nThat 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.
- 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:status0,okfalse 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.
- Authentication:
Authorization and credentials
Authorization and credentialsThere 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 |
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.
- Nómada Tasks:
js/data/tasks-api.js
js/data/tasks-api.jsYou 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
Taskinstances, not plain objects, exactly asLocalRepositorydid. The rest of the application does not know where the data comes from. That is the point of having adata/folder. checkis written only once. The trap from section 6 is neutralized by encapsulating it, not by remembering it.deleteTaskdoes not calljson(). A204 No Contenthas no body andjson()would throw.- There is no
try/catchhere. 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.)
- Real practice:
json-server on your machine
json-server on your machineapi.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 3000With 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/6You only have to change one line in your module:
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
awaitonresponse.json(). You get aPromiseand everything you do with it givesundefined. - Passing an object as
body. It becomes'[object Object]'. AlwaysJSON.stringify. - Setting
Content-Typewhen sendingFormData. It breaks theboundaryand the server cannot read the body. - Calling
.json()on a204. There is no body; it throws. Checkstatus === 204orContent-Length. - Reading the body twice.
TypeError: body stream already read. Useresponse.clone()before the first read. - Concatenating parameters by hand. A space, an
&or an accent breaks the URL. UseURLandURLSearchParams. - 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()whenjson()fails. You will see whether the server returned an HTML error page instead of JSON. - Tip: encapsulate
fetchin a single module. Never call it loose from the view. That way theokcheck, 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 body —JSON.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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
