Tu proyecto funciona: el dominio está en verde, la primera rebanada vertical se ve en el navegador y las reglas R1–R15 se cumplen. Y sin embargo hay una mentira en marcha, porque al recargar la página todo desaparece. El repositorio en memoria hizo exactamente lo que tenía que hacer —no bloquearte mientras construías lo importante— y ahora toca cambiarlo por algo real, sin tocar una sola línea de vista. Esa es la prueba de fuego del contrato que definiste en la lección anterior. Pero persistir no es solo llamar a localStorage.setItem: es decidir qué almacenamiento encaja con tu caso, versionar el formato que guardas para poder cambiarlo dentro de tres meses sin perder los datos de nadie, hablar con una API de forma que los fallos de red sean parte del diseño y no una sorpresa, decidir quién gana cuando dos personas editan lo mismo, permitir trabajar sin conexión con una cola de cambios que se reenvía sola, y hacer que la aplicación parezca instantánea con actualizaciones optimistas que se revierten si el servidor dice que no. Y hay una parte que no es técnica y sí es obligatoria: qué no debe estar nunca en el navegador, y qué implica legalmente guardar datos de personas. Al terminar tendrás persistencia con migraciones probadas, una capa de API con sus estados, y una cola offline funcionando.

Contenido

  1. Elegir el almacenamiento según el caso
  2. Cuándo localStorage se queda corto
  3. IndexedDB a nivel práctico
  4. Una envoltura mínima con promesas
  5. Versionar el formato guardado
  6. Migraciones numeradas y sus pruebas
  7. El repositorio como frontera: una interfaz, tres implementaciones
  8. Hablar con la API: fetch robusto
  9. Los estados de una operación asíncrona
  10. Reintentos, cancelación y AbortController
  11. Sincronización: quién gana cuando dos editan a la vez
  12. La cola de cambios pendientes
  13. Idempotencia y reenvío seguro
  14. Actualización optimista con reversión
  15. Tiempo real sin duplicar cambios propios
  16. Seguridad y privacidad de lo que se guarda
  17. Fallos de sincronización y su tratamiento
  18. Errores Comunes y Consejos
  19. Ejercicios
  20. Conclusión

  1. Elegir el almacenamiento según el caso

La lección 07-01 presentó las opciones de almacenamiento del navegador. Aquí la tabla vuelve con la columna que entonces no importaba y ahora sí: cuándo elegir cada una para tu proyecto.

Opción Capacidad típica Síncrono Estructurado Persiste Cuándo la eliges
Variables en memoria RAM Solo la sesión Estado de interfaz que no debe sobrevivir a la recarga
sessionStorage ~5 MB No (solo texto) Hasta cerrar la pestaña Datos de un flujo en curso: un formulario largo a medias
localStorage ~5–10 MB No (solo texto) Hasta que se borre Preferencias, y conjuntos de datos pequeños y estables
IndexedDB Cientos de MB a GB No (promesas) Sí (objetos, índices) Hasta que se borre Volúmenes grandes, consultas por índice, datos binarios
Cache Storage Cientos de MB No Peticiones y respuestas Hasta que se borre Recursos de la aplicación: el service worker de 07-05
Cookies ~4 kB No Configurable Solo identificación de sesión con el servidor
API remota Ilimitada No Siempre Multidispositivo, multiusuario, datos que importan de verdad

Dos advertencias que cambian decisiones:

localStorage es síncrono, y eso significa que bloquea el hilo principal. Guardar 2 MB de JSON puede costar decenas de milisegundos durante los cuales la interfaz no responde. Con el presupuesto de INP ≤ 200 ms de 11-01, eso importa. IndexedDB es asíncrona y no bloquea.

Nada de lo que está en el navegador es privado. Cualquier persona con acceso al dispositivo, y cualquier script que se ejecute en tu página, puede leerlo todo. Volveremos sobre esto en el apartado 16, pero tenlo presente ya al decidir qué guardas.

El árbol de decisión para tu proyecto:

flowchart TD
    A["¿Los datos deben verse<br/>en otro dispositivo?"] -->|Sí| B["API remota<br/>+ caché local"]
    A -->|No| C["¿Cuánto ocupan<br/>en el peor caso?"]
    C -->|"< 1 MB y estable"| D["localStorage<br/>con migraciones"]
    C -->|"> 1 MB o crece sin límite"| E["IndexedDB"]
    C -->|"No lo sé"| F["Mídelo con datos<br/>realistas ANTES de decidir"]
    F --> C
    E --> G["¿Necesitas consultar<br/>por algo que no sea el id?"]
    G -->|Sí| H["IndexedDB con índices"]
    G -->|No| I["IndexedDB como<br/>almacén clave-valor"]

    style D fill:#dcfce7,stroke:#16a34a
    style B fill:#dbeafe,stroke:#2563eb
    style F fill:#fef3c7,stroke:#d97706

El nodo naranja es el importante. «No lo sé» es la respuesta honesta al principio, y la salida no es adivinar: es medir con datos realistas. Genera 500 tareas, 3.000 entradas de historial y 10 usuarios ficticios, serialízalo y mira cuánto ocupa:

// Un cálculo de treinta segundos que evita una decisión equivocada
const datos = generarDatosRealistas({ tareas: 500, historial: 3000, usuarios: 10 });
const texto = JSON.stringify(datos);
console.log('Tamaño:', (new Blob([texto]).size / 1024).toFixed(1), 'kB');
console.time('serializar'); JSON.stringify(datos); console.timeEnd('serializar');

Para Órbita, con ese volumen, el resultado ronda los 900 kB y la serialización unos 12 ms. Conclusión: localStorage sirve para el MVP, con dos condiciones — que el historial se pode (apartado 2) y que la escritura no ocurra en cada pulsación de tecla.

  1. Cuándo localStorage se queda corto

Tiene cinco límites, y conviene reconocerlos antes de chocar con ellos:

Límite Síntoma Momento en que aparece
Cuota (~5–10 MB) QuotaExceededError al guardar Cuando el historial (R14) lleva unos meses creciendo
Síncrono La interfaz se congela al guardar Con más de ~1 MB, o al guardar muy a menudo
Solo texto JSON.parse en cada lectura Coste de CPU proporcional al total, aunque solo quieras una tarea
Todo o nada Hay que leer y escribir el documento entero Cambiar una tarea reescribe las 500
Sin consultas Filtrar exige cargarlo todo en memoria Siempre, aunque con volúmenes pequeños no se note

El más peligroso es el primero, porque falla en producción y en el dispositivo de otra persona, no en el tuyo. Y el manejo correcto no es un try/catch vacío:

// src/datos/repositorio-local.js
async guardarTodo(documento) {
  const texto = JSON.stringify(documento);
  try {
    localStorage.setItem(this.clave, texto);
  } catch (error) {
    if (esErrorDeCuota(error)) {
      // 1 · Podar lo que se puede podar: el historial antiguo
      const podado = podarHistorial(documento, { conservar: 200 });
      try {
        localStorage.setItem(this.clave, JSON.stringify(podado));
        this.avisos.emitir('historial-podado', { eliminadas: cuantas });
        return;
      } catch { /* sigue sin caber */ }
    }
    // 2 · Si no cabe ni podado, ES UN ERROR DEL USUARIO Y HAY QUE DECÍRSELO
    throw new ErrorDeDatos(
      'No hay espacio para guardar. Exporta tus datos y libera espacio.',
      { causa: error, recuperable: false }
    );
  }
}

function esErrorDeCuota(error) {
  return error instanceof DOMException &&
    (error.name === 'QuotaExceededError' ||
     error.name === 'NS_ERROR_DOM_QUOTA_REACHED');  // Firefox antiguo
}

Tres decisiones de ese fragmento:

  • Se intenta podar antes de rendirse. El historial es lo único prescindible; las tareas no lo son nunca.
  • Se avisa de la poda. Borrar datos del usuario en silencio es inaceptable, aunque sean datos secundarios.
  • Si no cabe, se lanza con un mensaje accionable. «Error al guardar» no ayuda; «exporta tus datos y libera espacio» sí. Y recuperable: false le dice a la interfaz que no ofrezca un botón de reintentar que volvería a fallar.

La comprobación que casi nadie hace: en modo privado de algunos navegadores, y con ciertas configuraciones de bloqueo, localStorage existe pero lanza al escribir. Compruébalo al arrancar y degrada con elegancia:

export function hayAlmacenamiento() {
  try {
    const prueba = '__orbita_prueba__';
    localStorage.setItem(prueba, '1');
    localStorage.removeItem(prueba);
    return true;
  } catch { return false; }
}

Si devuelve false, la aplicación debe seguir funcionando en memoria y avisar claramente de que los cambios no se guardarán. Es la diferencia entre una aplicación rota y una aplicación honesta.

  1. IndexedDB a nivel práctico

IndexedDB es la base de datos del navegador. Tiene fama de incómoda y la tiene merecida: su API nativa es de 2010, basada en eventos y verbosa. Pero la parte que necesitas es pequeña.

El modelo mental, en cuatro conceptos:

Concepto Equivalente En Órbita
Base de datos Una base de datos orbita
Almacén de objetos (object store) Una tabla tareas, usuarios, historial, cola
Clave Clave primaria id de la tarea
Índice Índice de columna porResponsable, porFechaLimite

Y cuatro reglas de funcionamiento que hay que entender antes de usarla:

  1. Todo ocurre dentro de una transacción, que puede ser readonly o readwrite.
  2. Las transacciones se cierran solas en cuanto el bucle de eventos queda sin trabajo pendiente para ellas. Si haces un await de algo ajeno en medio, la transacción muere. Es la fuente número uno de errores desconcertantes.
  3. El esquema solo se cambia en onupgradeneeded, que se dispara al abrir con un número de versión mayor. Es el equivalente a la migración del apartado 6, pero para la estructura.
  4. Guarda objetos, no texto. Usa el algoritmo de clonación estructurada, así que admite Date, Map, Set, ArrayBuffer… pero no funciones ni instancias de clase con sus métodos. Guarda objetos planos y reconstruye las entidades al leer, exactamente como con desdeJSON.

Cuándo migrar de localStorage a IndexedDB, con criterios objetivos:

Señal Umbral
El documento serializado supera ~2 MB
El tiempo de guardado supera ~16 ms (un fotograma)
Necesitas leer una parte sin cargar el todo Siempre
Guardas datos binarios (imágenes, adjuntos) Siempre
Necesitas consultar por algo que no sea la clave Siempre

Para el MVP de Órbita, con 900 kB estimados, no hace falta. Y eso también es una decisión defendible que merece su ADR: «se elige localStorage porque el volumen previsto está un orden de magnitud por debajo del límite, y la frontera del repositorio permite cambiar a IndexedDB sin tocar el resto».

  1. Una envoltura mínima con promesas

Si tu proyecto sí necesita IndexedDB, no uses la API nativa directamente en tu repositorio: envuélvela una vez, en un fichero, y olvídate.

// src/datos/idb.js — envoltura mínima con promesas
export function abrir(nombre, version, alActualizar) {
  return new Promise((resolver, rechazar) => {
    const peticion = indexedDB.open(nombre, version);
    peticion.onupgradeneeded = (e) => alActualizar(e.target.result, e.oldVersion, e.newVersion);
    peticion.onsuccess = () => resolver(peticion.result);
    peticion.onerror = () => rechazar(new ErrorDeDatos('No se pudo abrir la base', { causa: peticion.error }));
    peticion.onblocked = () => rechazar(new ErrorDeDatos('Hay otra pestaña con una versión antigua abierta'));
  });
}

function promesaDe(peticion) {
  return new Promise((resolver, rechazar) => {
    peticion.onsuccess = () => resolver(peticion.result);
    peticion.onerror = () => rechazar(peticion.error);
  });
}

export async function leerTodo(db, almacen) {
  const tx = db.transaction(almacen, 'readonly');
  return promesaDe(tx.objectStore(almacen).getAll());
}

export async function escribirLote(db, almacen, objetos) {
  const tx = db.transaction(almacen, 'readwrite');
  const store = tx.objectStore(almacen);
  // OJO: nada de await ajeno aquí dentro, o la transacción se cierra
  for (const objeto of objetos) store.put(objeto);
  return new Promise((resolver, rechazar) => {
    tx.oncomplete = () => resolver();
    tx.onerror = () => rechazar(tx.error);
    tx.onabort = () => rechazar(tx.error ?? new Error('Transacción abortada'));
  });
}

Cuatro puntos que explican por qué esta envoltura es así:

1 · promesaDe convierte el patrón de eventos en una promesa. Es exactamente la técnica de «promisificación» de 05-06: una función que envuelve una API de callbacks en un new Promise. Escrita una vez, sirve para todas las operaciones.

2 · onblocked está contemplado. Ocurre cuando el usuario tiene dos pestañas abiertas y una intenta actualizar el esquema mientras la otra usa la versión antigua. Ignorarlo produce un cuelgue silencioso que es dificilísimo de diagnosticar.

3 · El comentario del await no es decorativo. Esta es la trampa clásica:

// ❌ La transacción muere a mitad
const tx = db.transaction('tareas', 'readwrite');
for (const tarea of tareas) {
  const validada = await validarEnServidor(tarea);   // ← await ajeno: tx se cierra
  tx.objectStore('tareas').put(validada);            // ← TransactionInactiveError
}

// ✅ Preparar todo antes, escribir después
const validadas = await Promise.all(tareas.map(validarEnServidor));
const tx = db.transaction('tareas', 'readwrite');
for (const t of validadas) tx.objectStore('tareas').put(t);

4 · Se espera a oncomplete, no a la última petición. Que la última escritura tenga éxito no significa que la transacción se haya confirmado. Solo oncomplete garantiza que los datos están en disco.

  1. Versionar el formato guardado

Aquí está el apartado que separa un proyecto de juguete de uno serio, y merece la pena decirlo sin rodeos:

El día que cambies el modelo de datos, los usuarios ya tendrán datos guardados con el formato antiguo. Si no lo has previsto, los pierdes.

En Nómada Tareas, la clave era 'nomada:tablero:v1'. Ese v1 era la semilla de esta idea. Ahora se convierte en un mecanismo completo.

El documento guardado nunca es la lista de tareas a secas. Es un sobre con metadatos:

{
  "version": 3,
  "guardadoEn": "2026-09-20T18:42:11.320Z",
  "aplicacion": "orbita",
  "datos": {
    "tareas": [],
    "usuarios": [],
    "historial": []
  }
}
Campo Para qué
version El único imprescindible. Dice qué migraciones hay que aplicar
guardadoEn Depuración y resolución de conflictos por marca de tiempo
aplicacion Detectar que la clave la escribió otra cosa; evita corromper datos ajenos
datos El contenido real, siempre anidado, nunca en la raíz

Ese anidamiento importa: si los datos van en la raíz junto a version, añadir un metadato nuevo puede chocar con una entidad. Con datos aparte, el sobre y el contenido evolucionan por separado.

Regla de la versión: el número solo sube, de uno en uno, y cada subida tiene su migración. Nunca se reutiliza un número, ni siquiera durante el desarrollo — porque tu propio navegador de desarrollo ya tiene datos de la versión anterior, y ahí es donde encontrarás los fallos de migración antes que nadie.

  1. Migraciones numeradas y sus pruebas

Una migración es una función pura que transforma el documento de la versión N a la N+1.

// src/datos/migraciones.js
export const MIGRACIONES = [
  {
    a: 1,
    descripcion: 'Formato inicial',
    migrar: (doc) => doc
  },
  {
    a: 2,
    descripcion: 'responsable (texto) pasa a responsableId (referencia)',
    migrar: (doc) => {
      const porNombre = new Map(doc.datos.usuarios.map((u) => [u.nombre, u.id]));
      return {
        ...doc,
        datos: {
          ...doc.datos,
          tareas: doc.datos.tareas.map(({ responsable, revisor, ...resto }) => ({
            ...resto,
            responsableId: responsable ? (porNombre.get(responsable) ?? null) : null,
            revisorId: revisor ? (porNombre.get(revisor) ?? null) : null
          }))
        }
      };
    }
  },
  {
    a: 3,
    descripcion: 'Añadir tareaMadreId y creadaEn a las tareas existentes',
    migrar: (doc) => ({
      ...doc,
      datos: {
        ...doc.datos,
        tareas: doc.datos.tareas.map((t) => ({
          ...t,
          tareaMadreId: t.tareaMadreId ?? null,
          creadaEn: t.creadaEn ?? doc.guardadoEn ?? '2026-01-01T00:00:00.000Z'
        }))
      }
    })
  }
];

export const VERSION_ACTUAL = MIGRACIONES.at(-1).a;

export function migrar(documento) {
  let doc = documento;
  const desde = doc.version ?? 0;

  if (desde > VERSION_ACTUAL) {
    throw new ErrorDeDatos(
      `Los datos son de una versión más reciente (${desde}) que esta aplicación (${VERSION_ACTUAL}). ` +
      'Actualiza la aplicación para poder abrirlos.'
    );
  }

  for (const paso of MIGRACIONES) {
    if (paso.a <= desde) continue;
    doc = { ...paso.migrar(doc), version: paso.a };
  }
  return doc;
}

Seis propiedades de este diseño, y por qué cada una importa:

1 · Las migraciones son funciones puras. Reciben un documento y devuelven otro. No leen ni escriben localStorage. Por eso se pueden probar con un objeto literal, sin montar nada.

2 · Se aplican en cadena. Un usuario que abandonó la aplicación en la versión 1 y vuelve hoy pasa por 1→2 y 2→3 automáticamente. No hace falta una migración «de 1 a 3».

3 · Cada migración tiene su descripción. Es documentación que vive junto al código y aparece en el registro cuando se aplica.

4 · La versión futura se rechaza con un mensaje claro. Ocurre de verdad: el usuario tiene dos dispositivos y uno actualizó antes. Intentar leer un formato futuro y «apañarse» corrompe los datos; negarse y explicarlo, no.

5 · Los valores por defecto son conservadores. creadaEn usa guardadoEn si existe, y solo si no hay nada recurre a una fecha fija. Inventar new Date() pondría a todas las tareas antiguas como creadas hoy, rompiendo R4 y el orden del historial.

6 · La migración 2 usa el índice porNombre. Y cuando un nombre no existe entre los usuarios, pone null en lugar de fallar. Es la decisión correcta: perder una asignación es malo, pero no poder abrir la aplicación es peor.

6.1 Cómo se prueban las migraciones

Esta es la parte que casi nadie hace y la que evita el desastre. La técnica: guarda documentos reales de cada versión antigua como ficheros de prueba.

test/datos/fixtures/
  documento-v1.json      ← copiado literalmente de un localStorage real de la v1
  documento-v2.json
  documento-v1-vacio.json
  documento-v1-corrupto.json
// test/datos/migraciones.test.js
import { migrar, VERSION_ACTUAL } from '../../src/datos/migraciones.js';
import v1 from './fixtures/documento-v1.json';
import v2 from './fixtures/documento-v2.json';

describe('Migraciones', () => {
  test.each([
    ['v1', v1],
    ['v2', v2]
  ])('%s migra a la versión actual sin perder tareas', (nombre, original) => {
    const resultado = migrar(structuredClone(original));

    expect(resultado.version).toBe(VERSION_ACTUAL);
    expect(resultado.datos.tareas).toHaveLength(original.datos.tareas.length);
  });

  test('v1: cada responsable con nombre conocido conserva su asignación', () => {
    const resultado = migrar(structuredClone(v1));
    const original = v1.datos.tareas.find((t) => t.responsable === 'Marta');
    const migrada = resultado.datos.tareas.find((t) => t.id === original.id);
    expect(migrada.responsableId).toBe('u-marta');
    expect(migrada).not.toHaveProperty('responsable');   // el campo viejo se va
  });

  test('un responsable desconocido pasa a null, no rompe la migración', () => {
    const conFantasma = structuredClone(v1);
    conFantasma.datos.tareas[0].responsable = 'Persona Que No Existe';
    expect(() => migrar(conFantasma)).not.toThrow();
    expect(migrar(conFantasma).datos.tareas[0].responsableId).toBeNull();
  });

  test('el resultado de migrar es válido para el dominio', () => {
    const resultado = migrar(structuredClone(v1));
    for (const plano of resultado.datos.tareas) {
      expect(() => Tarea.desdeJSON(plano)).not.toThrow();
    }
  });

  test('migrar es idempotente: aplicarla dos veces no cambia nada', () => {
    const una = migrar(structuredClone(v1));
    const dos = migrar(structuredClone(una));
    expect(dos).toEqual(una);
  });

  test('una versión futura se rechaza con mensaje explicativo', () => {
    expect(() => migrar({ version: 99, datos: {} })).toThrow(/más reciente/);
  });
});

Las seis pruebas cubren los seis fallos posibles, y la cuarta y la quinta son las que más valor tienen:

  • «El resultado es válido para el dominio» es la que de verdad cierra el círculo. Una migración puede producir un documento sintácticamente correcto y semánticamente inválido —una tarea sin título, unas horas a 0— que reventará al construir la entidad. Validar cada objeto migrado contra el dominio lo detecta ahí.
  • La idempotencia protege contra el fallo más común de las migraciones: aplicarlas dos veces por un error de flujo. Si migrar(migrar(x)) === migrar(x), ese error es inofensivo.

Y la regla de oro operativa: antes de aplicar migraciones sobre datos reales, haz una copia de seguridad.

async function cargarConMigracion() {
  const bruto = localStorage.getItem(CLAVE);
  if (!bruto) return documentoVacio();

  const documento = JSON.parse(bruto);
  if (documento.version === VERSION_ACTUAL) return documento;

  // Copia de seguridad ANTES de tocar nada
  localStorage.setItem(`${CLAVE}:respaldo:v${documento.version}`, bruto);
  try {
    const migrado = migrar(documento);
    localStorage.setItem(CLAVE, JSON.stringify(migrado));
    return migrado;
  } catch (error) {
    registrar(error, { caso: 'migracion', desde: documento.version });
    throw new ErrorDeDatos(
      'No se han podido actualizar tus datos. Se conserva una copia de seguridad.',
      { causa: error, recuperable: false }
    );
  }
}

El respaldo cuesta una línea y convierte un desastre irreversible en un incidente recuperable. En la lección 11-04 depurarás precisamente una migración que corrompe datos, y esa copia será lo que te permita investigar.

  1. El repositorio como frontera: una interfaz, tres implementaciones

Ahora se cobra la inversión de la lección anterior. El contrato de src/datos/repositorio.js no cambia; aparecen dos implementaciones más:

flowchart LR
    A["aplicacion/<br/>casos de uso"] --> C{{"Contrato Repositorio<br/>listarTareas, guardarTarea,<br/>borrarTarea, añadirCambio…"}}
    C --> M["RepositorioMemoria<br/><i>pruebas, arranque</i>"]
    C --> L["RepositorioLocal<br/><i>localStorage + migraciones</i>"]
    C --> P["RepositorioApi<br/><i>fetch + reintentos</i>"]
    C --> S["RepositorioSincronizado<br/><i>local + api + cola</i>"]

    style C fill:#f3e8ff,stroke:#9333ea
    style S fill:#dbeafe,stroke:#2563eb

Y la misma batería de pruebas de contrato se ejecuta contra las cuatro:

// test/datos/repositorios.test.js
import { pruebasDeContrato } from './contrato-repositorio.js';

pruebasDeContrato('memoria', async () => new RepositorioMemoria());

pruebasDeContrato('local', async () => {
  localStorage.clear();
  return new RepositorioLocal('orbita:pruebas');
});

pruebasDeContrato('api', async () => {
  servidorSimulado.reiniciar();
  return new RepositorioApi('http://localhost:3001');
});

Si las tres pasan las mismas pruebas, son sustituibles, y cambiar de almacenamiento es cambiar una línea en main.js:

// src/main.js
const repo = import.meta.env.VITE_ORIGEN === 'api'
  ? new RepositorioApi(import.meta.env.VITE_API_URL)
  : new RepositorioLocal('orbita:tablero');

Esto es lo que la lección 08-04 llamaba inyección de dependencias, y aquí se ve su valor completo: la misma decisión de diseño que hizo posible probar con dobles hace posible cambiar de tecnología de almacenamiento. No son dos beneficios: es el mismo, mirado desde dos sitios.

RepositorioSincronizado es el que construirás en los apartados 12 a 14: combina local (rápido, siempre disponible) con API (compartido, autoritativo) y una cola para lo que no se ha podido enviar. Y cumple el mismo contrato, así que la aplicación no se entera de la diferencia.

  1. Hablar con la API: fetch robusto

Si tu proyecto va a hablar con un servidor, la capa de red se construye una vez y bien. Es lo que hacía js/datos/http.js en Nómada Tareas con pedirJson, ErrorDeApi y conReintentos, y merece la pena reconstruirlo entendiendo cada decisión.

// src/datos/http.js
export class ErrorDeApi extends Error {
  constructor(mensaje, { status, codigo, cuerpo } = {}) {
    super(mensaje);
    this.name = 'ErrorDeApi';
    this.status = status ?? 0;
    this.codigo = codigo ?? null;
    this.cuerpo = cuerpo ?? null;
  }
  get reintentable() {
    // 0 = fallo de red. 408 tiempo agotado. 429 demasiadas peticiones. 5xx servidor.
    return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500;
  }
  get esDeCliente() { return this.status >= 400 && this.status < 500; }
}

export async function pedirJson(url, opciones = {}) {
  const { tiempoMaximo = 8000, señal, ...resto } = opciones;
  const abortador = new AbortController();
  const temporizador = setTimeout(() => abortador.abort('tiempo agotado'), tiempoMaximo);
  señal?.addEventListener('abort', () => abortador.abort(señal.reason), { once: true });

  try {
    const respuesta = await fetch(url, {
      ...resto,
      signal: abortador.signal,
      headers: { 'Content-Type': 'application/json', ...resto.headers }
    });

    if (!respuesta.ok) {
      const cuerpo = await leerCuerpoSeguro(respuesta);
      throw new ErrorDeApi(cuerpo?.mensaje ?? `Error ${respuesta.status}`, {
        status: respuesta.status,
        codigo: cuerpo?.codigo,
        cuerpo
      });
    }
    return respuesta.status === 204 ? null : respuesta.json();
  } catch (error) {
    if (error instanceof ErrorDeApi) throw error;
    if (error.name === 'AbortError') {
      throw new ErrorDeApi('La petición ha tardado demasiado', { status: 408 });
    }
    throw new ErrorDeApi('No hay conexión con el servidor', { status: 0 });
  } finally {
    clearTimeout(temporizador);
  }
}

Los siete puntos que hacen robusta esta función, y que 07-03 introdujo:

  1. fetch no lanza con 404 ni 500. Solo lanza si la red falla. Sin la comprobación de respuesta.ok, un 500 se procesaría como si fuera un éxito con cuerpo raro. Es el error número uno con fetch.
  2. Tiempo máximo explícito. fetch no tiene tiempo de espera por defecto: una petición puede quedarse colgada indefinidamente y con ella tu indicador de carga.
  3. La señal externa se propaga. Permite que quien llama cancele (apartado 10) además del temporizador interno.
  4. El cuerpo del error se lee. Las APIs devuelven información útil en el cuerpo del 400: qué campo falló y por qué. Descartarlo obliga a mostrar «Error 400» a un usuario que no puede hacer nada con eso.
  5. leerCuerpoSeguro envuelve el json() en try/catch, porque un error de servidor puede devolver HTML en lugar de JSON, y entonces el json() lanza y tapa el error original.
  6. 204 devuelve null. Sin contenido significa sin contenido; llamar a json() sobre un cuerpo vacío lanza.
  7. Todo error acaba siendo ErrorDeApi. La capa superior maneja un tipo, no cinco. Eso simplifica muchísimo el catch de los casos de uso.

Y conReintentos, con retroceso exponencial y variación aleatoria:

export async function conReintentos(fn, { intentos = 3, base = 300, señal } = {}) {
  for (let i = 0; i < intentos; i++) {
    try {
      return await fn();
    } catch (error) {
      const ultimo = i === intentos - 1;
      if (ultimo || !(error instanceof ErrorDeApi) || !error.reintentable) throw error;
      const espera = base * 2 ** i + Math.random() * 200;   // 300, 600, 1200 ms + ruido
      await dormir(espera, señal);
    }
  }
}

Solo se reintenta lo reintentable. Reintentar un 400 («falta el título») es inútil: el resultado será idéntico las tres veces, y habrás multiplicado por tres la espera del usuario antes de mostrarle un error que ya se conocía al primer intento.

La variación aleatoria (jitter) evita que, si el servidor se cae y vuelve, todos los clientes reintenten en el mismo milisegundo y lo tumben otra vez. Con un solo usuario da igual; es una de esas cosas que cuestan una línea y se agradecen cuando hay mil.

  1. Los estados de una operación asíncrona

Una operación de red no tiene dos desenlaces, tiene cuatro estados, y la interfaz debe poder pintar los cuatro:

stateDiagram-v2
    [*] --> Inactivo
    Inactivo --> Cargando: se lanza la petición
    Cargando --> Exito: 2xx
    Cargando --> Error: 4xx, 5xx o red
    Cargando --> Cancelado: AbortController
    Error --> Cargando: reintentar
    Exito --> Cargando: recargar
    Cancelado --> Inactivo
Estado Qué se muestra Error típico si se ignora
Inactivo Nada, o el estado vacío
Cargando Esqueleto o indicador + aria-busy="true" Doble envío por impaciencia
Éxito Los datos, anunciados si cambian
Error Mensaje comprensible + acción («Reintentar») Pantalla en blanco sin explicación
Cancelado Se vuelve al estado anterior, sin error Mensaje de error por algo que el usuario canceló

Dos detalles de implementación que marcan la diferencia:

Deshabilita el disparador mientras carga. Un botón «Guardar» que sigue pulsable durante los dos segundos de la petición produce tres tareas idénticas. Es el fallo más frecuente de las aplicaciones que hablan con servidores.

Retrasa el indicador de carga unos 200 ms. Si la respuesta llega en 80 ms, un indicador que aparece y desaparece produce un parpadeo desagradable y contribuye al CLS. Mostrarlo solo si la operación se alarga es un detalle pequeño con efecto grande en la percepción de calidad.

let temporizadorDeCarga = setTimeout(() => almacen.actualizar({ cargando: true }), 200);
try {
  const datos = await repo.listarTareas();
  almacen.actualizar({ tareas: datos, cargando: false, error: null });
} finally {
  clearTimeout(temporizadorDeCarga);
}

Los esqueletos frente a los indicadores giratorios. Un esqueleto —bloques grises con la forma del contenido que va a llegar— informa mejor y, sobre todo, reserva el espacio, evitando el salto de diseño que castiga el CLS del presupuesto de 11-01. Un indicador giratorio centrado no reserva nada.

  1. Reintentos, cancelación y AbortController

La cancelación es la parte que más se olvida, y produce un fallo muy concreto: la respuesta obsoleta que pisa a la buena.

El escenario, que ocurre siempre que hay un buscador:

t=0    ms  El usuario escribe "ser"    → petición A
t=120  ms  El usuario escribe "seri"   → petición B
t=400  ms  Llega la respuesta de B → se pintan los resultados de "seri"  ✅
t=650  ms  Llega la respuesta de A → se pintan los resultados de "ser"   ❌

El usuario ve resultados de una búsqueda que ya no está escrita. Y no es un fallo raro: es lo que pasa por defecto cuando las peticiones tardan distinto.

La solución con AbortController:

// src/aplicacion/casos-uso.js
let abortadorDeBusqueda = null;

export async function buscar(almacen, repo, texto) {
  abortadorDeBusqueda?.abort('búsqueda superada');    // cancela la anterior
  abortadorDeBusqueda = new AbortController();

  try {
    const resultados = await repo.buscarTareas(texto, { señal: abortadorDeBusqueda.signal });
    almacen.actualizar({ resultados, cargando: false });
  } catch (error) {
    if (error.name === 'AbortError' || error.causa?.name === 'AbortError') return;  // esperado
    almacen.actualizar({ error: aErrorDeInterfaz(error), cargando: false });
  }
}

Tres reglas de la cancelación:

  1. Una cancelación no es un error. Se ignora en silencio. Mostrar «Error: petición abortada» por algo que provocó tu propio código es desconcertante para el usuario.
  2. Cancela también al desmontar. El destruir() de una vista debe abortar sus peticiones en vuelo. Si no, la respuesta llega a una vista que ya no existe, intenta tocar un DOM desconectado y deja viva toda su cadena de referencias: es una de las fugas de memoria que buscarás en 11-04.
  3. Cancelación y debounce se combinan. El debounce de 09-02 reduce el número de peticiones; la cancelación asegura que, de las que sí salen, solo importa la última. Necesitas las dos.

  1. Sincronización: quién gana cuando dos editan a la vez

En cuanto hay más de un dispositivo, aparece el problema central de la sincronización: dos personas editan la misma tarea y hay que decidir qué prevalece.

Las tres estrategias, con sus contrapartidas reales:

Estrategia Cómo funciona Ventajas Inconvenientes Cuándo elegirla
Última escritura gana El servidor acepta lo último que llega Trivial de implementar; nunca bloquea Pierde cambios en silencio; depende de relojes Datos de un solo dueño; preferencias
Versión / updatedAt El cliente envía la versión que leyó; el servidor rechaza si cambió (409) No pierde nada sin avisar; detectable y explicable Requiere resolver el conflicto en la interfaz La recomendada para Órbita
Fusión por campos Se combinan cambios de campos distintos Muchos conflictos desaparecen solos Compleja; puede producir estados incoherentes Documentos con campos independientes
CRDT / fusión automática Estructuras que convergen sin conflicto Colaboración real en tiempo real Muy compleja; formato de datos condicionado Edición colaborativa tipo documento

Para tu proyecto, la segunda. Es la que ofrece la mejor relación entre coste y garantía, y se implementa así:

// El cliente envía la versión que tenía
await pedirJson(`${base}/tareas/${id}`, {
  method: 'PUT',
  headers: { 'If-Match': tarea.version },      // o en el cuerpo, si la API lo prefiere
  body: JSON.stringify(tarea.toJSON())
});
// El servidor responde 409 Conflict si su versión es distinta

Y qué hacer con el 409, que es la parte que decide la calidad de la aplicación:

Opción Experiencia Recomendación
Sobrescribir sin preguntar Se pierde el trabajo de otra persona Nunca
Descartar lo mío sin preguntar Se pierde mi trabajo Nunca
Recargar y avisar «Esta tarea cambió; se han recargado los datos» Aceptable si mi cambio era trivial
Mostrar ambas versiones y elegir «Tú pusiste 12 h; Marta puso 8 h» con dos botones La correcta

Implementar la cuarta cuesta una pantalla pequeña y es exactamente el tipo de detalle que en 11-06 vas a poder contar en una entrevista: demuestra que has pensado en el caso incómodo.

Sobre los relojes. «Última escritura gana» compara marcas de tiempo, y los relojes de los clientes no son fiables: pueden estar desajustados horas. Si usas marcas de tiempo para decidir, usa siempre las del servidor, nunca las del cliente. Es un detalle que produce fallos imposibles de reproducir.

  1. La cola de cambios pendientes

Trabajar sin conexión —la promesa de la PWA de 07-05— exige que los cambios que no se pudieron enviar no se pierdan. La estructura que lo resuelve es una cola persistente de operaciones.

// src/datos/cola.js
export class ColaDeCambios {
  #clave;

  encolar(operacion) {
    const entrada = {
      id: crypto.randomUUID(),          // clave de idempotencia (apartado 13)
      tipo: operacion.tipo,             // 'crear' | 'actualizar' | 'borrar'
      recurso: operacion.recurso,       // 'tarea' | 'usuario'
      cargaUtil: operacion.cargaUtil,
      creadaEn: new Date().toISOString(),
      intentos: 0,
      ultimoError: null
    };
    this.#persistir([...this.listar(), entrada]);
    return entrada.id;
  }

  listar() { /* lee de localStorage o IndexedDB */ }
  marcarIntento(id, error) { /* intentos++, ultimoError */ }
  eliminar(id) { /* al confirmarse */ }
  get pendientes() { return this.listar().length; }
}

El ciclo de vida de una operación en cola:

flowchart TD
    A["El usuario actúa"] --> B["Se aplica en local<br/><i>optimista</i>"]
    B --> C{"¿Hay conexión?"}
    C -->|Sí| D["Enviar al servidor"]
    C -->|No| E["Encolar"]
    D -->|2xx| F["Confirmar:<br/>eliminar de la cola"]
    D -->|"4xx (no reintentable)"| G["Revertir + avisar<br/>+ sacar de la cola"]
    D -->|"5xx / red"| E
    E --> H["Esperar evento 'online'<br/>o reintento programado"]
    H --> I["Vaciar la cola<br/>en orden"]
    I --> D

    style E fill:#fef3c7,stroke:#d97706
    style G fill:#fee2e2,stroke:#b91c1c
    style F fill:#dcfce7,stroke:#16a34a

Las cinco reglas de una cola que funciona:

  1. Orden estricto. Las operaciones se reenvían en el orden en que se encolaron. Si «crear tarea 7» y «actualizar tarea 7» se envían al revés, la segunda falla con 404.
  2. Persistente, no en memoria. Si vive en una variable, se pierde al cerrar la pestaña — justo cuando más falta hace.
  3. Cada entrada con su clave de idempotencia. Apartado siguiente.
  4. Límite de intentos. Tras 5 fallos, la entrada pasa a «necesita atención» y se muestra al usuario. Una cola que reintenta eternamente una operación imposible consume batería y nunca avisa.
  5. Visible. El usuario debe poder ver cuántos cambios están pendientes y por qué. Un indicador discreto: «3 cambios sin sincronizar».

El disparo del vaciado, con tres fuentes:

window.addEventListener('online', () => sincronizador.vaciar());
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') sincronizador.vaciar();
});
setInterval(() => { if (navigator.onLine) sincronizador.vaciar(); }, 60_000);

Cuidado con navigator.onLine: dice si hay interfaz de red, no si hay internet. Con una wifi de hotel sin autenticar, devuelve true y las peticiones fallan igual. Sirve como pista para no intentarlo cuando claramente no hay red, pero la verdad la da el fetch, no la propiedad.

  1. Idempotencia y reenvío seguro

Una operación es idempotente si ejecutarla varias veces produce el mismo resultado que ejecutarla una. Es la propiedad que hace que reenviar sea seguro, y sin ella la cola del apartado anterior es peligrosa.

Operación ¿Idempotente por naturaleza? Riesgo al reenviar
GET /tareas Ninguno
PUT /tareas/7 con el objeto completo Ninguno
DELETE /tareas/7 Sí (el segundo da 404, y da igual) Ninguno
POST /tareas No Tareas duplicadas
PATCH /tareas/7 { horas: +2 } (incremento) No Horas sumadas dos veces

El escenario del duplicado es este, y ocurre más de lo que parece:

1. El cliente envía POST /tareas
2. El servidor la crea correctamente
3. La respuesta se pierde (red caída justo entonces)
4. El cliente cree que falló y reencola
5. Al recuperar la red, reenvía → SEGUNDA TAREA IDÉNTICA

La solución: clave de idempotencia. El cliente genera un identificador único por operación —no por reintento— y lo envía en cada intento:

await pedirJson(`${base}/tareas`, {
  method: 'POST',
  headers: { 'Idempotency-Key': entrada.id },     // el mismo en los 5 reintentos
  body: JSON.stringify(entrada.cargaUtil)
});

El servidor guarda las claves ya procesadas y, si ve una repetida, devuelve el resultado original en lugar de crear otra vez. Es el mecanismo que usan las pasarelas de pago, por razones evidentes.

¿Y si el servidor no soporta claves de idempotencia? Con json-server u otra API que no controlas, hay dos mitigaciones parciales:

  1. Que el cliente genere el id (un UUID) en lugar de dejarlo al servidor. Entonces el POST es efectivamente un PUT sobre un identificador conocido, y el segundo envío sobrescribe en vez de duplicar. Rompe la R1 tal como estaba escrita, así que es una decisión que hay que anotar.
  2. Comprobar antes de reenviar: GET por un campo distintivo para ver si ya existe. Es más frágil (hay una condición de carrera entre la comprobación y la creación) pero mejor que nada.

Y una regla de diseño de API que conviene conocer: prefiere operaciones absolutas a incrementales. PATCH { horas: 14 } es idempotente; PATCH { horas: '+2' } no lo es. La primera forma elimina un problema entero en lugar de gestionarlo.

  1. Actualización optimista con reversión

Una actualización optimista aplica el cambio en la interfaz antes de que el servidor confirme, suponiendo que va a funcionar. Si falla, se revierte.

Enfoque Percepción Riesgo
Pesimista: esperar la respuesta 300–800 ms de espera en cada acción Ninguno, pero se siente lento
Optimista: aplicar ya, revertir si falla Instantáneo Hay que gestionar la reversión bien

La diferencia en sensación es enorme, y por eso las aplicaciones que se sienten rápidas lo hacen así. El patrón:

export async function marcarHecha(almacen, repo, id) {
  const antes = almacen.obtener().tareas;                 // 1 · guardar para revertir
  const optimista = antes.map((t) => t.id === id ? t.conEstado('hecha') : t);

  almacen.actualizar({ tareas: optimista });              // 2 · aplicar ya
  anunciar('Tarea marcada como hecha');

  try {
    const confirmada = await repo.actualizarTarea(id, { estado: 'hecha' });
    almacen.actualizar({                                   // 3 · sustituir por lo del servidor
      tareas: almacen.obtener().tareas.map((t) => t.id === id ? confirmada : t)
    });
  } catch (error) {
    almacen.actualizar({ tareas: antes, error: aErrorDeInterfaz(error) });   // 4 · revertir
    anunciar('No se ha podido guardar el cambio. Se ha deshecho.');
    throw error;
  }
}

Las cuatro reglas de la actualización optimista:

  1. Guarda el estado anterior completo antes de tocar nada. Es lo que permite revertir con exactitud.
  2. Sustituye por lo que devuelve el servidor, no dejes tu versión optimista. El servidor puede haber añadido campos —un updatedAt, un id real— o normalizado algo.
  3. La reversión debe ser visible y anunciada. Un cambio que se deshace en silencio hace que el usuario crea que guardó algo que no guardó. Es peor que no ser optimista.
  4. No seas optimista con todo. La tabla:
Operación ¿Optimista? Por qué
Cambiar de estado, marcar, reordenar Reversible, de bajo riesgo, muy frecuente
Editar un campo de texto Ídem
Crear una tarea Con cuidado Sí, pero con id temporal marcado como «pendiente»
Borrar No, mejor confirmar Difícil de revertir de forma convincente; asusta si reaparece
Pagar, enviar, cerrar definitivamente Nunca Las acciones irreversibles se confirman antes de mostrarse hechas

El id temporal al crear merece explicación. Si creas la tarea optimista con id: -1 y luego el servidor devuelve id: 42, todo lo que se hiciera con la tarea mientras tanto apuntaría a un id inexistente. Dos soluciones: generar el UUID en el cliente (apartado 13), o marcar visualmente la tarea como «guardando» e impedir acciones sobre ella hasta que se confirme. La primera es más limpia.

  1. Tiempo real sin duplicar cambios propios

Si tu proyecto incorpora tiempo real —el CanalTablero de 07-04, que extendía EventTarget con reconexión, retroceso y latido—, hay un problema muy concreto que aparece siempre:

El servidor te reenvía tu propio cambio, y lo aplicas dos veces.

El síntoma típico: creas una tarea, aparece, y medio segundo después aparece otra vez. O el contador de horas se dobla. Tres formas de resolverlo, de peor a mejor:

Solución Cómo Valoración
Ignorar mensajes durante N ms tras actuar Ventana temporal Frágil: depende de la latencia
Identificador de cliente Cada mensaje lleva origenId; se ignora si es el propio Simple y fiable
Reconciliación por id y versión Se aplica siempre, pero como sustitución idempotente La más robusta

La segunda es suficiente para casi todo:

// src/datos/tiempo-real.js
const MI_ID = crypto.randomUUID();      // uno por pestaña, en memoria

canal.addEventListener('tarea-actualizada', (evento) => {
  const { tarea, origenId } = evento.detail;
  if (origenId === MI_ID) return;                     // es mi propio eco
  almacen.actualizar({ tareas: fusionarPorId(almacen.obtener().tareas, tarea) });
  anunciar(`${tarea.titulo} ha sido actualizada por otra persona`);
});

Y fusionarPorId implementa la tercera de propina: sustituye por id si existe, añade si no, e ignora si la versión que llega es más antigua que la que tienes. Con esa condición, aplicar el mismo mensaje dos veces es inofensivo, y el orden de llegada deja de importar.

Tres reglas del tiempo real que evitan problemas:

  1. El canal se destruye al salir. El destruir() de 07-04 cierra el socket y quita las escuchas. Sin él, navegar entre pantallas abre conexiones que no se cierran.
  2. No confíes en el orden de llegada. Los mensajes pueden llegar desordenados. Por eso la fusión debe ser idempotente y basada en versión, no en «aplicar lo que llegue».
  3. Anuncia los cambios ajenos, no los propios. Que la interfaz cambie sola sin explicación es desconcertante, especialmente para quien usa lector de pantalla. aria-live="polite" con «Marta ha marcado Inventario de tintas como hecha».

  1. Seguridad y privacidad de lo que se guarda

Este apartado no es opcional y conviene leerlo entero.

16.1 Qué no debe estar nunca en el navegador

No guardes Por qué Qué hacer en su lugar
Claves de API y secretos Todo el JavaScript del cliente es público: se lee con Ver Código Fuente El servidor guarda la clave y expone un punto de acceso propio
Contraseñas, aunque estén «codificadas» Base64 no es cifrado; se descodifica en un segundo Nunca salen del servidor; se envían y se olvidan
Tokens de larga duración en localStorage Cualquier XSS los roba (apartado 16.2) Cookie HttpOnly + Secure + SameSite, o token corto en memoria
Datos personales sensibles Salud, ideología, biometría… categoría especial del RGPD No los trates; y si el producto los exige, con asesoramiento legal
Datos de terceros sin base legal No son tuyos Datos ficticios en desarrollo, siempre

El caso de los tokens merece detalle porque es el error más frecuente. Un token de sesión en localStorage es accesible desde cualquier JavaScript de la página. Si un atacante consigue ejecutar código —una dependencia comprometida, un XSS—, se lleva la sesión completa. Una cookie HttpOnly no es accesible desde JavaScript, así que ese vector desaparece. No es una diferencia teórica: es la diferencia entre un XSS molesto y un robo de cuentas.

16.2 XSS: por qué importa más cuando hay API

La lección 06-02 estableció la regla: textContent para datos, innerHTML solo para literales tuyos. Con datos que vienen de una API, el riesgo se multiplica, porque el contenido lo escribió otra persona.

// ❌ Si el título viene de una API y contiene <img src=x onerror="...">, se ejecuta
fila.innerHTML = `<h3>${tarea.titulo}</h3>`;

// ✅ El texto es texto
fila.querySelector('h3').textContent = tarea.titulo;

La regla completa:

Situación Uso correcto
Texto de datos textContent
Atributo de datos setAttribute con valor validado
URL de datos Validar el esquema: solo http: y https:
HTML enriquecido de usuario Sanear con una librería mantenida, nunca a mano
Estructura tuya, fija innerHTML con literales, o <template>

La validación de URL es la que se olvida: javascript:alert(1) en un campo de enlace se ejecuta al pulsar. Comprueba siempre el esquema antes de asignar un href.

Y en 11-05 añadirás la segunda capa: una Content-Security-Policy que impide ejecutar scripts en línea aunque se cuele uno.

16.3 Datos personales y RGPD

En cuanto tu aplicación guarda nombres, correos electrónicos, fotos o cualquier dato que identifique a una persona —incluso indirectamente—, estás tratando datos personales, y en la Unión Europea eso está regulado por el RGPD.

Lo que sí puedo decirte con seguridad, porque son principios del reglamento:

Principio Qué significa en tu proyecto
Minimización Guarda solo lo necesario. ¿Necesitas la fecha de nacimiento? Casi seguro que no
Limitación de finalidad Los datos recogidos para una cosa no se usan para otra
Limitación del plazo Define cuánto se conservan y bórralos después
Integridad y confidencialidad Cifrado en tránsito (HTTPS, 11-05) y control de acceso
Transparencia La persona debe saber qué guardas, por qué y durante cuánto
Derechos Acceso, rectificación, supresión y portabilidad de sus datos

Y lo que no puedo darte: asesoramiento legal.

Advertencia explícita. Un producto real que trate datos de personas reales exige una revisión legal y de cumplimiento normativo: base legal del tratamiento, información al interesado, registro de actividades, encargados de tratamiento (cualquier servicio externo que use), transferencias internacionales, y en algunos casos evaluación de impacto. Nada de esto se resuelve con código y nada de esto es materia de un curso de JavaScript. Mientras aprendes, usa datos ficticios —como los de Taller Nómada— y no publiques un producto con datos reales sin haberlo consultado con quien corresponda.

Lo que sí puedes hacer desde ya, y es buena ingeniería además de buena práctica legal:

  • Documenta en docs/modelo-datos.md qué campos son personales y para qué.
  • Implementa la exportación de todos los datos de una persona (te sirve además como copia de seguridad).
  • Implementa el borrado de verdad, no un activo: false disfrazado. Ojo: el borrado real choca con el historial inmutable de R14. La solución habitual es anonimizar las entradas de historial —sustituir el nombre por «Usuario eliminado»— conservando la integridad del registro. Es una decisión que merece su ADR.
  • No registres datos personales en el sistema de errores, como ya se dijo en 11-02.

  1. Fallos de sincronización y su tratamiento

Esta tabla es el resumen operativo de toda la lección. Tenla a mano mientras implementas: cada fila es un fallo que va a ocurrir.

# Fallo Síntoma Causa Tratamiento
1 Sin conexión al guardar La acción no llega al servidor Red caída Encolar + aplicar en local + indicador de pendientes
2 Servidor caído (5xx) Error tras esperar Fallo temporal Reintentar con retroceso; tras N, encolar
3 Petición colgada Indicador eterno Sin tiempo máximo AbortController con tiempo máximo (apartado 8)
4 Respuesta obsoleta Datos de una búsqueda anterior Carrera entre peticiones Cancelar la anterior antes de lanzar
5 Conflicto de edición (409) El cambio se rechaza Otra persona editó antes Mostrar ambas versiones y dejar elegir
6 Duplicado al reenviar Dos tareas idénticas POST no idempotente Clave de idempotencia o id generado en cliente
7 Eco del tiempo real El cambio propio aparece dos veces El servidor reenvía a todos origenId + fusión por id y versión
8 Cuota llena QuotaExceededError Historial crecido Podar, avisar, y si no cabe, mensaje accionable
9 Datos de versión futura No se puede abrir Otro dispositivo actualizó antes Rechazar con mensaje claro; no adivinar
10 Migración que corrompe Datos raros tras actualizar Migración con un fallo Respaldo previo + validación contra el dominio
11 JSON corrupto SyntaxError al arrancar Escritura interrumpida try/catch al parsear + arrancar desde el respaldo
12 Reloj del cliente desajustado Orden de cambios equivocado Hora local errónea Usar siempre la marca de tiempo del servidor
13 Cola atascada Nada se sincroniza nunca Una operación imposible bloquea el orden Límite de intentos + entrada «necesita atención»
14 Dos pestañas del mismo usuario Se pisan los datos locales Ambas escriben en la misma clave storage event o BroadcastChannel para coordinar

El caso 14 es el que más sorprende a quien lo encuentra por primera vez. Dos pestañas abiertas con la misma aplicación escriben en el mismo localStorage sin saberlo. El evento storage avisa a las otras pestañas cuando una escribe:

window.addEventListener('storage', (e) => {
  if (e.key !== CLAVE) return;
  almacen.actualizar({ ...leerDocumento(), avisoDeOtraPestaña: true });
});

Con cinco líneas, las dos pestañas se mantienen coherentes. Sin ellas, la última que guarda pisa el trabajo de la otra.

Errores Comunes y Consejos

No versionar el formato guardado. Es el error que más datos destruye. Sin version en el documento, el día que añadas un campo obligatorio tendrás dos formatos indistinguibles conviviendo, y ninguna forma limpia de saber cuál es cuál. Poner version: 1 desde el primer día cuesta una línea.

Migrar sin copia de seguridad. Una migración con un fallo destruye datos de forma irreversible. Guardar el original bajo otra clave antes de tocarlo cuesta una línea y convierte el desastre en un incidente.

Migraciones que no se prueban con datos reales. Probar la migración con un objeto que has escrito a mano para la prueba demuestra poco: los datos reales tienen campos inesperados, null donde no los esperabas y estructuras de versiones intermedias. Guarda documentos reales como ficheros de prueba.

Suponer que fetch lanza con un error HTTP. No lo hace. Sin if (!respuesta.ok), un 500 se procesa como éxito y el fallo aparece tres capas más arriba con un mensaje incomprensible.

No poner tiempo máximo a las peticiones. fetch espera indefinidamente. Con una red mala, el indicador de carga se queda girando para siempre y el usuario no tiene salida.

Reintentar lo que no se debe. Un 400 dará 400 las tres veces. Reintentarlo solo triplica la espera antes del mismo error. Solo se reintenta lo reintentable: red, 408, 429 y 5xx.

No cancelar peticiones obsoletas. Produce el fallo más desconcertante de todos: resultados de una búsqueda anterior que pisan a los buenos, de forma intermitente y dependiente de la latencia. Prácticamente imposible de reproducir a propósito si no sabes que existe.

POST en la cola sin idempotencia. Reenviar una creación tras un fallo de red duplica el registro. Es el fallo que produce «tengo la misma tarea tres veces» y que el usuario nunca sabe explicar.

Revertir en silencio. Si una actualización optimista falla y deshaces sin avisar, el usuario cree que guardó algo que no se guardó. Es peor que no haber sido optimista.

Guardar tokens en localStorage. Cualquier XSS se lleva la sesión completa. Cookie HttpOnly o token corto en memoria.

Meter datos personales en el registro de errores. Es un problema legal, no solo de estilo, y se agrava en cuanto ese registro se envía a un servicio externo (11-05). Registra identificadores y nombres de campo, nunca valores.

Consejo · Mide el tamaño de tus datos antes de elegir almacenamiento. Treinta segundos de JSON.stringify con datos realistas evitan tanto la ingenuidad de localStorage con 20 MB como la sobreingeniería de IndexedDB con 200 kB.

Consejo · Prueba con la red estrangulada y desconectada. El panel Network de DevTools tiene modo Offline y perfiles lentos. La mitad de los fallos de esta lección solo aparecen ahí. Hazlo parte de tu rutina de cierre de incremento.

Consejo · Implementa exportar e importar pronto. Un botón que descarga todos los datos en JSON te sirve de copia de seguridad manual, de herramienta de depuración, de forma de mover datos entre dispositivos sin servidor, y de cumplimiento del derecho de portabilidad. Cuatro beneficios por una tarde de trabajo.

Consejo · Deja un panel de diagnóstico oculto. Una pantalla con la versión del formato, el tamaño ocupado, las entradas de la cola y los últimos errores registrados. Te ahorrará horas cuando algo falle en un dispositivo que no es el tuyo.

Ejercicios

Estos ejercicios son el hito H4 de tu proyecto: persistencia con migraciones, la capa de API con sus estados, y la cola offline.

Ejercicio 1 — Persistencia con migraciones probadas.

  1. Implementa RepositorioLocal cumpliendo el contrato completo, con el documento envuelto (version, guardadoEn, aplicacion, datos).
  2. Ejecuta la batería de pruebas de contrato contra él y contra RepositorioMemoria; las dos deben pasar exactamente las mismas pruebas.
  3. Implementa migraciones.js con al menos tres migraciones reales de tu proyecto (no inventadas: cambios que de verdad hayas hecho o vayas a hacer al modelo).
  4. Guarda documentos de prueba reales de cada versión antigua en test/datos/fixtures/, incluyendo uno vacío y uno con un dato inesperado.
  5. Escribe las seis pruebas de migración del apartado 6.1: no pierde datos, mapea correctamente, tolera datos desconocidos, produce documentos válidos para el dominio, es idempotente, y rechaza versiones futuras.
  6. Implementa el respaldo previo, el manejo de QuotaExceededError con poda del historial y aviso, y la detección de almacenamiento no disponible con degradación a memoria.
  7. Implementa exportar e importar todos los datos en JSON, con validación al importar.

Ejercicio 2 — La capa de API con sus estados.

Monta un servidor de pruebas local (json-server o equivalente) y:

  1. Implementa http.js con ErrorDeApi (con status, codigo, reintentable), pedirJson con tiempo máximo y conReintentos con retroceso exponencial y variación aleatoria.
  2. Implementa RepositorioApi cumpliendo el contrato y pasando la misma batería de pruebas que los otros dos.
  3. Implementa los cinco estados en la interfaz: inactivo, cargando (con esqueleto, aria-busy y retraso de 200 ms), éxito, error (con acción de reintento) y cancelado.
  4. Implementa la cancelación de la búsqueda con AbortController, combinada con debounce, y demuestra con una prueba que una respuesta obsoleta no pisa a la buena.
  5. Implementa el manejo del 409 con la pantalla de resolución que muestra ambas versiones.
  6. Escribe pruebas con fetch simulado (08-04) para: 200, 400 con cuerpo útil, 500 con reintento exitoso al segundo intento, tiempo agotado, y cancelación.

Ejercicio 3 — La cola offline y la actualización optimista.

  1. Implementa ColaDeCambios persistente, con id de idempotencia, contador de intentos, orden estricto y límite de reintentos.
  2. Implementa RepositorioSincronizado que cumple el mismo contrato combinando local + API + cola.
  3. Implementa el vaciado disparado por online, visibilitychange y temporizador, con la advertencia de navigator.onLine.
  4. Implementa la actualización optimista con reversión en al menos tres operaciones, con anuncio en caso de reversión, y respeta la tabla de qué no debe ser optimista.
  5. Implementa el indicador de «N cambios sin sincronizar» accesible y una vista de la cola con las entradas que necesitan atención.
  6. Implementa la coordinación entre pestañas con el evento storage o BroadcastChannel.
  7. Escribe pruebas con temporizadores falsos para: encolar sin red, vaciar al recuperarla, orden preservado, no duplicar al reenviar, y reversión al fallar.
  8. Demuéstralo a mano: con DevTools en modo Offline, haz cinco cambios, vuelve a conectar y comprueba que los cinco llegan en orden y sin duplicados. Graba un GIF: te servirá para la demo de 11-06.

Soluciones

Criterios de aceptación del ejercicio 1 — Persistencia

# Criterio Cómo se comprueba
1 Los datos sobreviven a la recarga Crear una tarea, F5, sigue ahí
2 El documento lleva version Inspeccionar localStorage en DevTools
3 Un documento v1 se abre sin perder nada Pegar un v1 real y comprobar el número de tareas
4 Se hace respaldo antes de migrar Existe orbita:tablero:respaldo:v1 tras migrar
5 Migrar es idempotente migrar(migrar(x)) es igual a migrar(x)
6 Lo migrado es válido para el dominio Tarea.desdeJSON no lanza con ningún elemento
7 Una versión futura se rechaza con mensaje Poner version: 99 y ver el aviso
8 Un JSON corrupto no impide arrancar Escribir {{{ en la clave; la aplicación arranca y avisa
9 Cuota llena poda y avisa Llenar localStorage a propósito y observar
10 Sin almacenamiento, funciona en memoria y avisa Simular el fallo de setItem
11 Contrato: memoria y local pasan lo mismo La misma función de pruebas, dos llamadas
12 Exportar produce un JSON reimportable Exportar, borrar todo, importar, comparar

Rúbrica del ejercicio 1 (21 puntos)

Dimensión 0 1 2 3
Versionado Sin versión Campo presente Documento envuelto completo Además con aplicacion y comprobación
Migraciones Ninguna Una, sin probar ≥ 3 probadas Con datos reales y validación contra el dominio
Robustez Sin try/catch Captura genérica Cuota, corrupción y no disponible tratados Además con degradación y mensajes accionables
Respaldo No hay Manual Automático antes de migrar Además recuperable desde la interfaz
Contrato Solo una implementación Dos sin pruebas comunes Pruebas comunes Idénticas y en verde para las tres
Exportar/importar No Exporta Exporta e importa Con validación y mensajes de error por fila
Pruebas < 5 5–9 ≥ 10 incluidas las 6 de migración Además con casos límite reales

Umbral: 15/21, con obligatoriamente 3 en «Migraciones». Es la parte que destruye datos si está mal.

Criterios de aceptación del ejercicio 2 — API

# Criterio Cómo se comprueba
1 Un 500 se trata como error Simular y comprobar que no se procesa como éxito
2 Un 400 muestra el mensaje del servidor El cuerpo del error llega a la interfaz
3 Una petición colgada se corta Retrasar 30 s; a los 8 s hay error de tiempo agotado
4 Un 5xx se reintenta, un 400 no Contar las llamadas en el fetch simulado
5 El retroceso es exponencial con ruido Comprobar los tiempos con temporizadores falsos
6 El indicador tarda 200 ms en aparecer Respuesta de 80 ms: no parpadea
7 El disparador se deshabilita al cargar Doble clic rápido produce una petición
8 La respuesta obsoleta no pisa Prueba con dos respuestas desordenadas
9 Cancelar no muestra error Ninguna alerta al abortar
10 El 409 ofrece elegir versión Pantalla con ambos valores y dos acciones
11 Contrato: la API pasa las mismas pruebas Batería común en verde
12 Ningún secreto en el cliente Buscar claves en dist/ tras compilar: cero resultados

Criterios de aceptación del ejercicio 3 — Cola y optimismo

# Criterio Cómo se comprueba
1 Sin red, la acción se aplica y se encola Modo Offline + inspeccionar la cola
2 La cola sobrevive al cierre de la pestaña Cerrar y reabrir; las entradas siguen
3 Al recuperar la red se vacía sola Volver a Online sin recargar
4 El orden se preserva Crear y luego actualizar: llegan en ese orden
5 No hay duplicados Cortar la red tras enviar y antes de recibir; al reenviar, una sola tarea
6 Tras N intentos se marca «necesita atención» Forzar un 400 permanente
7 La reversión se ve y se anuncia Forzar el fallo; el cambio se deshace con aviso
8 Borrar no es optimista Se confirma antes
9 El indicador de pendientes es accesible Texto, no solo icono; anunciado al cambiar
10 Dos pestañas se mantienen coherentes Cambiar en una, ver el efecto en la otra
11 El eco de tiempo real no duplica Si lo implementas: crear y observar una sola tarjeta
12 La demostración manual funciona El GIF de 5 cambios offline

Rúbrica global del hito H4 (24 puntos)

Dimensión Peso Qué se evalúa
Persistencia y migraciones 6 Versionado, cadena de migraciones, respaldo, pruebas con datos reales
Robustez de red 5 Errores tipados, tiempo máximo, reintentos selectivos, cancelación
Estados de interfaz 4 Los cinco estados, sin parpadeo, sin doble envío, accesibles
Cola y sin conexión 5 Persistencia, orden, idempotencia, límite de intentos, visibilidad
Conflictos 2 Detección y resolución con participación del usuario
Seguridad y privacidad 2 Sin secretos, sin innerHTML con datos, registro sin datos personales

Umbral: 17/24. Un 0 en «Seguridad y privacidad» invalida el hito independientemente del resto: un producto que filtra una clave de API o que ejecuta el HTML que le mandan no está terminado, por bien que funcione todo lo demás.

Autoevaluación del hito H4:

Pregunta Sí / No
¿Podría cambiar de localStorage a IndexedDB tocando solo un fichero?
¿Sé qué pasa si un usuario abre datos de una versión antigua? ¿Y de una futura?
¿He probado mi aplicación con la red desconectada?
¿Hay alguna clave, token o secreto en mi código de cliente?
¿Uso innerHTML con algún dato que no haya escrito yo?
¿Mi registro de errores contiene algún dato personal?
¿Puedo exportar todos mis datos y volver a importarlos?

Las preguntas 4, 5 y 6 son las que hay que responder «no». Si alguna es «sí», arréglala antes de pasar a la siguiente lección: en 11-05 esa aplicación estará publicada en internet.

Conclusión

Has convertido una aplicación que perdía todo al recargar en un producto cuyos datos sobreviven, se comparten y se recuperan.

Sabes elegir el almacenamiento con un árbol de decisión que empieza por una pregunta honesta —«¿cuánto ocupa en el peor caso?»— cuya respuesta correcta al principio es «no lo sé» y cuya salida no es adivinar sino medir con datos realistas: treinta segundos de JSON.stringify que evitan tanto la ingenuidad como la sobreingeniería. Conoces los cinco límites de localStorage —cuota, sincronía, solo texto, todo o nada, sin consultas—, sabes que el peligroso es la cuota porque falla en el dispositivo de otra persona, y sabes tratarlo podando lo prescindible, avisando de la poda y dando un mensaje accionable cuando ya no cabe. Y conoces IndexedDB a nivel práctico: transacciones que se cierran solas si metes un await ajeno, esquema que solo cambia en onupgradeneeded, onblocked para las dos pestañas, y una envoltura con promesas que se escribe una vez.

Tienes lo que separa un proyecto serio de uno de juguete: el formato guardado versionado en un sobre con version, guardadoEn y aplicacion, y una cadena de migraciones numeradas que son funciones puras, se aplican en secuencia, rechazan las versiones futuras con un mensaje claro, usan valores por defecto conservadores y prefieren perder una asignación antes que impedir abrir la aplicación. Con las seis pruebas que las respaldan —no pierde datos, mapea bien, tolera lo desconocido, produce documentos válidos para el dominio, es idempotente y rechaza el futuro— y con documentos reales guardados como ficheros de prueba, porque los datos de verdad tienen campos que tú no habrías escrito a mano. Y con el respaldo previo, que cuesta una línea y convierte un desastre irreversible en un incidente investigable.

Has cobrado la inversión del contrato del repositorio: la misma batería de pruebas ejecutada contra memoria, local y API demuestra que son intercambiables, y cambiar de almacenamiento es cambiar una línea en main.js. Es la inyección de dependencias de 08-04 mirada desde el otro lado: lo que hizo posible probar con dobles hace posible cambiar de tecnología.

Sabes hablar con una API con los siete puntos de pedirJson: comprobar respuesta.ok porque fetch no lanza con un 500, poner tiempo máximo porque no lo tiene, propagar la señal externa, leer el cuerpo del error porque ahí está la información útil, envolver el json() porque un 500 puede devolver HTML, tratar el 204, y unificar todo en ErrorDeApi. Con reintentos solo de lo reintentable, retroceso exponencial y variación aleatoria. Y sabes que una operación asíncrona tiene cinco estados y no dos, que el indicador debe retrasarse 200 ms para no parpadear, que el disparador se deshabilita mientras carga, y que un esqueleto reserva el espacio que un indicador giratorio no reserva.

Sabes cancelar, que es lo que evita el fallo más desconcertante de todos: la respuesta obsoleta que pisa a la buena. Con las tres reglas —una cancelación no es un error, se cancela también al desmontar, y debounce y cancelación se combinan porque resuelven cosas distintas.

Sabes decidir quién gana cuando dos personas editan: la tabla de cuatro estrategias, la recomendación de versión con 409, y sobre todo qué hacer con ese 409 — ni sobrescribir ni descartar en silencio, sino mostrar ambas versiones y dejar elegir, que es el detalle que demuestra que has pensado en el caso incómodo. Con la advertencia sobre los relojes del cliente, que no son fiables nunca.

Tienes la cola de cambios pendientes con sus cinco reglas —orden estricto, persistente, con clave de idempotencia, con límite de intentos y visible—, sus tres disparadores de vaciado, y la advertencia sobre navigator.onLine, que dice si hay interfaz de red y no si hay internet. Sabes qué es la idempotencia, por qué POST es el único verbo peligroso, cómo la clave de idempotencia evita el duplicado que produce «tengo la misma tarea tres veces», y por qué conviene preferir operaciones absolutas a incrementales.

Sabes hacer que la aplicación parezca instantánea con actualizaciones optimistas: guardar el estado anterior, aplicar ya, sustituir por lo que devuelve el servidor y revertir con aviso si falla — porque una reversión silenciosa es peor que no haber sido optimista. Y sabes con qué no ser optimista: borrar, pagar, y todo lo irreversible. Y sabes integrar el tiempo real sin duplicar tus propios cambios, con origenId y una fusión por id y versión que hace inofensivo aplicar el mismo mensaje dos veces.

Y sabes lo que no puede estar en el navegador: claves, contraseñas, tokens de larga duración en localStorage que cualquier XSS se lleva, y datos personales sin base legal. Sabes que textContent para datos y innerHTML solo para lo tuyo importa mucho más cuando el contenido viene de una API, que las URL hay que validar por esquema, y que los principios del RGPD —minimización, finalidad, plazo, transparencia, derechos— se traducen en decisiones de modelado concretas. Con la advertencia sin rodeos: un producto real con datos de personas reales exige revisión legal y de cumplimiento, y eso no se resuelve con código.

Cierras con la tabla de los catorce fallos de sincronización que van a ocurrir, incluido el de las dos pestañas del mismo usuario que se pisan y que se resuelve con cinco líneas y el evento storage.

El hito H4 está cerrado: persistencia con migraciones, capa de API con sus estados y cola offline. Tu proyecto ya hace todo lo que promete. La pregunta que queda es la incómoda: ¿cómo sabes que sigue haciéndolo mañana? Porque ahora hay migraciones que pueden corromper datos, carreras que solo aparecen con mala red y vistas que pueden retener memoria al navegar — tres fallos que no se ven mirando la pantalla. Convertir la calidad en algo automático y verificable, y depurar esos tres fallos concretos con método, es Pruebas y Depuración del Proyecto.

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