La lección anterior resolvió media promesa: Nómada Tareas ya sobrevive a F5. Pero Marta, Iván y Lucía siguen teniendo cada uno su propio localStorage, con su propia versión del tablero, sin manera de verse. La otra mitad —que el tablero sea el mismo para todo el equipo— exige que los datos vivan en un servidor y que el navegador sepa hablar con él sin recargar la página. Eso es AJAX, y la herramienta moderna para hacerlo es fetch. En esta lección aprenderás qué cambió AJAX en la web, repasarás el HTTP que necesitas de verdad (verbos, códigos y cabeceras), dominarás fetch y el objeto Response con su trampa más famosa —un 404 no rechaza la promesa—, enviarás datos en JSON y en FormData, construirás URLs con parámetros, entenderás CORS lo suficiente para no perder una tarde, y escribirás js/datos/api-tareas.js, el hermano de red del repositorio local. Y por fin retirarás aquellas leerBacklogSimulado() y guardarInformeSimulado() de 05-06 que fingían latencia con setTimeout.

Contenido

  1. Qué es AJAX y qué cambió
  2. XMLHttpRequest, el antepasado
  3. HTTP en diez minutos: verbos, rutas, códigos y cabeceras
  4. fetch: la petición mínima
  5. El objeto Response y los métodos de cuerpo
  6. La trampa fundamental: fetch no rechaza con 404 ni 500
  7. Enviar datos: method, headers y body
  8. Enviar FormData y archivos
  9. Parámetros de consulta con URL y URLSearchParams
  10. CORS: por qué el navegador te bloquea
  11. Autenticación: Authorization y credentials
  12. Nómada Tareas: js/datos/api-tareas.js
  13. Practicar de verdad: json-server en tu máquina
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. Qué es AJAX y qué cambió

AJAX son las siglas de Asynchronous JavaScript And XML, un nombre acuñado en 2005 que hoy es medio mentira: casi nadie usa XML —se usa JSON— y la técnica se aplica a mucho más que XML. Lo que la sigla nombra sigue vigente: pedir datos al servidor desde JavaScript, sin recargar la página, y actualizar solo la parte del DOM que cambia.

Compara los dos modelos:

Web clásica (sin AJAX) Web con AJAX
Al pulsar "marcar como hecha" El navegador envía un formulario y recarga la página entera JavaScript envía una petición en segundo plano
Lo que viaja de vuelta Un documento HTML completo Unos cientos de bytes de JSON
Estado de la interfaz Se pierde: scroll, foco, filtros Se conserva
Percepción del usuario Parpadeo blanco, espera La tarjeta cambia y ya
Trabajo del servidor Renderizar toda la página Devolver el dato

Lo que AJAX hace posible, en términos de Nómada Tareas: Marta pulsa el botón de avanzar estado, la tarjeta se mueve de columna, y ni el scroll ni el filtro por responsable ni el foco se pierden. El ciclo estado → render → evento → nuevo estado → render que montaste en 06-06 sigue mandando; lo único que cambia es que ahora una parte del "nuevo estado" llega del servidor.

Y hay algo que no cambia y conviene decir pronto: el servidor sigue mandando. Una petición AJAX no es más segura que un formulario; el usuario puede fabricar la que quiera desde la consola. Toda validación del cliente es una cortesía; la de verdad está en el servidor.

  1. XMLHttpRequest, el antepasado

Antes de fetch había XMLHttpRequest (XHR), disponible desde principios de los 2000. Lo verás en código antiguo y merece la pena reconocerlo:

// Estilo XHR: eventos y estados numéricos, sin promesas
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.tallernomada.example/v1/tareas');
xhr.onload = function () {
  if (xhr.status >= 200 && xhr.status < 300) {
    const tareas = JSON.parse(xhr.responseText);   // el parseo, a mano
    console.log(tareas.length);
  } else {
    console.error('Error HTTP', xhr.status);
  }
};
xhr.onerror = function () { console.error('Fallo de red'); };
xhr.send();

Es el estilo de callbacks de 05-05 llevado al extremo: nada de promesas, nada de async/await, y el famoso readyState con sus cinco valores numéricos. La comparación:

XMLHttpRequest fetch
Modelo Eventos y callbacks Promesas (05-06)
Sintaxis open + send + onload Una llamada que devuelve una promesa
Parseo de JSON Manual (JSON.parse(responseText)) await respuesta.json()
Errores HTTP (404, 500) Miras xhr.status Tampoco rechaza: miras respuesta.ok
Cancelación xhr.abort() AbortController (07-03)
Progreso de subida Sí, evento progress No (solo de descarga, con streams)
Streaming de la respuesta Limitado Sí, respuesta.body es un ReadableStream
Timeout integrado Sí, xhr.timeout No, hay que montarlo (07-03)

Hoy se usa fetch salvo en dos casos: cuando necesitas una barra de progreso de subida de un archivo grande, o cuando mantienes código antiguo. Los dos huecos de fetch —timeout y cancelación— se cubren con AbortController, y esa es la lección siguiente.

  1. HTTP en diez minutos: verbos, rutas, códigos y cabeceras

Una petición HTTP tiene cuatro partes: un verbo, una ruta, unas cabeceras y a veces un cuerpo. La respuesta tiene un código de estado, sus propias cabeceras y su cuerpo.

Los verbos

Verbo Qué significa ¿Lleva cuerpo? ¿Idempotente? En Nómada Tareas
GET Leer un recurso No Listar tareas, leer una tarea
POST Crear un recurso nuevo No Crear una tarea
PUT Reemplazar un recurso entero Guardar una tarea con todos sus campos
PATCH Modificar parte de un recurso Depende Cambiar solo el estado
DELETE Borrar un recurso No suele Eliminar una tarea

Idempotente significa que repetir la misma petición deja el sistema igual que hacerla una vez. PUT de la tarea 6 con los mismos datos, mil veces, deja una tarea 6 con esos datos. POST mil veces crea mil tareas. Esa distinción parece teórica hasta que en 07-03 decidas qué peticiones se pueden reintentar sin miedo.

Las rutas de recurso

Una API REST nombra cosas, no acciones, y deja que el verbo diga qué hacer con ellas:

GET    /v1/tareas               → la colección entera
GET    /v1/tareas?estado=hecha  → la colección filtrada
GET    /v1/tareas/6             → un elemento concreto
POST   /v1/tareas               → crear uno nuevo (el id lo asigna el servidor)
PUT    /v1/tareas/6             → reemplazar el 6 entero
PATCH  /v1/tareas/6             → cambiar parte del 6
DELETE /v1/tareas/6             → borrar el 6

Un antipatrón habitual es POST /v1/crearTarea o GET /v1/borrarTarea?id=6. El segundo es especialmente malo: un GET no debe cambiar nada, y cualquier buscador o precargador del navegador podría dispararlo.

El dominio https://api.tallernomada.example/v1 que usaremos en toda la lección es ficticio. El TLD .example está reservado por la IANA precisamente para documentación y jamás resolverá. En el apartado 13 montarás un servidor real en tu máquina para practicar.

Los códigos de estado

Se agrupan en familias, y con saber las familias vas sobrado:

Familia Significado Los que verás
1xx Informativo 101 Switching Protocols (lo verás en WebSockets, 07-04)
2xx Éxito 200 OK, 201 Created (tras un POST), 204 No Content (tras un DELETE)
3xx Redirección 301 permanente, 304 Not Modified (caché)
4xx Error del cliente: la petición está mal 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity, 429 Too Many Requests
5xx Error del servidor: la petición estaba bien, el servidor falló 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout

La frontera entre 4xx y 5xx es la que gobierna tu manejo de errores: un 4xx lo arregla el cliente (corrige los datos, inicia sesión, deja de insistir); un 5xx no lo arregla el cliente (reintenta más tarde y avisa). Dos parejas que se confunden a menudo:

  • 401 Unauthorized significa "no sé quién eres" (falta o caducó la credencial). 403 Forbidden significa "sé quién eres y no puedes". El primero pide iniciar sesión; el segundo, no.
  • 204 No Content es un éxito sin cuerpo. Llamar a .json() sobre él lanza, porque no hay nada que parsear. Lo tendrás en cuenta en borrarTarea().

Las cabeceras

Cabecera Quién la pone Para qué
Content-Type Quien envía cuerpo Qué formato lleva el cuerpo: application/json, multipart/form-data
Accept El cliente Qué formatos entiende de vuelta: application/json
Authorization El cliente La credencial: Bearer <token>
Cache-Control Ambos Cómo puede cachearse
Content-Length Quien envía cuerpo Tamaño en bytes (lo pone el navegador)
ETag / If-None-Match Servidor / cliente Versión del recurso, para ahorrar transferencia

El error de novato más frecuente con cabeceras: enviar un body con JSON sin Content-Type: application/json. Muchos servidores lo interpretan como texto plano y devuelven un 400 desconcertante.

  1. fetch: la petición mínima

fetch(url, opciones) devuelve una promesa que se resuelve con un objeto Response en cuanto llegan las cabeceras de la respuesta. Con lo que sabes de 05-06, se lee sin esfuerzo:

// Con async/await, que es como lo escribirás siempre
async function listar() {
  const respuesta = await fetch('https://api.tallernomada.example/v1/tareas');
  const tareas = await respuesta.json();
  console.log(tareas.length);
}

Dos await, y cada uno espera algo distinto. Esto es lo que más cuesta al principio:

sequenceDiagram
    participant JS as Tu código
    participant N as Navegador
    participant S as API

    JS->>N: fetch(url)
    N->>S: GET /v1/tareas
    Note over N,S: Latencia de red
    S-->>N: 200 OK + cabeceras
    N-->>JS: ✅ promesa resuelta con Response
    Note over JS: El CUERPO aún no ha llegado
    JS->>N: respuesta.json()
    N-->>JS: (lee el cuerpo, lo parsea)
    N-->>JS: ✅ segunda promesa resuelta con los datos

La primera promesa se resuelve con las cabeceras; el cuerpo puede seguir llegando. Por eso respuesta.json() es otra operación asíncrona y necesita su propio await. Olvidarlo produce el error más común de todos:

const respuesta = await fetch(url);
const datos = respuesta.json();          // ✗ falta el await
console.log(datos.length);               // undefined  ← es una Promise, no un array

  1. El objeto Response y los métodos de cuerpo

Response describe la respuesta completa:

Miembro Tipo Qué contiene
ok boolean true si el estado está entre 200 y 299
status number El código: 200, 404, 500
statusText string El texto: 'OK', 'Not Found'
headers Headers Las cabeceras, con get, has, entries
url string La URL final (tras redirecciones)
redirected boolean Si hubo redirección
type string 'basic', 'cors', 'opaque'
bodyUsed boolean Si el cuerpo ya se consumió

Y los métodos de cuerpo, todos asíncronos porque todos devuelven promesas:

Método Devuelve Cuándo
json() El valor parseado Respuestas JSON. Lanza si el cuerpo no es JSON válido
text() string HTML, CSV, texto, o para depurar qué llegó de verdad
blob() Blob Imágenes, PDF, cualquier binario
arrayBuffer() ArrayBuffer Binario a bajo nivel (lo usarás en 07-07 con Wasm)
formData() FormData Respuestas multipart o urlencoded
const respuesta = await fetch('https://api.tallernomada.example/v1/tareas');

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

for (const [nombre, valor] of respuesta.headers) {
  console.log(nombre, '=', valor);
}

Los nombres de cabecera no distinguen mayúsculas: headers.get('Content-Type') y headers.get('content-type') son lo mismo.

Una regla que sorprende: el cuerpo solo se puede leer una vez. Es un flujo, y una vez consumido, se acabó.

const respuesta = await fetch(url);
const texto = await respuesta.text();
const datos = await respuesta.json();   // ✗ TypeError: body stream already read

Si necesitas leerlo dos veces —por ejemplo, intentar json() y, si falla, mirar el text() para depurar—, clona antes:

const respuesta = await fetch(url);
const copia = respuesta.clone();        // ← clona ANTES de leer

try {
  return await respuesta.json();
} catch {
  console.error('La respuesta no era JSON. Contenido real:', await copia.text());
  throw new Error('Respuesta no interpretable');
}

Ese patrón te salvará el día que un proxy devuelva una página HTML de error donde esperabas JSON.

  1. La trampa fundamental: fetch no rechaza con 404 ni 500

Esta es la cosa que hay que saber de fetch, y la que más código roto ha producido:

La promesa de fetch solo se rechaza si la petición no llegó a completarse: red caída, DNS que no resuelve, CORS bloqueado, petición cancelada. Si el servidor responde —aunque responda 404, 403 o 500— la promesa se resuelve con normalidad.

// ✗ Roto: parece correcto y no lo es
async function listarRoto() {
  try {
    const respuesta = await fetch('https://api.tallernomada.example/v1/tareaz');   // ruta mal escrita
    const datos = await respuesta.json();     // el servidor devolvió 404 con un JSON de error
    return datos;                             // devuelve { error: 'No encontrado' } como si fueran tareas
  } catch (error) {
    console.error('Nunca llego aquí por un 404');
  }
}

El catch no se ejecuta. respuesta.json() parsea el cuerpo del error sin quejarse, y tu aplicación sigue adelante con basura. La corrección es siempre comprobar ok:

// ✓ Correcto
async function listar() {
  const respuesta = await fetch('https://api.tallernomada.example/v1/tareas');

  if (!respuesta.ok) {                                  // ← la línea que no puede faltar
    throw new Error(`HTTP ${respuesta.status} ${respuesta.statusText}`);
  }
  return respuesta.json();
}

La tabla que explica el porqué del diseño:

Situación ¿La promesa de fetch…? Cómo lo detectas
200 OK se resuelve respuesta.ok === true
404 Not Found se resuelve respuesta.ok === false, status 404
500 Internal Server Error se resuelve respuesta.ok === false, status 500
Sin conexión / DNS falla se rechaza TypeError: Failed to fetch
Bloqueado por CORS se rechaza TypeError, con detalle solo en la consola
Petición cancelada se rechaza AbortError (07-03)

La lógica del diseño es defendible: fetch promete hacer la petición HTTP, y un 404 es una petición HTTP realizada con éxito cuya respuesta es "no existe". Que sea defendible no quita que sea la primera causa de bugs de red. En el apartado 12 encapsularemos la comprobación una sola vez para no volver a olvidarla.

Fíjate además en el detalle de la última fila del diagnóstico: cuando CORS bloquea una petición, JavaScript recibe un TypeError genérico sin detalles. El motivo real solo aparece en la consola del navegador. Es intencionado —dar detalles sería filtrar información entre orígenes— y significa que la consola es tu única fuente de verdad ante un fallo de CORS.

  1. Enviar datos: method, headers y body

El segundo parámetro de fetch es un objeto de opciones. Para crear una tarea:

const nueva = {
  titulo: 'Revisar la prensa de serigrafía',
  responsable: 'Iván',
  prioridad: 'alta',
  etiquetas: ['serigrafía', 'mantenimiento'],
  horasEstimadas: 4,
  fechaLimite: '2026-10-10'
};

const respuesta = await fetch('https://api.tallernomada.example/v1/tareas', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',      // qué envío
    'Accept': 'application/json'             // qué espero recibir
  },
  body: JSON.stringify(nueva)                // ← el cuerpo SIEMPRE es texto o binario
});

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

const creada = await respuesta.json();
console.log(creada.id);                      // 7  ← el id lo asigna el servidor (R1)

Cuatro puntos:

  • body nunca es un objeto. Si le pasas { titulo: '…' } directamente, el navegador lo convierte con String() y envía '[object Object]', exactamente el mismo desastre que en localStorage. JSON.stringify es obligatorio.
  • JSON.stringify usa el toJSON de tus instancias, así que puedes pasarle una Tarea directamente y viajará completa, campos privados incluidos. Otra vez 05-03 rindiendo.
  • El id lo asigna el servidor, no el cliente. Es la regla R1 del proyecto, y en un sistema con varios usuarios es la única forma de no colisionar.
  • fetch no lanza si el servidor rechaza los datos. Un 422 Unprocessable Entity con la lista de campos inválidos llega como respuesta normal; hay que comprobar ok y leer el cuerpo del error.

Las opciones que más usarás:

Opción Valores Para qué
method 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' El verbo (por defecto 'GET')
headers Objeto o Headers Las cabeceras
body string, FormData, Blob, URLSearchParams El cuerpo
credentials 'omit', 'same-origin', 'include' Si van cookies
mode 'cors', 'same-origin', 'no-cors' Política de origen cruzado
cache 'default', 'no-store', 'reload' Uso de la caché HTTP
signal AbortSignal Cancelación (07-03)
redirect 'follow', 'error', 'manual' Qué hacer con los 3xx

  1. Enviar FormData y archivos

Cuando hay un archivo de por medio —Iván quiere adjuntar la foto del boceto—, JSON no sirve: es texto. Se usa FormData, que ya conoces de 06-07:

const formulario = document.querySelector('#nueva-tarea');
const datos = new FormData(formulario);      // toma todos los campos con name
datos.append('adjunto', archivoInput.files[0]);
datos.append('origen', 'web');

const respuesta = await fetch('https://api.tallernomada.example/v1/tareas', {
  method: 'POST',
  body: datos                                // ← ¡SIN Content-Type!
});

No pongas Content-Type cuando envías FormData. El navegador lo genera solo, y añade el boundary —un separador aleatorio— que el servidor necesita para trocear el cuerpo. Si lo escribes tú, el boundary falta y el servidor no puede leer nada. Es un fallo desconcertante, porque el código "parece más completo" con la cabecera puesta.

Comparación rápida de los tres formatos de cuerpo:

Formato Content-Type ¿Lo pones tú? Cuándo
JSON application/json Lo normal en una API
FormData multipart/form-data; boundary=… No Archivos, o formularios tal cual
URLSearchParams application/x-www-form-urlencoded No APIs antiguas, formularios simples
// URLSearchParams como cuerpo: el navegador pone el Content-Type correcto
const cuerpo = new URLSearchParams({ estado: 'hecha', revisor: 'Marta' });
await fetch('https://api.tallernomada.example/v1/tareas/6', { method: 'PATCH', body: cuerpo });

  1. Parámetros de consulta con URL y URLSearchParams

Construir la cadena de consulta a mano es una fuente inagotable de bugs de codificación:

// ✗ Frágil: ¿y si el texto tiene un espacio, un & o una ñ?
const url = `https://api.tallernomada.example/v1/tareas?responsable=${responsable}&texto=${texto}`;
// Con responsable = 'Lucía' y texto = 'prensa & rodillo' → URL rota

Las clases URL y URLSearchParams codifican por ti:

const url = new URL('https://api.tallernomada.example/v1/tareas');
url.searchParams.set('responsable', 'Lucía');
url.searchParams.set('estado', 'pendiente');
url.searchParams.set('texto', 'prensa & rodillo');
url.searchParams.set('limite', 20);

console.log(url.toString());
// https://api.tallernomada.example/v1/tareas?responsable=Luc%C3%ADa&estado=pendiente&texto=prensa+%26+rodillo&limite=20

const respuesta = await fetch(url);          // fetch acepta un objeto URL, sin toString()

Los miembros útiles de URLSearchParams:

Método Qué hace
set(clave, valor) Pone el valor, reemplazando los que hubiera
append(clave, valor) Añade otro valor con la misma clave (?etiqueta=a&etiqueta=b)
get(clave) / getAll(clave) Lee el primero / todos
has(clave) Si existe
delete(clave) Lo quita
toString() La cadena codificada, sin la ?

Un ayudante que usarás en el proyecto, saltándose los filtros vacíos:

/** Construye una URL de la API con solo los parámetros que tienen valor. */
function urlDeApi(ruta, parametros = {}) {
  const url = new URL(ruta, BASE);
  for (const [clave, valor] of Object.entries(parametros)) {
    if (valor === undefined || valor === null || valor === '') continue;   // filtro vacío = sin filtro
    url.searchParams.set(clave, valor);
  }
  return url;
}

urlDeApi('/v1/tareas', { responsable: 'Iván', estado: '', texto: null });
// https://api.tallernomada.example/v1/tareas?responsable=Iv%C3%A1n

Ese new URL(ruta, BASE) con dos argumentos resuelve rutas relativas contra una base, igual que lo hace un <a href>. Muy cómodo para no concatenar barras.

  1. CORS: por qué el navegador te bloquea

Antes o después escribirás una petición perfecta y la consola dirá algo como:

Access to fetch at 'https://api.tallernomada.example/v1/tareas' 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) es el mecanismo que decide si una página de un origen puede leer respuestas de otro origen. Por defecto, el navegador aplica la same-origin policy: puedes enviar peticiones a otros orígenes, pero no leer sus respuestas salvo que ese servidor lo autorice.

¿Por qué existe? Porque el navegador envía automáticamente las cookies del usuario. Sin esta política, cualquier página maliciosa que visitaras podría pedir https://tubanco.example/api/saldo con tus cookies y leer la respuesta.

Hay dos tipos de petición cruzada, y la diferencia importa mucho:

Petición simple: el navegador la envía directamente y luego decide si te deja leer la respuesta. Se considera simple si usa GET, HEAD o POST, y sus cabeceras son las corrientes, con un Content-Type limitado a text/plain, multipart/form-data o application/x-www-form-urlencoded.

sequenceDiagram
    participant P as Página<br/>localhost:3000
    participant N as Navegador
    participant S as api.tallernomada.example

    P->>N: fetch('https://api…/v1/tareas')
    N->>S: GET /v1/tareas<br/>Origin: http://localhost:3000
    S-->>N: 200 OK<br/>Access-Control-Allow-Origin: http://localhost:3000
    N->>N: ¿La cabecera autoriza mi origen?
    N-->>P: ✅ Respuesta entregada

Petición con preflight: cualquier otra cosa —PUT, PATCH, DELETE, o un POST con Content-Type: application/json, o una cabecera Authorization— obliga al navegador a preguntar antes con una petición OPTIONS.

sequenceDiagram
    participant P as Página<br/>localhost:3000
    participant N as Navegador
    participant S as api.tallernomada.example

    P->>N: fetch(url, { method: 'PATCH', headers: {…} })
    Note over N: PATCH + Content-Type JSON<br/>⇒ hace falta preflight
    N->>S: OPTIONS /v1/tareas/6<br/>Origin: http://localhost:3000<br/>Access-Control-Request-Method: PATCH<br/>Access-Control-Request-Headers: content-type
    S-->>N: 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
    N->>N: Autorizado ✅
    N->>S: PATCH /v1/tareas/6 (la petición real)
    S-->>N: 200 OK + Access-Control-Allow-Origin
    N-->>P: ✅ Respuesta entregada

Ese OPTIONS extra explica dos cosas que desconciertan: por qué en la pestaña Network aparecen dos entradas por una sola llamada, y por qué a veces el fallo ocurre "antes" de que el servidor vea tu petición real.

Las cabeceras que controla el servidor:

Cabecera de respuesta Qué autoriza
Access-Control-Allow-Origin Qué origen puede leer (https://app.taller.example o *)
Access-Control-Allow-Methods Qué verbos se permiten
Access-Control-Allow-Headers Qué cabeceras puede enviar el cliente
Access-Control-Allow-Credentials Si se pueden enviar cookies (true)
Access-Control-Expose-Headers Qué cabeceras de respuesta puede leer tu JavaScript
Access-Control-Max-Age Cuántos segundos cachear el preflight

Y la conclusión que ahorra tardes enteras:

CORS no se arregla desde el cliente. Ni con mode, ni con cabeceras, ni con trucos. Lo aplica el navegador según lo que responde el servidor. Solo hay tres salidas legítimas: que el servidor añada las cabeceras, poner un proxy en tu mismo origen que reenvíe las peticiones, o servir API y web bajo el mismo origen.

Dos matices que se malinterpretan:

  • mode: 'no-cors' no desactiva CORS. Te devuelve una respuesta opaca: status 0, ok false y cuerpo ilegible. Sirve para casos muy concretos (precachear en un service worker), no para saltarse nada.
  • Las extensiones tipo "Allow CORS" del navegador solo desactivan la comprobación en tu máquina. Tu código seguirá roto para todos los demás. Úsalas, como mucho, para diagnosticar.
  • Access-Control-Allow-Origin: * es incompatible con credenciales. Si envías cookies, el servidor debe nombrar tu origen exacto.

  1. Autenticación: Authorization y credentials

Hay dos formas de que la API sepa quién eres.

Token en la cabecera Authorization, el patrón habitual de las APIs:

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

Cookies, que el navegador gestiona solo. Con fetch hay que pedirlas explícitamente cuando la API está en otro origen:

credentials Comportamiento
'same-origin' Por defecto: cookies solo si la URL es del mismo origen
'include' Cookies siempre, también entre orígenes (requiere Access-Control-Allow-Credentials: true)
'omit' Nunca envía cookies
await fetch('https://api.tallernomada.example/v1/tareas', { credentials: 'include' });

¿Cuál elegir? Retomando la advertencia de 07-01:

Token en localStorage Cookie HttpOnly
¿Lo lee un XSS? , entero No, JavaScript no la ve
¿Se envía solo? No, lo pones tú en cada petición Sí, el navegador
Riesgo de CSRF Bajo Existe; se mitiga con SameSite y tokens anti-CSRF
Caducidad La gestionas tú El servidor, con Max-Age

La recomendación no ha cambiado: para una sesión de usuario real, cookie HttpOnly; Secure; SameSite=Lax, emitida y validada por el servidor. Si tu API impone un token en localStorage —cosa habitual—, asume que un XSS es un robo de cuenta y trata la prevención de XSS (06-02: textContent en lugar de innerHTML) como parte de la seguridad de la autenticación.

Tres reglas más, cortas y no negociables:

  • HTTPS siempre. Sobre http://, cabeceras y cuerpos viajan legibles para cualquiera en la red. Un token enviado por HTTP es un token público.
  • Nunca metas claves de API en el código del cliente. Todo lo que llega al navegador es visible. Si una clave debe permanecer secreta, la petición la hace tu servidor, no la página.
  • Ante un 401, limpia la sesión local y manda a iniciar sesión. Reintentar con un token caducado solo genera ruido.

  1. Nómada Tareas: js/datos/api-tareas.js

Ya puedes escribir el hermano de red de RepositorioLocal. La misma responsabilidad —traducir entre el mundo exterior y el modelo— con otro medio.

// js/datos/api-tareas.js
import { Tarea } from '../modelo/tarea.js';

/**
 * API ficticia de ejemplo. El TLD .example está reservado y NUNCA resuelve:
 * para practicar de verdad, arranca el json-server del apartado 13 y cambia esta
 * constante por 'http://localhost:3000'.
 */
const BASE = 'https://api.tallernomada.example/v1';

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

/** Une la base con la ruta y añade solo los parámetros con valor. */
function construirUrl(ruta, parametros = {}) {
  const url = new URL(BASE + ruta);
  for (const [clave, valor] of Object.entries(parametros)) {
    if (valor === undefined || valor === null || valor === '') continue;
    url.searchParams.set(clave, valor);
  }
  return url;
}

/**
 * Comprobación única de `ok` para toda la aplicación: escrita una vez, imposible de olvidar.
 * En 07-03 esta función crecerá hasta convertirse en `pedirJson`, con su ErrorDeApi.
 */
async function comprobar(respuesta) {
  if (respuesta.ok) return respuesta;
  const detalle = await respuesta.text().catch(() => '');
  throw new Error(`HTTP ${respuesta.status} ${respuesta.statusText}${detalle ? ` — ${detalle}` : ''}`);
}

/** GET /v1/tareas → array de instancias Tarea (no de objetos planos). */
export async function listarTareas({ responsable, estado, texto } = {}) {
  const respuesta = await fetch(construirUrl('/tareas', { responsable, estado, texto }), {
    headers: { 'Accept': 'application/json' }
  });
  await comprobar(respuesta);
  const planas = await respuesta.json();
  return planas.map((datos) => Tarea.desdeJSON(datos));      // ← misma frontera que en 07-01
}

/** GET /v1/tareas/:id */
export async function obtenerTarea(id) {
  const respuesta = await fetch(construirUrl(`/tareas/${id}`), {
    headers: { 'Accept': 'application/json' }
  });
  await comprobar(respuesta);
  return Tarea.desdeJSON(await respuesta.json());
}

/** POST /v1/tareas → 201 Created con la tarea ya provista de id (R1: lo asigna el servidor). */
export async function crearTarea(datos) {
  const respuesta = await fetch(construirUrl('/tareas'), {
    method: 'POST',
    headers: CABECERAS_JSON,
    body: JSON.stringify(datos)              // si `datos` es una Tarea, su toJSON() la serializa entera
  });
  await comprobar(respuesta);
  return Tarea.desdeJSON(await respuesta.json());
}

/** PATCH /v1/tareas/:id → cambios parciales; PUT reemplazaría la tarea entera. */
export async function actualizarTarea(id, cambios) {
  const respuesta = await fetch(construirUrl(`/tareas/${id}`), {
    method: 'PATCH',
    headers: CABECERAS_JSON,
    body: JSON.stringify(cambios)
  });
  await comprobar(respuesta);
  return Tarea.desdeJSON(await respuesta.json());
}

/** DELETE /v1/tareas/:id → normalmente 204 No Content, SIN cuerpo que parsear. */
export async function borrarTarea(id) {
  const respuesta = await fetch(construirUrl(`/tareas/${id}`), { method: 'DELETE' });
  await comprobar(respuesta);
  return true;                               // ← nada de respuesta.json(): un 204 no tiene cuerpo
}

Cuatro decisiones a subrayar:

  • La frontera del modelo se respeta. El módulo devuelve instancias de Tarea, no objetos planos, exactamente como hacía RepositorioLocal. El resto de la aplicación no sabe de dónde vienen los datos. Ese es el sentido de tener una carpeta datos/.
  • comprobar está escrita una sola vez. La trampa del apartado 6 se neutraliza encapsulándola, no recordándola.
  • borrarTarea no llama a json(). Un 204 No Content no tiene cuerpo y json() lanzaría.
  • No hay try/catch aquí. Este módulo traduce y propaga; quien decide qué mostrar al usuario es la vista. Cómo clasificar y presentar esos errores es justo el tema de 07-03.

Y así se sustituyen por fin las funciones simuladas de 05-06:

// js/app.js — antes
// const backlog = await leerBacklogSimulado();     // setTimeout fingiendo latencia

// js/app.js — ahora
import { listarTareas } from './datos/api-tareas.js';
import { RepositorioLocal } from './datos/repositorio-local.js';

const repositorio = new RepositorioLocal();

async function arrancar() {
  // 1 · Pintar de inmediato lo que haya en local: la interfaz no espera a la red
  const local = repositorio.cargar();
  if (local !== null) { vista.actualizar({ tablero: local }); }

  // 2 · Traer la verdad del servidor y refrescar
  const tareas = await listarTareas();
  const tablero = new Tablero('Taller Nómada', tareas);
  vista.actualizar({ tablero });
  repositorio.guardar(tablero);              // ← la copia local queda al día
}

arrancar();

Ahí está el patrón que hace que una aplicación se sienta rápida: local primero, red después. El almacenamiento de 07-01 y la red de esta lección no compiten; se complementan. (Ese await desnudo, sin try/catch ni estado de carga, todavía está a medias: lo completarás en la lección siguiente.)

  1. Practicar de verdad: json-server en tu máquina

api.tallernomada.example no existe. Para practicar necesitas un servidor real, y el más rápido de montar es json-server, que convierte un fichero JSON en una API REST completa.

# 1 · Crea una carpeta para el servidor de pruebas
mkdir nomada-api && cd nomada-api

# 2 · Arráncalo directamente con npx (no hace falta instalar nada permanente)
npx json-server --watch db.json --port 3000

Con este db.json, que es el backlog canónico del proyecto:

{
  "tareas": [
    { "id": 1, "titulo": "Rediseñar la sala polivalente", "responsable": "Iván",
      "prioridad": "alta", "estado": "en-curso", "etiquetas": ["diseño", "espacio"],
      "horasEstimadas": 12, "fechaLimite": "2026-09-30", "revisor": "Marta" },
    { "id": 2, "titulo": "Migrar la web del taller", "responsable": "Lucía",
      "prioridad": "media", "estado": "pendiente", "etiquetas": ["web"],
      "horasEstimadas": 8, "fechaLimite": "2026-10-15", "revisor": "Iván" },
    { "id": 3, "titulo": "Catálogo de encuadernación", "responsable": "Iván",
      "prioridad": "media", "estado": "pendiente", "etiquetas": ["diseño", "encuadernación"],
      "horasEstimadas": 8, "fechaLimite": "2026-10-05", "revisor": "Marta" },
    { "id": 4, "titulo": "Inventario del almacén de serigrafía", "responsable": "Lucía",
      "prioridad": "baja", "estado": "hecha", "etiquetas": ["taller", "inventario"],
      "horasEstimadas": 3, "fechaLimite": "2026-09-12", "revisor": "Marta" },
    { "id": 5, "titulo": "Preparar la jornada de puertas abiertas", "responsable": "Marta",
      "prioridad": "alta", "estado": "en-curso", "etiquetas": ["evento"],
      "horasEstimadas": 6, "fechaLimite": "2026-11-20", "revisor": "Lucía" },
    { "id": 6, "titulo": "Presupuesto de la carpintería", "responsable": "Iván",
      "prioridad": "alta", "estado": "pendiente", "etiquetas": ["carpintería", "compras"],
      "horasEstimadas": 5, "fechaLimite": "2026-09-05", "revisor": "Marta" }
  ]
}

Ese fichero da inmediatamente todas las rutas que necesitas:

curl http://localhost:3000/tareas
curl http://localhost:3000/tareas/6
curl "http://localhost:3000/tareas?responsable=Iván&estado=pendiente"
curl -X POST http://localhost:3000/tareas \
     -H "Content-Type: application/json" \
     -d '{"titulo":"Revisar la prensa","responsable":"Iván","horasEstimadas":4}'
curl -X PATCH http://localhost:3000/tareas/6 \
     -H "Content-Type: application/json" -d '{"estado":"en-curso"}'
curl -X DELETE http://localhost:3000/tareas/6

Solo tienes que cambiar una línea en tu módulo:

const BASE = 'http://localhost:3000';        // ← en lugar de la API ficticia

Y json-server ya envía Access-Control-Allow-Origin: *, así que no pelearás con CORS mientras aprendes. Otras opciones para practicar: https://jsonplaceholder.typicode.com (API pública de solo lectura simulada), https://httpbin.org (devuelve tu propia petición, ideal para inspeccionar cabeceras) o https://httpstat.us/500 (devuelve el código que le pidas, perfecto para probar el manejo de errores de la lección siguiente).

Errores Comunes y Consejos

  • Olvidar comprobar respuesta.ok. El error número uno. Un 404 llega como éxito y tu aplicación procesa un mensaje de error como si fueran datos.
  • Olvidar el await de respuesta.json(). Obtienes una Promise y todo lo que hagas con ella da undefined.
  • Pasar un objeto como body. Se convierte en '[object Object]'. JSON.stringify siempre.
  • Poner Content-Type al enviar FormData. Rompe el boundary y el servidor no puede leer el cuerpo.
  • Llamar a .json() sobre un 204. No hay cuerpo; lanza. Comprueba status === 204 o Content-Length.
  • Leer el cuerpo dos veces. TypeError: body stream already read. Usa respuesta.clone() antes de la primera lectura.
  • Concatenar parámetros a mano. Un espacio, un & o una tilde rompen la URL. Usa URL y URLSearchParams.
  • Intentar arreglar CORS desde el cliente. No se puede. Lee la consola, habla con quien mantiene la API o monta un proxy.
  • Usar mode: 'no-cors' para "saltarse" CORS. Devuelve una respuesta opaca e ilegible.
  • Meter una clave de API en el JavaScript del cliente. Es pública desde el momento en que se descarga.
  • Consejo: mira siempre la pestaña Network de las DevTools. Verbo, código, cabeceras, cuerpo enviado y recibido, y el OPTIONS del preflight. Casi todos los problemas de red se diagnostican ahí en treinta segundos.
  • Consejo: usa await respuesta.text() cuando json() falle. Verás si el servidor devolvió una página HTML de error en lugar de JSON.
  • Consejo: encapsula fetch en un único módulo. Nunca lo llames suelto desde la vista. Así la comprobación de ok, la base de la URL y las cabeceras viven en un solo sitio, y en 07-03 podrás añadir timeouts y reintentos sin tocar el resto.
  • Consejo: en el navegador, copy(await (await fetch(url)).json()) en la consola copia la respuesta al portapapeles. Muy útil para inspeccionar formatos.

Ejercicios

Ejercicio 1 — El envoltorio que no se puede olvidar. Escribe pedirJson(url, opciones = {}) que: haga el fetch; si respuesta.ok es falso, lea el cuerpo como texto y lance un Error cuyo mensaje incluya el status, el statusText y ese texto; si el estado es 204 devuelva null; y en caso contrario devuelva await respuesta.json(). Reescribe después listarTareas y borrarTarea usándolo, y comprueba que el código queda más corto.

Ejercicio 2 — Buscador de tareas con parámetros. Escribe buscarTareas({ texto, responsable, estados, orden, pagina }) que construya la URL con URLSearchParams, omitiendo los valores vacíos. estados es un array (['pendiente', 'en-curso']) y debe generar ?estado=pendiente&estado=en-curso. La paginación usa _page y _limit, los parámetros de json-server. Devuelve { tareas, total } leyendo el total de la cabecera X-Total-Count.

Ejercicio 3 — Sincronizar el tablero local con el servidor. Escribe sincronizar(repositorio, api) que: cargue el tablero local; pida al servidor la lista de tareas; y devuelva un informe { soloLocal, soloServidor, enAmbos, coinciden } comparando por id, donde coinciden es el número de tareas presentes en ambos lados con el mismo estado. No modifiques nada todavía: solo informa. Usa Map y los métodos de array de 04-05.

Soluciones

Solución 1

export async function pedirJson(url, opciones = {}) {
  const respuesta = await fetch(url, opciones);

  if (!respuesta.ok) {
    // El cuerpo del error suele traer el detalle útil; si no se puede leer, seguimos igual
    const detalle = await respuesta.text().catch(() => '');
    throw new Error(`HTTP ${respuesta.status} ${respuesta.statusText}${detalle ? ` — ${detalle}` : ''}`);
  }

  if (respuesta.status === 204) return null;                 // sin cuerpo

  const tipo = respuesta.headers.get('content-type') ?? '';
  if (!tipo.includes('application/json')) {
    throw new Error(`Se esperaba JSON y llegó "${tipo}"`);   // proxy, login HTML, error del CDN…
  }
  return respuesta.json();
}
export async function listarTareas(filtros = {}) {
  const planas = await pedirJson(construirUrl('/tareas', filtros), { headers: { Accept: 'application/json' } });
  return planas.map((d) => Tarea.desdeJSON(d));
}

export async function borrarTarea(id) {
  await pedirJson(construirUrl(`/tareas/${id}`), { method: 'DELETE' });   // 204 → null, sin romperse
  return true;
}

La comprobación del content-type es la que evita el peor de los fallos: un proxy corporativo o una pantalla de inicio de sesión devuelven HTML con estado 200, y sin esa línea el json() lanzaría un SyntaxError incomprensible. Esta función es el germen del pedirJson completo que construirás en 07-03.

Solución 2

export async function buscarTareas({ texto = '', responsable = null, estados = [],
                                     orden = 'fechaLimite', pagina = 1, porPagina = 20 } = {}) {
  const url = new URL(`${BASE}/tareas`);

  if (texto) url.searchParams.set('q', texto);                     // búsqueda libre de json-server
  if (responsable) url.searchParams.set('responsable', responsable);
  for (const estado of estados) url.searchParams.append('estado', estado);   // append, no set
  url.searchParams.set('_sort', orden);
  url.searchParams.set('_page', pagina);
  url.searchParams.set('_limit', porPagina);

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

  const planas = await respuesta.json();
  return {
    tareas: planas.map((d) => Tarea.desdeJSON(d)),
    total: Number(respuesta.headers.get('X-Total-Count') ?? planas.length)
  };
}

La diferencia entre set y append es la clave: set reemplaza, así que un bucle con set dejaría solo el último estado. Y ojo con X-Total-Count: en una API de otro origen, esa cabecera solo será legible si el servidor la expone con Access-Control-Expose-Headers. Con json-server en localhost no hay problema.

Solución 3

export async function sincronizar(repositorio, api) {
  const local = repositorio.cargar();
  const tareasLocales = local === null ? [] : local.tareas;
  const tareasRemotas = await api.listarTareas();

  const porIdLocal  = new Map(tareasLocales.map((t) => [t.id, t]));
  const porIdRemoto = new Map(tareasRemotas.map((t) => [t.id, t]));

  const soloLocal    = tareasLocales.filter((t) => !porIdRemoto.has(t.id)).map((t) => t.id);
  const soloServidor = tareasRemotas.filter((t) => !porIdLocal.has(t.id)).map((t) => t.id);
  const enAmbos      = tareasLocales.filter((t) => porIdRemoto.has(t.id)).map((t) => t.id);

  const coinciden = enAmbos.filter((id) => porIdLocal.get(id).estado === porIdRemoto.get(id).estado).length;

  return { soloLocal, soloServidor, enAmbos, coinciden, conflictos: enAmbos.length - coinciden };
}
console.log(await sincronizar(repositorio, api));
// { soloLocal: [7], soloServidor: [], enAmbos: [1,2,3,4,5,6], coinciden: 5, conflictos: 1 }

Los dos Map convierten la comparación en operaciones de coste constante en lugar de recorrer el array remoto por cada tarea local. Y fíjate en lo que revela el resultado: hay un conflicto. Qué hacer con él —quién gana cuando local y servidor discrepan— es una decisión de producto, no técnica, y volverás sobre ella en 07-04 al hablar de edición simultánea.

Conclusión

Nómada Tareas ya sabe hablar con un servidor. Entiendes qué significa AJAX y por qué cambió la web: pedir datos en segundo plano y actualizar solo lo que cambia, conservando scroll, foco y filtros, con un JSON de unos cientos de bytes en lugar de un documento HTML completo. Conoces a XMLHttpRequest lo justo para reconocerlo, y sabes que fetch lo sustituye en todo salvo en el progreso de subida, y que sus dos huecos —timeout y cancelación— se rellenan con AbortController.

Tienes el HTTP que se usa a diario: los verbos con su significado y su idempotencia (GET, PUT y DELETE sí, POST no), las rutas que nombran recursos y no acciones, las cinco familias de códigos con la frontera decisiva entre el 4xx que arregla el cliente y el 5xx que no, y las cabeceras Content-Type, Accept y Authorization. Dominas fetch y su doble espera —la primera promesa trae las cabeceras, la segunda el cuerpo—, el objeto Response con ok, status, headers y los cinco métodos de cuerpo, y la regla de que el cuerpo se lee una sola vez salvo que lo clones. Y llevas grabada la trampa que define a esta API: fetch no rechaza con 404 ni con 500; solo rechaza si la petición no llegó a completarse. Por eso la comprobación de respuesta.ok no se recuerda, se encapsula.

Sabes enviar datos con method, headers y bodyJSON.stringify obligatorio, y Content-Type prohibido cuando el cuerpo es FormData—, construir URLs con URL y URLSearchParams distinguiendo set de append, y explicar CORS: la same-origin policy, la petición simple frente al preflight OPTIONS que duplica las entradas en la pestaña Network, las cabeceras Access-Control-* que decide el servidor, y la conclusión que ahorra tardes enteras —no se arregla desde el cliente, y mode: 'no-cors' solo te da una respuesta opaca—. Y tienes clara la parte de seguridad: HTTPS siempre, tokens preferiblemente en cookie HttpOnly y no en localStorage, ninguna clave secreta en el código del cliente, y ninguna confianza en la validación del navegador.

En código, el módulo js/datos/api-tareas.js con listarTareas, obtenerTarea, crearTarea, actualizarTarea y borrarTarea, que devuelve instancias de Tarea y no objetos planos, igual que hacía RepositorioLocal: la capa datos/ es una frontera, y el resto de la aplicación no sabe si los datos vienen del disco o de la red. Y un json-server en localhost:3000 con el backlog canónico para que puedas practicar contra un servidor de verdad, porque api.tallernomada.example es y seguirá siendo ficticio.

Ahora bien: ese código funciona solo cuando todo va bien. Y en la red nada va bien todo el tiempo. El wifi del taller se cae a mitad de un POST; la API tarda quince segundos y Marta pulsa el botón tres veces; el servidor devuelve un 503 porque están desplegando; Iván escribe en el buscador y se lanzan ocho peticiones de las que llega antes la penúltima, dejando en pantalla resultados que no corresponden a lo que escribió. Ninguna de esas situaciones la cubre lo que has escrito hoy: no hay timeout, no hay cancelación, no hay reintentos, no hay estado de carga y no hay forma de decirle al usuario qué ha pasado. Eso es exactamente lo que separa una demo de una aplicación, y es el tema de Peticiones Robustas: Errores, Timeouts y AbortController, donde AbortController —presentado de pasada en 06-04— por fin ocupará el lugar que le corresponde.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados