El modelo de cuota: dinámico, por origen y nunca fijo
La cuota de almacenamiento no es una constante que puedas asumir, sino un presupuesto que el navegador asigna al origen entero y calcula de forma dinámica según el espacio libre del disco de cada usuario.
Casi todo el que empieza a guardar datos en el navegador arrastra una cifra en la cabeza: cinco megas, cincuenta, algún número redondo aprendido en un tutorial de hace una década. Esa cifra es un fósil. El almacenamiento del navegador dejó hace tiempo de repartirse en cuotas fijas por API y pasó a funcionar como un presupuesto: un espacio que se asigna al origen entero, que el navegador calcula de forma dinámica mirando cuánto queda libre en el disco, y que puede encoger mientras tu aplicación está abierta porque el usuario acaba de descargarse una película. Programar contra un número que crees conocer es, en este terreno, la manera más eficaz de escribir una aplicación que funciona impecablemente en tu portátil y falla en el del cliente.
- Entender que la cuota se asigna al origen completo y no a cada API de almacenamiento por separado.
- Ver por qué el navegador la calcula de forma dinámica a partir del espacio libre del disco.
- Reconocer la única cifra realmente fija del almacenamiento web: los 5 MB de
localStorage. - Escribir código que trate el límite como desconocido y el fallo por cuota como un camino previsto.
El origen es la unidad de contabilidad
La primera corrección que hay que hacer al modelo mental heredado es la unidad de medida. No tienes una cuota para IndexedDB y otra para la caché del service worker: tienes una sola para el origen, entendido como la terna de esquema, host y puerto. Todo lo que ese origen escriba —bases de datos de IndexedDB, entradas de Cache Storage, ficheros en OPFS, claves de localStorage— sale del mismo bolsillo. La consecuencia práctica es inmediata y casi siempre se descubre tarde: puedes pasarte una tarde optimizando el tamaño de tus registros mientras una caché olvidada de una versión anterior del service worker retiene cientos de megabytes que nadie va a leer jamás.
// Todo esto consume del MISMO presupuesto: el del origen
const peticion = indexedDB.open("documentos", 1);
const cache = await caches.open("assets-v3");
const raiz = await navigator.storage.getDirectory(); // OPFS
// Un subdominio distinto es otro origen: otro presupuesto y otros datos
// https://app.ejemplo.com y https://cdn.ejemplo.com no comparten nada
Auditar ese bolsillo común es más fácil de lo que parece y debería formar parte de la higiene de cualquier aplicación que guarde datos en serio. El navegador expone la enumeración de bases de datos y de cachés, de modo que en unas pocas líneas puedes recorrer todo lo que tu origen ha dejado escrito a lo largo de sus versiones, incluido lo que ya nadie abre.
// Inventario del origen: que hay realmente ahi dentro
const bases = await indexedDB.databases(); // nombre y version de cada base
const nombresDeCache = await caches.keys(); // incluye versiones antiguas
console.table(bases);
for (const nombre of nombresDeCache) {
const c = await caches.open(nombre);
console.log(nombre, (await c.keys()).length, "entradas");
}
El resultado de ese inventario suele ser incómodo la primera vez que se ejecuta sobre una aplicación con algo de recorrido: aparecen bases de datos de prototipos abandonados, cachés con nombres versionados que un service worker antiguo creó y jamás limpió, y almacenes cuya finalidad ya no recuerda nadie del equipo. Todo eso consume del mismo presupuesto que tus datos vivos y adelanta el momento en que el usuario se topa con el límite.
Que el origen sea la unidad tiene además una lectura arquitectónica que conviene anticipar. Repartir una aplicación entre subdominios no multiplica tu presupuesto de forma gratuita: crea espacios de almacenamiento incomunicados, con sus propias bases de datos, sus propias claves y su propio desalojo, y obliga a coordinarlos con mensajería entre ventanas. Y a la inversa, alojar tu aplicación en un dominio compartido con otras significa que la contabilidad del origen puede no ser tan tuya como crees. La decisión de dónde vive tu aplicación es, también, una decisión de almacenamiento.
Dinámica, no constante
La segunda idea cuesta más de interiorizar porque contradice el hábito de pensar en límites. El presupuesto no es un valor grabado en el navegador: es una función del estado del disco. El cálculo concreto varía entre implementaciones y ninguna se compromete públicamente con una fórmula estable, pero el principio es común y está documentado: cuanto más espacio libre haya, más capacidad se concede. Chrome, por ejemplo, puede llegar a emplear hasta el 80% del espacio en disco para el almacenamiento web; cuánto de ese conjunto acaba disponible para un origen concreto vuelve a ser una decisión dinámica del propio navegador.
flowchart LR D[espacio libre en disco] --> N[el navegador calcula la capacidad total] N --> P[presupuesto disponible para el origen] P --> I[IndexedDB] P --> C[Cache Storage] P --> O[OPFS] style D fill:#89b4fa,color:#11111b style N fill:#cba6f7,color:#11111b style P fill:#a6e3a1,color:#11111b
De aquí se deduce algo que rara vez se dice en voz alta: tu límite puede bajar sin que tú escribas nada. Un usuario que llena el disco con vídeos reduce el espacio libre, y con él la capacidad que el navegador está dispuesto a conceder. La aplicación que ayer cabía holgadamente hoy puede recibir un fallo de cuota en la primera transacción del día, sin que su volumen de datos haya crecido un solo byte. El límite no es una propiedad de tu aplicación: es una propiedad del entorno, y el entorno cambia sin avisarte.
Cuando una transacción de IndexedDB aborta con un QuotaExceededError, el impulso natural es buscar la fuga en tu propio código. A veces la hay, pero muchas veces no: el error describe el estado del sistema, no un defecto de tu programa. Tratarlo como una excepción imprevista —dejar que suba, que rompa el flujo y que el usuario vea una pantalla en blanco— es el verdadero fallo. Es un resultado posible de cualquier escritura, tan legítimo como el éxito, y merece la misma dignidad en tu diseño: un camino explícito, con su mensaje, su purga de lo prescindible y su reintento.
Conviene añadir que ese presupuesto tampoco se negocia solo contigo. La capacidad que el navegador dedica al almacenamiento web se reparte entre todos los orígenes que la reclaman en la misma máquina, de modo que tu margen depende también de lo que hayan acumulado sitios que no controlas ni conoces. No hay manera de consultarlo, de influir en él ni de reservarlo por adelantado. Es, una vez más, una propiedad del entorno y no un parámetro de tu aplicación.
Hay un corolario incómodo para los equipos que miden. Como la cuota depende de una máquina concreta en un momento concreto, no existe la prueba de laboratorio definitiva. Un portátil de desarrollo con medio terabyte libre concederá presupuestos generosos y jamás reproducirá el problema del usuario que trabaja con el disco al límite. La única forma seria de validar el comportamiento bajo presión es provocarlo: llenar deliberadamente el almacenamiento en un entorno controlado y comprobar que la aplicación degrada con elegancia en lugar de romperse.
Lo que sí está fijado: localStorage
En medio de tanta elasticidad hay una excepción, y es precisamente la que originó el mito. localStorage está limitado a 5 MB de cadenas UTF-16, un techo fijo que no crece con el disco ni negocia con nadie. Dos detalles de esa frase merecen atención. El primero es cadenas: localStorage solo guarda texto, de modo que cualquier objeto debe pasar por una serialización, con su coste de tiempo y su inflación de tamaño. El segundo es UTF-16: cada unidad de código ocupa dos bytes, así que ese techo de 5 MB equivale a bastante menos texto del que la cifra sugiere.
// localStorage guarda UTF-16: cada unidad de codigo ocupa 2 bytes
const bytesAprox = (clave, valor) => (clave.length + valor.length) * 2;
const serializado = JSON.stringify(estado);
if (bytesAprox("estado", serializado) > 4_000_000) {
// margen de seguridad bajo el techo: este dato no es para localStorage
await guardarEnIndexedDB("estado", estado);
}
A esa restricción de tamaño se suma otra que pesa más en la práctica: localStorage es síncrono. Cada lectura y cada escritura bloquean el hilo principal, de modo que un dato grande no solo arriesga el techo, sino que congela la interfaz mientras se serializa y se escribe. La conclusión de ingeniería es sencilla y no admite muchos matices: localStorage es el sitio correcto para un puñado de preferencias diminutas —el tema, el idioma, un identificador de sesión— y el sitio equivocado para cualquier cosa que se parezca a los datos del usuario.
Migrar desde localStorage hacia un almacén asíncrono es, por todo lo anterior, una de las inversiones con mejor retorno en una aplicación local-first, y tiene una propiedad que la abarata mucho: puede hacerse de forma incremental y perezosa. En el arranque lees la clave antigua, la vuelcas en IndexedDB, la eliminas del almacén síncrono y a partir de ese momento consultas únicamente el nuevo. El coste son unas líneas y una versión de transición; el beneficio es dejar de estar sujeto a un techo que no crece y de bloquear el hilo principal en cada guardado.
El daño mayor de esa cifra no es su tamaño, sino la creencia que instaló: que el navegador es un almacén de juguete. Es falso desde hace años. Bajo el mismo origen conviven IndexedDB, Cache Storage y OPFS, cuyo presupuesto lo gestiona el navegador de forma dinámica y puede alcanzar órdenes de magnitud muy superiores en una máquina con espacio libre. Si tu diseño evita guardar datos localmente porque recuerdas los 5 MB, estás renunciando a una arquitectura local-first entera por una restricción que solo se aplica a la API más antigua y limitada del conjunto.
Programar sin conocer el número
Aceptado que el límite es desconocido, variable y ajeno, la disciplina que se deriva es clara: nunca escribas una constante que pretenda representarlo. Ni MAX_STORAGE, ni un contador propio de bytes que creas exacto, ni una comprobación previa que decida si algo cabrá. Ese estilo defensivo produce falsos negativos —rechazas escrituras que habrían cabido— y falsos positivos, porque entre tu comprobación y tu escritura el sistema puede haber cambiado. La única verdad la da el intento.
El patrón correcto es escribir y capturar. La operación se intenta, y si el sistema no puede satisfacerla la respuesta llega como una excepción con un nombre reconocible que puedes distinguir de cualquier otro fallo. A partir de ahí se abre una cascada de recuperación ordenada: liberar primero lo prescindible, reintentar después, e involucrar al usuario solo si el reintento vuelve a fallar, con un mensaje que nombre una acción concreta.
try {
await guardarDocumento(doc);
} catch (error) {
if (error.name !== "QuotaExceededError") throw error;
await purgarCachesPrescindibles(); // 1. libera lo regenerable
try {
await guardarDocumento(doc); // 2. reintenta una sola vez
} catch {
await ofrecerExportacionDeEmergencia(doc); // 3. no pierdas el trabajo
}
}
Fíjate en el orden y sobre todo en la última rama, porque es la que separa una degradación digna de una pérdida silenciosa: si ni siquiera tras purgar cabe el documento, lo que no se puede hacer es descartarlo. El trabajo del usuario tiene que salir por alguna puerta —una descarga, un envío al servidor, el portapapeles en el peor de los casos— antes de que la aplicación admita que no puede guardarlo.
No codifiques un límite
Cualquier constante que represente la cuota estará equivocada en la mitad de las máquinas y obsoleta en la otra mitad al cabo de un mes de uso.
Mide en lugar de adivinar
El StorageManager ofrece una estimación de uso y cuota, con sus reservas; la lección cuatro de este nivel la exprime y define qué se puede concluir de ella.
El fallo es un camino, no una excepción
Envuelve cada escritura significativa y decide qué hacer al fallar: purgar lo prescindible, avisar, degradar o exportar antes de perder nada.
El presupuesto es del origen
Auditar tu consumo significa mirar todas las APIs a la vez, no solo la que estás usando hoy. Las cachés viejas cuentan igual que tus datos.
El error de fondo al pensar en cuotas es tratarlas como una restricción de recurso comparable a la memoria de un proceso —un número que averiguas una vez, contra el que planificas y del que te mantienes lejos— cuando en realidad son un contrato de disponibilidad con un tercero que ni te consulta ni te avisa. Ese cambio de categoría lo reorganiza todo. Si la cuota fuera un número, la ingeniería correcta consistiría en medirlo y respetarlo; como es una función del espacio libre del disco de un desconocido, evaluada por el navegador en cada momento y aplicada al origen entero, la ingeniería correcta consiste en no depender de su valor. Fíjate en la asimetría que esto crea con el desarrollo de servidor, donde el disco es tuyo, lo dimensionas y una alerta te avisa al 80%: aquí no hay alerta, no hay dimensionado y el 80% de hoy puede ser el 100% de mañana porque alguien descargó una serie. La consecuencia arquitectónica es que el volumen de datos que tu aplicación aspira a guardar localmente deja de ser una decisión de producto y pasa a ser una hipótesis que cada máquina confirma o refuta por su cuenta. Y hay una segunda consecuencia, más sutil, que el resto del nivel desarrollará: si el presupuesto es dinámico y compartido por todo el origen, entonces el navegador necesita alguna política para recuperar espacio cuando escasea, y esa política —el desalojo— convierte una cuestión de tamaño en una cuestión de permanencia. La pregunta interesante nunca fue cuánto cabe. Siempre fue durante cuánto tiempo se queda.
- Abre una aplicación web que uses a diario y localiza en las herramientas del navegador el desglose de almacenamiento por origen: anota cuánto ocupa cada API por separado.
- Comprueba qué proporción de ese total corresponde a cachés antiguas o a datos que la aplicación ya no lee, y estima cuánto presupuesto se está desperdiciando.
- Busca en tu propio código cualquier constante, comentario o supuesto que represente un límite de almacenamiento, y sustitúyelo por la pregunta correcta: qué hacer cuando la escritura falle.
- Calcula con la fórmula de dos bytes por unidad de código cuánto texto real te cabe de verdad en
localStorage, y contrástalo con lo que estás guardando ahí hoy. - Provoca el fallo a propósito: escribe en bucle hasta agotar el presupuesto en un perfil de prueba y observa qué ve el usuario cuando llega el
QuotaExceededError.