wandres.dev
APPLICATION · Almacenamiento y manifiesto

Almacenamiento web: el panel, sus dos ámbitos y sus tres límites

El inspector de localStorage y sessionStorage, en qué se diferencian sus ámbitos, los tres problemas estructurales de esta API, y el evento de sincronización entre pestañas.

⏱ 16 min

El almacenamiento web es la API de persistencia más usada de la plataforma y la peor diseñada de todas: es síncrona, solo guarda cadenas y tiene un límite pequeño que nadie comprueba hasta que lo revienta. El panel de aplicación la inspecciona y la edita en vivo, que es exactamente lo que hace falta para depurar la clase de bug que produce: estado persistente que sobrevive a recargas y que hace que la aplicación se comporte de forma distinta para una persona y no para otra.

🎯 Al terminar esta lección sabrás
  • Inspeccionar y editar los dos almacenes desde el panel.
  • Distinguir el ámbito de cada uno y cuándo elegir cada cual.
  • Enumerar los tres límites estructurales y sus consecuencias.
  • Sincronizar estado entre pestañas con el evento correspondiente.

El panel y lo que permite hacer

En la sección de almacenamiento del panel de aplicación, cada origen tiene sus dos almacenes con una tabla de claves y valores. La tabla no es solo de lectura: se puede añadir una entrada, editar un valor, borrar una o vaciar el almacén entero, y todo tiene efecto inmediato sobre la página.

Esa capacidad de edición es la razón principal de abrir este panel. Los tres usos que más veces resuelven algo:

Reproducir el estado de un usuario. Un bug que solo le ocurre a una persona muchas veces está en un valor guardado. Copiar ese valor a tu propio almacén reproduce el bug en tu máquina.

Provocar estados difíciles de alcanzar. Poner a mano la marca que indica que el tutorial ya se vio, o que el usuario rechazó una función, ahorra recorrer flujos enteros.

Comprobar el comportamiento ante datos corruptos. Cambiar un valor a algo que la aplicación no espera y ver si la ruptura es elegante o catastrófica. Casi siempre es catastrófica, y esa es la lección.

💡
Tip

Cuando un bug desaparece en una ventana de incógnito, el almacenamiento es el primer sospechoso. La comprobación ordenada es vaciar los dos almacenes en la ventana normal y recargar: si el bug desaparece, está en un valor guardado, y solo queda averiguar cuál comparando el contenido antes y después. Ese diagnóstico cuesta un minuto y evita horas de mirar código.

Los dos ámbitos

El almacenamiento local persiste indefinidamente y se comparte entre todas las pestañas del mismo origen. Sobrevive a cerrar el navegador. Su ámbito es el origen: protocolo, dominio y puerto. Dos subdominios distintos tienen almacenes distintos y no se ven entre sí.

El almacenamiento de sesión vive mientras viva la pestaña. Cada pestaña tiene el suyo, incluso para el mismo origen, y una pestaña abierta duplicando otra hereda una copia al crearse. Se pierde al cerrarla.

La elección entre los dos casi siempre se hace mal, y el criterio correcto es una pregunta única: ¿este dato tiene sentido en dos pestañas a la vez? El identificador de un borrador que el usuario está editando en esta pestaña, no: si abre dos pestañas y las dos comparten el mismo borrador, se pisan. La preferencia de tema, sí: debería ser igual en todas.

Un caso que ilustra la diferencia mejor que ninguna explicación: guardar en almacenamiento local el paso actual de un formulario por pasos es un bug esperando a ocurrir, porque el usuario que abre una segunda pestaña ve su primera pestaña saltar de paso. En almacenamiento de sesión, cada pestaña avanza a su ritmo.

Los tres límites

Es síncrono, y por tanto bloquea el hilo principal. Cada lectura y cada escritura detienen todo lo demás. Con valores pequeños es despreciable; con un objeto serializado de varios cientos de kilobytes, cada escritura es un bloqueo perceptible. Y como es síncrono, no se puede mover a un worker: el almacenamiento web no existe en un worker. Esa es la razón principal para no usarlo como almacén de datos de la aplicación.

Solo guarda cadenas. Cualquier otra cosa hay que serializarla, y ese ciclo de serialización y deserialización tiene dos costes: el de tiempo, proporcional al tamaño, y el de fidelidad, porque el formato de intercambio habitual pierde tipos. Una fecha guardada vuelve como cadena, un mapa vuelve como objeto vacío, un valor indefinido desaparece de un objeto, y los valores infinitos y no numéricos vuelven como nulo.

El límite de tamaño es pequeño y su incumplimiento lanza una excepción. El orden de magnitud son unos pocos megabytes por origen, y al superarlo la escritura lanza una excepción de cuota. El fallo más habitual no es superar el límite sino no capturar esa excepción, con lo que un error de almacenamiento revienta un flujo entero.

// Envoltorio seguro: tipos preservados, errores capturados y tamaño vigilado
const almacen = {
  guardar(clave, valor) {
    try {
      const sobre = { v: valor, t: Date.now() };
      const texto = JSON.stringify(sobre, (k, val) => {
        if (val instanceof Date) return { __tipo: 'fecha', v: val.toISOString() };
        if (val instanceof Map) return { __tipo: 'mapa', v: [...val] };
        if (val instanceof Set) return { __tipo: 'conjunto', v: [...val] };
        return val;
      });
      localStorage.setItem(clave, texto);
      return true;
    } catch (e) {
      if (e.name === 'QuotaExceededError') {
        console.warn('Cuota de almacenamiento agotada al guardar', clave);
        return false;
      }
      throw e;
    }
  },

  leer(clave, porDefecto = null) {
    const texto = localStorage.getItem(clave);
    if (texto === null) return porDefecto;
    try {
      const sobre = JSON.parse(texto, (k, val) => {
        if (val && typeof val === 'object' && val.__tipo === 'fecha') return new Date(val.v);
        if (val && typeof val === 'object' && val.__tipo === 'mapa') return new Map(val.v);
        if (val && typeof val === 'object' && val.__tipo === 'conjunto') return new Set(val.v);
        return val;
      });
      return sobre.v;
    } catch {
      console.warn('Valor corrupto en', clave, '- se descarta');
      localStorage.removeItem(clave);
      return porDefecto;
    }
  },

  informe() {
    let total = 0;
    const filas = [];
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      const bytes = (k.length + localStorage.getItem(k).length) * 2;
      total += bytes;
      filas.push({ clave: k, kb: +(bytes / 1024).toFixed(2) });
    }
    console.table(filas.sort((a, b) => b.kb - a.kb));
    console.log('Total aproximado:', (total / 1024).toFixed(1), 'KB');
    return total;
  }
};

// Ejemplo ejecutable
almacen.guardar('prueba', { creado: new Date(), etiquetas: new Set(['a', 'b']) });
console.log(almacen.leer('prueba'));
almacen.informe();

La captura del error de cuota y la recuperación ante un valor corrupto son las dos partes que casi nunca se escriben y las dos que evitan que un problema de almacenamiento tumbe la aplicación. El cálculo aproximado del tamaño multiplica por dos porque las cadenas se almacenan en unidades de dos bytes.

Sincronizar entre pestañas

El almacenamiento local dispara un evento en las demás pestañas del mismo origen cuando cambia. No en la que lo cambió: esa ya lo sabe. Es el mecanismo más simple de comunicación entre pestañas y sirve para casos concretos y valiosos.

// Sincronizacion de estado entre pestañas del mismo origen
window.addEventListener('storage', (e) => {
  if (e.key === null) {
    console.log('Otra pestaña vacio el almacen entero');
    return;
  }
  console.log('Cambio externo en', e.key);
  console.log('  antes:', e.oldValue);
  console.log('  ahora:', e.newValue);
  console.log('  origen del cambio:', e.url);

  if (e.key === 'sesion' && e.newValue === null) {
    console.warn('Sesion cerrada en otra pestaña: hay que reaccionar aqui tambien');
  }
});

// Para comunicacion entre pestañas sin persistencia, hay una API mejor
const canal = new BroadcastChannel('mi-aplicacion');
canal.onmessage = (e) => console.log('Mensaje de otra pestaña:', e.data);
canal.postMessage({ tipo: 'ping', cuando: Date.now() });

El caso de uso más importante de ese evento es el cierre de sesión: si el usuario cierra sesión en una pestaña, las demás deberían enterarse en lugar de seguir mostrando una interfaz autenticada que fallará en la siguiente petición.

Para comunicación entre pestañas que no necesite persistir, el canal de difusión es mejor herramienta: es asíncrono, admite estructuras complejas sin serializar a mano, y no ensucia el almacenamiento.

Todo lo que guardes aquí es un contrato con versiones de tu propio código que ya no existen

La consecuencia menos evidente de usar almacenamiento persistente es que estás escribiendo datos que va a leer una versión futura de tu aplicación que todavía no has escrito, y esa asimetría produce una clase de bug que es difícil de reproducir precisamente porque el desarrollador siempre tiene el almacén en el formato actual. Un usuario que no abre la aplicación durante seis meses vuelve con un objeto guardado por una versión que ya no existe, con campos que se renombraron, con una estructura que cambió, con valores que ahora significan otra cosa. Y la forma habitual de leerlo —deserializar y usarlo directamente— asume que el dato tiene la forma que el código actual espera. Cuando no la tiene, el resultado no es un error limpio sino un comportamiento incoherente: un campo indefinido que se propaga, una comprobación que da falso cuando debería dar verdadero, una pantalla que se queda vacía. Y como el estado está persistido, el bug sobrevive a la recarga: el usuario no puede salir de él, y el remedio universal de “prueba a recargar” no funciona por primera vez en su vida. Las tres prácticas que cierran esta categoría son baratas y hay que ponerlas desde la primera línea que se guarda. Una: versiona el formato, guardando un número de versión junto al dato y escribiendo migraciones explícitas de cada versión a la siguiente; si no hay migración posible, descarta y empieza de cero, que siempre es mejor que interpretar mal. Dos: valida al leer, nunca al escribir. El dato de entrada viene del disco de otra persona, no de tu código, y merece la misma desconfianza que una respuesta de red: si no tiene la forma esperada, se descarta. Tres: haz que la aplicación funcione con el almacén vacío, siempre, sin excepciones. Es la propiedad que convierte cualquier corrupción en un incidente recuperable, porque permite que el remedio sea vaciar y seguir, y es la que hace que la comprobación de la ventana de incógnito siga siendo un diagnóstico válido en lugar de un camino a otro error distinto.