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
- Elegir el almacenamiento según el caso
- Cuándo
localStoragese queda corto - IndexedDB a nivel práctico
- Una envoltura mínima con promesas
- Versionar el formato guardado
- Migraciones numeradas y sus pruebas
- El repositorio como frontera: una interfaz, tres implementaciones
- Hablar con la API:
fetchrobusto - Los estados de una operación asíncrona
- Reintentos, cancelación y
AbortController - Sincronización: quién gana cuando dos editan a la vez
- La cola de cambios pendientes
- Idempotencia y reenvío seguro
- Actualización optimista con reversión
- Tiempo real sin duplicar cambios propios
- Seguridad y privacidad de lo que se guarda
- Fallos de sincronización y su tratamiento
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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 | Sí | Sí | Solo la sesión | Estado de interfaz que no debe sobrevivir a la recarga |
sessionStorage |
~5 MB | Sí | No (solo texto) | Hasta cerrar la pestaña | Datos de un flujo en curso: un formulario largo a medias |
localStorage |
~5–10 MB | Sí | 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 | Sí | No | Configurable | Solo identificación de sesión con el servidor |
| API remota | Ilimitada | No | Sí | 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.
- Cuándo
localStorage se queda corto
localStorage se queda cortoTiene 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: falsele 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.
- 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:
- Todo ocurre dentro de una transacción, que puede ser
readonlyoreadwrite. - Las transacciones se cierran solas en cuanto el bucle de eventos queda sin trabajo pendiente para ellas. Si haces un
awaitde algo ajeno en medio, la transacción muere. Es la fuente número uno de errores desconcertantes. - 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. - 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 condesdeJSON.
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».
- 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.
- 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.
- 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.
- 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.
- Hablar con la API:
fetch robusto
fetch robustoSi 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:
fetchno lanza con 404 ni 500. Solo lanza si la red falla. Sin la comprobación derespuesta.ok, un 500 se procesaría como si fuera un éxito con cuerpo raro. Es el error número uno confetch.- Tiempo máximo explícito.
fetchno tiene tiempo de espera por defecto: una petición puede quedarse colgada indefinidamente y con ella tu indicador de carga. - La señal externa se propaga. Permite que quien llama cancele (apartado 10) además del temporizador interno.
- 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.
leerCuerpoSeguroenvuelve eljson()entry/catch, porque un error de servidor puede devolver HTML en lugar de JSON, y entonces eljson()lanza y tapa el error original.- 204 devuelve
null. Sin contenido significa sin contenido; llamar ajson()sobre un cuerpo vacío lanza. - Todo error acaba siendo
ErrorDeApi. La capa superior maneja un tipo, no cinco. Eso simplifica muchísimo elcatchde 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.
- 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.
- Reintentos, cancelación y
AbortController
AbortControllerLa 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:
- 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.
- 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. - Cancelación y
debouncese combinan. Eldebouncede 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.
- 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 distintaY 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.
- 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:
- 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.
- Persistente, no en memoria. Si vive en una variable, se pierde al cerrar la pestaña — justo cuando más falta hace.
- Cada entrada con su clave de idempotencia. Apartado siguiente.
- 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.
- 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.
- 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 |
Sí | Ninguno |
PUT /tareas/7 con el objeto completo |
Sí | 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ÉNTICALa 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:
- Que el cliente genere el
id(un UUID) en lugar de dejarlo al servidor. Entonces elPOSTes efectivamente unPUTsobre 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. - Comprobar antes de reenviar:
GETpor 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.
- 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:
- Guarda el estado anterior completo antes de tocar nada. Es lo que permite revertir con exactitud.
- Sustituye por lo que devuelve el servidor, no dejes tu versión optimista. El servidor puede haber añadido campos —un
updatedAt, unidreal— o normalizado algo. - 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.
- No seas optimista con todo. La tabla:
| Operación | ¿Optimista? | Por qué |
|---|---|---|
| Cambiar de estado, marcar, reordenar | Sí | Reversible, de bajo riesgo, muy frecuente |
| Editar un campo de texto | Sí | Í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.
- 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:
- 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. - 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».
- 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».
- 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.mdqué 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: falsedisfrazado. 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.
- 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.
- Implementa
RepositorioLocalcumpliendo el contrato completo, con el documento envuelto (version,guardadoEn,aplicacion,datos). - Ejecuta la batería de pruebas de contrato contra él y contra
RepositorioMemoria; las dos deben pasar exactamente las mismas pruebas. - Implementa
migraciones.jscon al menos tres migraciones reales de tu proyecto (no inventadas: cambios que de verdad hayas hecho o vayas a hacer al modelo). - Guarda documentos de prueba reales de cada versión antigua en
test/datos/fixtures/, incluyendo uno vacío y uno con un dato inesperado. - 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.
- Implementa el respaldo previo, el manejo de
QuotaExceededErrorcon poda del historial y aviso, y la detección de almacenamiento no disponible con degradación a memoria. - 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:
- Implementa
http.jsconErrorDeApi(constatus,codigo,reintentable),pedirJsoncon tiempo máximo yconReintentoscon retroceso exponencial y variación aleatoria. - Implementa
RepositorioApicumpliendo el contrato y pasando la misma batería de pruebas que los otros dos. - Implementa los cinco estados en la interfaz: inactivo, cargando (con esqueleto,
aria-busyy retraso de 200 ms), éxito, error (con acción de reintento) y cancelado. - Implementa la cancelación de la búsqueda con
AbortController, combinada condebounce, y demuestra con una prueba que una respuesta obsoleta no pisa a la buena. - Implementa el manejo del 409 con la pantalla de resolución que muestra ambas versiones.
- Escribe pruebas con
fetchsimulado (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.
- Implementa
ColaDeCambiospersistente, conidde idempotencia, contador de intentos, orden estricto y límite de reintentos. - Implementa
RepositorioSincronizadoque cumple el mismo contrato combinando local + API + cola. - Implementa el vaciado disparado por
online,visibilitychangey temporizador, con la advertencia denavigator.onLine. - 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.
- Implementa el indicador de «N cambios sin sincronizar» accesible y una vista de la cola con las entradas que necesitan atención.
- Implementa la coordinación entre pestañas con el evento
storageoBroadcastChannel. - Escribe pruebas con temporizadores falsos para: encolar sin red, vaciar al recuperarla, orden preservado, no duplicar al reenviar, y reversión al fallar.
- 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
