wandres.dev
OPFS I · el sistema de archivos del origen

Los límites: lo que ser privado te quita

Qué no te da OPFS: no es el disco del usuario, no se comparte entre orígenes, no es visible desde fuera y no es permanente, con las consecuencias que eso tiene para una aplicación local-first.

⏱ 15 min

Todo lo que hace atractivo a OPFS —que aparezca sin permiso, que nadie más lo vea, que el navegador lo gestione por ti— es la misma propiedad mirada desde ángulos distintos, y esa propiedad tiene una cara oscura que conviene ver antes de construir encima. Un almacén que el usuario no puede inspeccionar, ni copiar, ni abrir con otro programa, ni encontrar cuando desinstala el navegador, no es propiedad del dato: es custodia. Y la custodia es exactamente lo que el software local-first prometía terminar.

🎯 Al terminar esta lección sabrás
  • Delimitar qué no es OPFS: ni el disco del usuario, ni un espacio compartido, ni algo visible desde fuera.
  • Entender la cuota, el desalojo y qué cambia realmente pedir persistencia.
  • Conocer la partición por sitio de nivel superior y por qué el mismo origen puede tener árboles distintos.
  • Asumir la consecuencia de diseño: sin un camino de exportación, no hay propiedad.

Lo que no es

Conviene enunciar las cuatro negaciones de forma explícita, porque casi todos los errores de diseño con OPFS consisten en asumir en silencio lo contrario de alguna de ellas.

💾

No es el disco del usuario

No puedes escribir en su carpeta de documentos ni leer lo que ya tenía. Para eso está la otra puerta, con su gesto, su diálogo y su permiso revocable. OPFS solo contiene lo que tú mismo pusiste.

🕵️

No es visible desde fuera

No hay ruta que enseñar, ni carpeta que abrir en el explorador, ni fichero que otro programa pueda leer. Ninguna copia de seguridad del sistema lo recogerá, porque para el sistema no es más que datos internos del navegador.

🚧

No se comparte entre orígenes

Ni entre subdominios, ni entre http y https, ni entre puertos distintos. Y tampoco entre navegadores del mismo equipo: cada uno tiene el suyo, sin puentes.

No es permanente por defecto

Vive dentro de la cuota y bajo las políticas de borrado. El usuario puede llevárselo entero limpiando datos de navegación, y el navegador puede desalojarlo cuando el disco aprieta.

La primera negación es la que más se olvida en el diseño de producto, porque el usuario tiene un modelo mental de fichero que no se corresponde con nada de esto. Cuando alguien dice que su documento está guardado en su ordenador, quiere decir que puede encontrarlo, copiarlo a un disco externo y abrirlo dentro de diez años. Un fichero en OPFS no cumple ninguna de las tres cosas.

Cuota, desalojo y qué cambia pedir persistencia

El espacio disponible no es fijo ni conocido: se deriva del disco libre y se reporta de forma deliberadamente aproximada para que no sirva como huella identificativa. Lo consultas junto con el consumo:

const { usage, quota } = await navigator.storage.estimate();
const porcentaje = (usage / quota) * 100;

await navigator.storage.persisted();   // ¿ya soy persistente?
await navigator.storage.persist();     // pedir la promoción

Los dos números que devuelve esa llamada engañan si se leen con ingenuidad. La cuota no es tuya: es un techo compartido con el resto del almacenamiento del origen —IndexedDB, cachés, cookies— y se recalcula según el disco libre del sistema, así que puede encoger entre dos ejecuciones sin que hayas escrito nada. Y llega redondeada a propósito, con relleno añadido, para que no sirva como huella identificativa del equipo.

Superar la cuota no devuelve un aviso amable: la escritura falla con QuotaExceededError en mitad de la operación, y como el camino asíncrono solo publica al cerrar, lo normal es que el fichero quede como estaba. Toda escritura grande necesita su bloque de captura y una estrategia de liberación.

async function escribirConMargen(dirHandle, nombre, datos) {
  const { usage, quota } = await navigator.storage.estimate();
  if (usage + datos.size > quota * 0.9) {
    await liberarEspacio(datos.size);      // compactar, podar, borrar caché
  }
  const h = await dirHandle.getFileHandle(nombre, { create: true });
  const w = await h.createWritable();
  try {
    await w.write(datos);
    await w.close();
  } catch (e) {
    await w.abort();                       // el fichero anterior sobrevive
    if (e.name === 'QuotaExceededError') throw new SinEspacio(datos.size);
    throw e;
  }
}

Reservar un margen no es paranoia: una compactación necesita espacio para el resultado antes de poder liberar el original, así que un almacén que solo falla cuando ya está lleno es un almacén que no puede recuperarse. Deja siempre sitio para tu propia limpieza.

flowchart TD
A[El disco del sistema se llena] --> B[El navegador busca origenes que desalojar]
B --> C[Almacenamiento mejor esfuerzo]
B --> D[Almacenamiento persistente]
C --> E[Se borra entero y sin aviso]
D --> F[Se respeta salvo peticion del usuario]
E --> G[Tu aplicacion arranca vacia la proxima vez]

Pedir persistencia cambia la categoría del almacenamiento de mejor esfuerzo a persistente, y con ello deja de ser candidato al desalojo automático. Pero la concesión no la decides tú: unos motores la otorgan por heurística según el compromiso del usuario con el sitio —instalado como aplicación, marcado como favorito, visitado con frecuencia—, otros preguntan, y otros pueden denegarla sin explicación. Trátalo como una mejora probabilística, nunca como una garantía.

⚠️
El plazo de inactividad de algunos navegadores

Hay motores que aplican límites temporales al almacenamiento escrito por scripts: si el usuario no vuelve al sitio en un plazo de días, todo lo que guardaste puede desaparecer, incluido OPFS. Para una aplicación local-first que aspira a ser el hogar de los datos de alguien durante años, ese comportamiento no es un caso extremo: es el caso central que tu diseño tiene que sobrevivir.

La partición: el mismo origen no siempre es el mismo árbol

El aislamiento por origen ya no es toda la historia. Los navegadores modernos particionan el almacenamiento también por el sitio de nivel superior en el que se está navegando, para impedir que un mismo tercero incrustado en muchos sitios los correlacione. La consecuencia práctica es que tu propio origen puede tener varios árboles disjuntos según dónde se ejecute:

flowchart TD
A[tuapp.com abierta como pagina principal] --> B[Arbol 1]
C[tuapp.com dentro de un iframe en otrositio.com] --> D[Arbol 2]
E[tuapp.com dentro de un iframe en tercero.com] --> F[Arbol 3]
B --> G[Tres arboles disjuntos y sin puentes]
D --> G
F --> G

Un widget incrustado que escribe en OPFS no encontrará esos datos cuando el usuario abra tu aplicación directamente, y viceversa. Si tu producto se distribuye como componente empotrable, esto no es un matiz: reorganiza la arquitectura entera, porque el estado local deja de ser único y pasa a ser uno por contexto de incrustación.

Añade a eso el modo privado, donde el almacén suele estar respaldado por memoria y muere con la sesión, y tienes el mapa completo de contextos en los que tu aplicación puede arrancar con un árbol vacío sin que nada haya ido mal.

La consecuencia operativa es que el arranque en frío no es un caso de error. Un almacén local es siempre una caché desde el punto de vista de la corrección, por muy autorizada que sea desde el punto de vista del rendimiento, y tu código de inicio tiene que distinguir sin ambigüedad entre tres situaciones que se parecen mucho vistas desde dentro: el usuario es nuevo, el usuario existía y fue desalojado, y el usuario existía y estás en un contexto particionado distinto. Las tres se presentan como una raíz vacía y las tres piden respuestas diferentes.

💡
Escribe una marca de identidad al crear el árbol

Guarda en la raíz un fichero minúsculo con un identificador de instalación, la versión del esquema y una marca de tiempo, y escríbelo antes que cualquier otra cosa. Al arrancar, su presencia o ausencia te dice de inmediato si estás ante una instalación nueva o ante los restos de una que desapareció, y su versión te dice si toca migrar. Cuesta diez líneas y evita meses de diagnósticos confusos.

La implicación: sin exportación no hay propiedad

Privacidad sin visibilidad es custodia, no propiedad

Aquí está la tensión que este nivel entero venía a plantear, y merece nombrarse sin suavizarla. El artículo que fundó el movimiento local-first colocó la propiedad del dato entre sus ideales, y la propiedad se define por dos capacidades concretas: poder llevarte tus datos cuando quieras y poder seguir usándolos cuando el software que los creó ya no exista. OPFS, tomado por sí solo, falla en ambas de la forma más limpia posible. Los bytes están en el disco del usuario, sí, pero encerrados en un espacio que solo tu código sabe nombrar, sin ruta que revelar, sin formato que documentar, sin herramienta ajena que pueda abrirlos y sin copia de seguridad del sistema que los recoja. Si mañana tu dominio caduca, si el usuario cambia de navegador, si limpia los datos por error o si el sistema desaloja el origen para hacer sitio a una descarga, esos datos han desaparecido tan completamente como si hubieran estado en un servidor que cerró, y con el agravante de que ni siquiera hay a quién reclamar. Esa es la ironía exacta que hay que ver: la propiedad que hace posible el rendimiento —que sea privado, invisible, gestionado por el navegador— es la misma propiedad que destruye la propiedad del dato. Y la salida no es técnica sino de diseño de producto: OPFS debe entenderse como el almacén de trabajo, el sitio donde vive el estado caliente porque ahí es donde puede ir rápido, y nunca como el archivo definitivo. Una aplicación local-first seria acompaña siempre ese almacén de al menos un camino de salida real —exportación explícita a un fichero del usuario, sincronización con un dispositivo bajo su control, formato documentado que otro programa pueda leer— y trata ese camino como una función central, no como una casilla de la pantalla de ajustes. La regla que te puedes llevar a cualquier proyecto es corta: si la única copia de los datos de tu usuario está en un lugar que él no puede señalar con el dedo, no son suyos, están en depósito. Y el depósito es justo el modelo del que veníamos huyendo.

// El camino de salida: devolver los bytes al disco que el usuario sí controla.
const raiz = await navigator.storage.getDirectory();
const origen = await (await raiz.getFileHandle('db.sqlite')).getFile();

const destino = await window.showSaveFilePicker({ suggestedName: 'db.sqlite' });
const w = await destino.createWritable();
await w.write(origen);          // el flujo va de OPFS al disco real
await w.close();

Y la exportación solo cuenta si el formato de salida es legible por algo que no seas tú. Un volcado de tu estructura interna en el disco del usuario satisface la letra del ideal y no su intención: sigue siendo un fichero que solo tu programa entiende. Un fichero de SQLite, un archivo comprimido con los adjuntos y un manifiesto en texto plano, o cualquier formato con una especificación pública, son lo que convierte la copia en propiedad de verdad.

⚔️ Pon a prueba los límites
  1. Llena OPFS hasta provocar QuotaExceededError y comprueba en qué estado queda el fichero que estabas escribiendo.
  2. Pide persistencia con navigator.storage.persist() en dos navegadores distintos y anota qué responde cada uno y bajo qué condiciones.
  3. Incrusta tu propia página en un iframe de otro sitio, escribe desde dentro y comprueba desde la pestaña principal que ese fichero no existe.
  4. Repite todo el flujo en modo privado y documenta qué sobrevive a cerrar la ventana.
  5. Implementa la exportación completa a un único fichero y cronométrala con un árbol de quinientos megabytes: si tarda demasiado para ofrecerla, tu diseño de propiedad todavía no está terminado.