wandres.dev
EL MAPA DEL ALMACENAMIENTO · cookies, local y session

Qué elegir para qué: la tabla de decisión honesta

Una tabla de decisión para preferencias, borradores, caché de red y bases de datos completas, con sus casos límite y el coste asimétrico de equivocarse de mecanismo.

⏱ 18 min

Las cuatro lecciones anteriores describen el territorio; esta lo convierte en una decisión. La forma honesta de hacerlo no es una tabla que reparta seis mecanismos entre seis casos de uso, porque los casos reales caen casi siempre en los bordes: la preferencia que empieza a crecer, el borrador que el usuario no quiere perder, la caché que en realidad quería ser una consulta. Lo que hace útil una guía de elección no es su casilla central sino su tratamiento de los límites, y sobre todo su honestidad respecto a algo que casi nunca se explicita: el coste de equivocarse es profundamente asimétrico, y esa asimetría, no la elegancia técnica, es lo que debe gobernar la decisión por defecto.

🎯 Al terminar esta lección sabrás
  • Resolver los cuatro casos canónicos: preferencias, borradores, caché de red y base de datos.
  • Aplicar el árbol de decisión y saber en qué punto la respuesta deja de ser automática.
  • Razonar los casos límite donde la tabla se rompe y hay que decidir con criterio propio.
  • Evaluar la reversibilidad de cada elección antes de tomarla, no después.

Los cuatro casos canónicos

Cuatro casos concentran la inmensa mayoría de lo que una aplicación necesita persistir. Vale la pena resolverlos de forma explícita, con su condición de validez, antes de mirar los bordes.

Preferencias del usuario. Dato diminuto, acotado por diseño, que a menudo debe leerse antes del primer fotograma para no pintar con el valor equivocado. Es el caso donde la sincronía de localStorage es una virtud y su cuota basta con holgura. La única condición es que la lista de preferencias esté cerrada: en cuanto pasa a ser una preferencia por documento o por proyecto, deja de estar acotada y cambia de casilla.

Borradores y trabajo del usuario. Aquí la propiedad que manda no es el tamaño sino el valor de lo que se pierde. Un borrador es contenido que la persona ha creado y que no existe en ningún otro sitio, lo cual exige escrituras que no puedan quedar a medias y un almacén que crezca sin un techo que sorprenda. IndexedDB es la respuesta: transacciones, cuota dinámica gestionada por el navegador, y estructura para guardar el documento sin serializarlo entero en cada pulsación.

Caché de recursos de red. El mecanismo diseñado exactamente para esto es la Cache API, y es uno de los pocos casos del inventario en que existe una respuesta única y sin matices.

La Cache API, cuya clave es un Request y cuyo valor es un Response completo con sus cabeceras. Guardar respuestas HTTP en IndexedDB es reimplementar a mano, y peor, algo que la plataforma ya resuelve; guardarlas en localStorage añade además el impuesto de base64 sobre cualquier cuerpo binario.

Base de datos completa de la aplicación. Cuando el cliente debe responder consultas sobre volúmenes que no caben en memoria, la elección real está entre IndexedDB con una biblioteca encima y un motor SQL compilado a WebAssembly sobre OPFS, ejecutándose dentro de un Worker. Es la configuración hacia la que converge todo el local-first serio, y el resto del track la desarrolla.

Conviene decir explícitamente algo que la presentación por casos oculta: estos cuatro mecanismos no compiten, coexisten. Una aplicación madura los usa todos a la vez, cada uno en su función, y esa combinación no es un síntoma de indecisión arquitectónica sino la consecuencia natural de que cada capa resuelva un problema distinto. Una configuración típica y sana consiste en una cookie HttpOnly para la sesión, dos claves en localStorage para el arranque, la Cache API para el casco de la aplicación, e IndexedDB u OPFS para los datos. Quien intenta unificarlo todo en un solo mecanismo acaba usando el equivocado para tres de las cuatro funciones.

flowchart TD
A[Que dato quiero guardar] --> B{lo necesito antes del primer fotograma}
B -->|si| C[localStorage con tamano acotado]
B -->|no| D{es trafico de red}
D -->|si| E[Cache API]
D -->|no| F{necesito consultar partes sin cargar el todo}
F -->|no| G{muere con la pestana}
G -->|si| H[sessionStorage]
G -->|no| C
F -->|si| I{necesito consultas complejas o volumen grande}
I -->|no| J[IndexedDB]
I -->|si| K[motor SQL sobre OPFS en un Worker]
style C fill:#f9e2af,color:#11111b
style J fill:#a6e3a1,color:#11111b
style K fill:#89b4fa,color:#11111b

La tabla de decisión

Caso Mecanismo Condición que debe cumplirse Si se rompe
Tema, idioma, bandera de arranque localStorage Lista cerrada y diminuta Pasa a IndexedDB
Paso de asistente o carrito por pestaña sessionStorage Debe morir con la pestaña Pasa a IndexedDB
Identidad de sesión Cookie HttpOnly | Secure El servidor debe verla No hay alternativa
Borradores y documentos del usuario IndexedDB Escrituras atómicas y crecimiento libre Motor sobre OPFS
Respuestas y recursos de red Cache API Expresable como Request | Response Pasa a IndexedDB
Consultas y volumen grande SQLite sobre OPFS Ejecutable dentro de un Worker Vuelve al servidor

Obsérvese que la última fila de la columna de ruptura no propone otro mecanismo del navegador, sino abandonar el cliente. Esa salida tiene que existir en cualquier guía honesta.

La columna que importa de verdad es la tercera. Una tabla que solo empareja casos con mecanismos envejece mal, porque los casos reales nunca llegan en la forma canónica en que aparecen escritos; una tabla que enuncia la condición bajo la cual la elección es válida sigue sirviendo cuando el caso muta, porque te dice exactamente en qué momento debes volver a decidir. Léela, por tanto, como un conjunto de contratos con condición de ruptura, no como un diccionario.

ℹ️
La cuota no es una constante que puedas consultar y confiar

localStorage tiene un tope propio de 5 MB de datos en cadenas UTF-16 por origen. IndexedDB, Cache API y OPFS comparten una cuota que el navegador gestiona de forma dinámica: Chrome puede llegar al 80% del espacio en disco, pero los datos empiezan en modo best-effort y pueden ser desalojados si el sistema anda escaso de espacio. navigator.storage.estimate() te da una fotografía del momento, no una promesa; diseñar contra ese número como si fuera fijo es construir sobre arena.

Los casos límite que la tabla no resuelve

Las casillas anteriores cubren el centro de la distribución. Los problemas reales viven en los bordes, y ahí la tabla deja de decidir por ti. Los cuatro que siguen son los que más veces he visto resolverse mal, y los cuatro comparten un rasgo: en todos ellos la intuición apunta con seguridad en la dirección equivocada.

🌱

La preferencia que crece

Empieza como tema e idioma y termina como configuración por documento. El síntoma es una clave con un identificador dentro del nombre: eso ya es una tabla, no una preferencia.

✍️

El borrador de la pestaña

sessionStorage parece encajar hasta que recuerdas que el momento en que el usuario más necesita su borrador es justamente cuando cierra la pestaña sin querer.

🔀

La caché que quería ser consulta

Si necesitas filtrar, ordenar o unir sobre lo cacheado, no era una caché de red: era una base de datos que llegaba por HTTP.

⚖️

La base que no debería ser local

Si los permisos cambian con frecuencia, si hay obligación de autoridad central o si el volumen no cabe en un dispositivo, la respuesta correcta es no bajar los datos.

El caso de la preferencia que crece tiene una dinámica reconocible y vale la pena describirla, porque es la forma en que casi todas las aplicaciones acaban con datos en el mecanismo equivocado. Nadie decide nunca guardar una tabla en localStorage; lo que ocurre es que se guarda una preferencia legítima, luego una segunda, luego una que depende del documento abierto, y en algún punto de esa secuencia —sin que ninguna decisión individual fuera errónea— el conjunto dejó de ser una lista cerrada. El error no está en ninguna de las escrituras, está en la ausencia de un criterio explícito que dijera dónde se cruzaba la frontera.

El caso del borrador merece detenerse porque es el que más a menudo se resuelve mal. La intuición dice que un borrador pertenece a la sesión de edición y por tanto a sessionStorage; la experiencia dice lo contrario. El escenario que justifica guardar un borrador es exactamente aquel en el que sessionStorage no sobrevive: la pestaña cerrada por accidente, el navegador que se cierra, el sistema que reinicia. Un mecanismo cuya vida coincide con la del riesgo del que quieres protegerte no ofrece protección alguna.

// Antipatron: el borrador vive justo lo que dura el riesgo del que protege
sessionStorage.setItem("borrador", texto);

// Correcto: escritura atomica en un almacen que sobrevive al cierre
const tx = db.transaction("borradores", "readwrite");
await tx.objectStore("borradores").put({ id, texto, actualizado: Date.now() });
await tx.done; // o falla entera, o no falla
⚠️
Una clave con un identificador dentro es una tabla disfrazada

Cuando en Web Storage aparecen claves de la forma documento-482-zoom o proyecto-17-columnas, el dato ya no es una preferencia: es una relación entre entidades, con cardinalidad no acotada, almacenada en un mecanismo sin índices, sin transacciones y con una cuota fija que se agotará el día menos oportuno. Esa forma de clave es la señal más fiable de que ha llegado el momento de migrar, y llega siempre antes de que aparezca el primer error de cuota.

El coste de equivocarse

Aquí está la parte que las guías suelen omitir. Los errores de elección no cuestan lo mismo, y su coste no se mide en rendimiento sino en reversibilidad. Un error reversible es una decisión; un error irreversible es una restricción con la que vivirás durante años.

Equivocarse por exceso —usar IndexedDB para cuatro preferencias— es barato: pagas complejidad innecesaria y algo de código de arranque, y revertirlo es una tarde de trabajo. Es un error visible desde el primer día, que además se manifiesta en tu propia máquina y no en la del usuario, lo cual lo convierte casi en un no problema. Equivocarse por defecto —usar localStorage para datos que crecen— es caro por tres motivos que se acumulan. El fallo llega tarde, cuando ya hay usuarios con datos acumulados. Llega en el dispositivo del usuario más comprometido, que es el que más ha guardado. Y sobre todo, corregirlo no es cambiar una línea: los datos ya están en el mecanismo equivocado, en dispositivos que no controlas, y hay que escribir una migración que se ejecute en cada uno de ellos, tolerar estados a medio migrar, y mantener el lector antiguo indefinidamente porque siempre habrá alguien que vuelva después de un año.

La defensa contra ese coste es sorprendentemente barata si se paga por adelantado: versiona el formato desde la primera línea. Un número de versión guardado junto a los datos, y una función que sepa leer el formato anterior y escribir el nuevo, convierten una migración distribuida en un trámite de veinte líneas. Escribirla cuando ya hay usuarios afectados cuesta un orden de magnitud más, porque entonces hay que inferir el formato antiguo a partir de lo que encuentres.

// Escrito desde el primer dia, la migracion futura cuesta veinte lineas
const VERSION = 2;
const crudo = JSON.parse(localStorage.getItem("ajustes") ?? "null");
const ajustes =
  crudo === null ? porDefecto()
  : crudo.version === VERSION ? crudo
  : migrar(crudo); // el lector antiguo se mantiene, no se borra

El tercer error, el más silencioso, es confiar la única copia del trabajo del usuario a un almacén desalojable. Es silencioso porque no produce ningún error que puedas registrar: simplemente, un día el usuario abre la aplicación y su trabajo no está. Los datos de IndexedDB, Cache API y OPFS nacen en modo best-effort, y ese modo significa exactamente que el navegador puede borrarlos bajo presión de disco sin preguntar. Solicitar persistencia con navigator.storage.persist() mejora la situación, pero es una petición que puede ser denegada; una arquitectura que trate el almacenamiento local como indestructible está apostando el trabajo de sus usuarios contra una heurística que no controla.

// Antes de tratar el almacen local como fuente unica de verdad
const yaPersistido = await navigator.storage.persisted();
const concedido = yaPersistido || (await navigator.storage.persist());
if (!concedido) {
  // sigues siendo desalojable: exporta, sincroniza o avisa. No lo ignores
}
Elige por reversibilidad, no por elegancia: el error que no puedes deshacer es el único que importa

Si de este nivel entero hubiera que retener un solo criterio operativo, no sería ninguna correspondencia entre casos y mecanismos, porque esas correspondencias envejecen y sus bordes son discutibles; sería este: evalúa cada elección de almacenamiento por lo que costaría deshacerla, no por lo bien que resuelve el problema de hoy. La razón es que el almacenamiento del navegador tiene una propiedad que casi ninguna otra decisión técnica comparte, y es que sus consecuencias no viven en tu repositorio sino en dispositivos ajenos que no puedes tocar. Un componente mal diseñado se reescribe en una tarde y el despliegue siguiente lo corrige para todo el mundo a la vez; un dato mal ubicado sigue estando mal ubicado en el portátil de alguien que no abre tu aplicación desde hace ocho meses, y el día que la abra ejecutará tu código nuevo sobre su estado antiguo. Por eso la pregunta cuál es el mecanismo óptimo es menos importante que la pregunta qué tendría que hacer si me equivoco, y por eso la asimetría manda: sobredimensionar cuesta complejidad, que es un recurso renovable, mientras que infradimensionar cuesta una migración distribuida sobre dispositivos que no controlas, que no lo es. De ahí se derivan tres disciplinas concretas que valen más que cualquier tabla. La primera: escribe la migración antes de necesitarla, aunque sea trivial, porque un almacén con número de versión y un camino de lectura del formato anterior convierte un desastre en un trámite. La segunda: acota por diseño lo que no quieres que crezca, con un límite explícito y un fallo ruidoso al superarlo, porque el crecimiento silencioso es exactamente el mecanismo por el que una preferencia se convierte en una base de datos sin que nadie tome la decisión de que lo sea. Y la tercera, la que separa una aplicación local-first seria de una que solo lo parece: trata el almacenamiento local como una réplica, no como un original, porque en cuanto asumes que puede desaparecer —desalojado, corrupto, en un dispositivo que se pierde— empiezas a construir exportación, sincronización y recuperación desde el primer día, que es justamente la arquitectura que el resto de este track desarrolla. La elección del mecanismo importa; lo que importa más es no haber apostado nada irreversible a que la elección fue correcta.

⚔️ Audita tus elecciones por reversibilidad
  1. Inventaria todo lo que tu aplicación persiste y clasifícalo con el árbol de decisión. Marca cada dato que esté en un mecanismo distinto al que le corresponde.
  2. Busca claves de Web Storage que contengan un identificador dentro del nombre y estima cuántas entradas habrá cuando tu usuario más activo lleve dos años usando la aplicación.
  3. Por cada dato mal ubicado, escribe el plan de migración completo: cómo detectas el formato antiguo, qué haces con un estado a medio migrar y cuánto tiempo mantendrás el lector antiguo.
  4. Comprueba si tu origen tiene almacenamiento persistente concedido y decide, explícitamente, qué pierde el usuario si el navegador desaloja sus datos esta noche.
  5. Elige un dato que hoy guardes localmente y argumenta si debería estar ahí o si en realidad pertenece al servidor. La respuesta honesta a veces es que local-first no era la arquitectura correcta para ese dato.