Inicio / Artículos

Cómo el almacenamiento del navegador daña silenciosamente los datos del usuario y cómo prevenirlo

El almacenamiento del navegador puede dañar los datos del usuario sin generar errores: actualizaciones perdidas, instantáneas obsoletas, desviaciones en el esquema y dispositivos compartidos. Patrones para localStorage que resisten.

Editor: REACTAPP.TOP14 min de lectura3109 palabras

Fuentes: The Browser Storage Mistake That Quietly Corrupts User Data

El almacenamiento en el navegador resulta sorprendentemente sencillo. Se serializa un objeto con JSON.stringify, se vuelve a leer con JSON.parse, y las preferencias del usuario, los formularios parcialmente completados, los filtros o los borradores sobreviven a la siguiente carga. Sin esquemas, sin llamadas a la red, sin migraciones al servidor. Unas pocas líneas de JavaScript pueden hacer que una aplicación parezca persistente en cuestión de minutos.

Esa facilidad lleva a una suposición arriesgada: que todo lo almacenado en el navegador puede tratarse como una pequeña base de datos local.

El problema suele comenzar con un único objeto de estado grande. La aplicación carga todo desde localStorage, modifica una propiedad en memoria y vuelve a escribir todo. Cada paso parece razonable: el JSON es válido, la escritura tiene éxito y la pantalla muestra el valor esperado. Nada parece estar roto.

Surgen problemas cuando otra parte de la aplicación almacena una copia obsoleta de ese mismo objeto. Quizás una segunda pestaña modifica una preferencia, una tarea en segundo plano edita un borrador, o una versión anterior de la aplicación interpreta el objeto de otra manera. También puede ocurrir que un componente actualice datos antiguos justo después de que el usuario haya modificado algo. Dado que cada uno de estos procesos guarda una captura completa, modificar un solo campo puede hacer que los valores antiguos reaparezcan silenciosamente en todos los demás campos.

No se necesita ningún error de sintaxis ni se genera ninguna excepción de almacenamiento. Cada proceso puede contener datos perfectamente válidos y, aun así, borrar la actualización válida de otro proceso.

La API de almacenamiento no es realmente la culpable. El error radica en esperar que un almacén de cadenas simples, donde prevalece la última escritura, se comporte como si supiera algo sobre registros, campos, transacciones, versiones, propiedad y acceso concurrente. En realidad no sabe nada de eso y simplemente recuerda la cadena que se asignó a una clave por última vez.

La inocente operación leer-modificar-escribir

Una implementación típica se ve así:

function updatePreferences(changes) {
  const saved = localStorage.getItem("user-settings");
  const settings = saved ? JSON.parse(saved) : {};

  const nextSettings = {
    ...settings,
    ...changes,
  };
  localStorage.setItem(
    "user-settings",
    JSON.stringify(nextSettings)
  );
}

Con una pestaña y una sola versión actual de la aplicación, esta puede funcionar durante años. Cada actualización lee el valor más reciente, fusiona los cambios y escribe el resultado. Es breve, legible y generalmente suficiente para las preferencias básicas.

El riesgo surge cuando transcurre tiempo entre la lectura y la escritura, o cuando la aplicación mantiene una copia en memoria del objeto.

Supongamos que dos pestañas cargan las mismas configuraciones:

{
  "theme": "light",
  "fontSize": 16,
  "sidebarCollapsed": false
}

La primera pestaña cambia el tema a oscuro. La segunda aumenta el tamaño de la fuente a dieciocho. Si cada una edita su propia copia en memoria y guarda todo el objeto, quien lo guarde por último borrará el cambio del otro.

La primera pestaña podría almacenar esto:

{
  "theme": "dark",
  "fontSize": 16,
  "sidebarCollapsed": false
}

Más tarde, la segunda pestaña, que sigue conservando la captura anterior, almacena lo siguiente:

{
  "theme": "light",
  "fontSize": 18,
  "sidebarCollapsed": false
}

El cambio en el tamaño de la fuente se mantiene, y el tema vuelve silenciosamente al estilo claro.

Eso es una corrupción por actualización perdida. El objeto final no está dañado, y cada campo contiene un valor legítimo. Lo que salió mal es que una actualización válida desapareció sin señales de que hubiera habido un conflicto.

Estos errores se ocultan durante el desarrollo porque los desarrolladores prueban con una pestaña, un dispositivo y una acción a la vez. Los usuarios reales mantienen las aplicaciones abiertas durante horas, duplican pestañas, restauran sesiones, cambian ventanas y regresan a páginas cuyo estado en memoria es más antiguo que el que se almacena actualmente.

La API de almacenamiento no sabe si una pestaña solo cambió el tema y otra solo el tamaño de la fuente. Ve dos cadenas completas, y la última prevalece.

Una captura instantánea no representa el estado actual

El error más grave es creer que un objeto cargado desde el almacenamiento permanece conectado a él. No es así. JSON.parse crea un valor en memoria que representa la cadena almacenada en un momento determinado; después de eso, solo se trata de una instantánea. Mientras tanto, otra pestaña puede actualizar la clave, un componente puede reemplazarla o una versión más reciente de la aplicación puede migrarla, y la copia ya en memoria no tiene conocimiento alguno de ello.

La gestión de estado empeora la situación. Una aplicación puede cargar los datos desde localStorage al iniciar y luego persistir todo el almacenamiento después de cada cambio. Varios componentes operan sobre esa instantánea mientras que otros modifican la misma clave desde el exterior.

Los guardados retrasados amplían el período de tiempo. Muchas aplicaciones implementan mecanismos de debounce en la persistencia para que localStorage.setItem no se ejecute con cada tecla pulsada. El usuario edita un campo, se programa un guardado unos cientos de milisegundos después, y la función de cierre mantiene el objeto de estado de ese momento. Antes de que se realice el guardado, otra pestaña o componente podría almacenar datos más recientes. Cuando finalmente se ejecuta la función de callback, escribe su versión anterior sobre los valores más recientes.

El mecanismo de debounce mejoró el rendimiento pero perjudicó la consistencia, ya que nadie definió qué hacer con las versiones antiguas del estado.

La inicialización asíncrona produce el mismo patrón. Un componente se renderiza con valores predeterminados, el usuario interactúa de inmediato, y el estado almacenado llega más tarde a través de un efecto o abstracción del framework. Luego, el paso de hidratación reemplaza el estado actual por una versión anterior guardada. Para el usuario, parece que su cambio se anula un instante después de realizarlo.

Estos errores parecen aleatorios porque todos los valores involucrados son válidos. El verdadero problema es la autoridad temporal: ¿debería prevalecer el valor cargado primero, la acción del usuario posterior o la entrada escrita por otro contexto? Si no se establece ninguna regla, gana aquella asignación que se ejecute por último.

Persistencia sin transacciones

localStorage suele describirse como un almacenamiento síncrono de tipo clave-valor. Eso es cierto, pero puede generar más confianza de la que realmente merece la API.

Una llamada a reemplaza un valor de forma síncrona en el contexto actual. Esa atomicidad solo abarca una llamada. Una secuencia de lectura, modificación y escritura distribuida en varios pasos no es transaccional entre pestañas o procesos. No existe comparación e intercambio, ni bloqueo de filas, ni conflicto de versiones, ni fusión a nivel de campo, ni reversión.

Conceptualmente, la secuencia sigue siendo:

const current = read();
const next = modify(current);
write(next);

Otro contexto puede ejecutar su propia secuencia contra una captura de estado diferente. Cada operación de lectura y escritura tiene éxito, pero el resultado combinado provoca la pérdida de datos.

Dividir las categorías en claves separadas reduce los conflictos. Un tema almacenado bajo una clave no puede sobrescribir un borrador no relacionado bajo otra. Esto es mejor que mantener un único objeto de estado enorme, aunque no resuelve todos los problemas de concurrencia, ya que dos usuarios aún pueden competir por el mismo borrador o registro de preferencias.

El navegador también emite un evento storage para informar a otros documentos cuando cambia una clave de localStorage. Esto ayuda con la sincronización, pero no constituye un mecanismo de transacciones. Una vez que otra pestaña recibe el evento, ya podría haber actuado sobre un estado obsoleto o haber programado una escritura que restaure su versión anterior. El evento indica que ocurrió otra escritura. La aplicación aún debe decidir cómo reconciliarla con los cambios locales no guardados.

Aquí es donde muchas implementaciones se complican. Los desarrolladores añaden oyentes, fusionan objetos, actualizan los almacenes y suprimen escrituras circulares, y pronto han creado un sistema de sincronización parcial sobre una API elegida porque parecía más simple que una capa de datos real.

Nada de esto significa que localStorage no debería almacenar datos de la aplicación. Significa que las garantías que necesitas deben coincidir con lo que ofrece la API. Para valores pequeños con un propietario evidente y tolerancia a las sobrescrituras, es una buena opción. Se vuelve frágil cuando varios usuarios editan de forma independiente un mismo registro estructurado compartido.

Un gran bloque JSON tiene un gran radio de impacto

Tener todo el estado bajo una única clave es conveniente, ya que la carga inicial requiere una sola lectura y análisis. También significa que cada actualización sobrescribe todo.

Un pequeño ajuste en una configuración de barra lateral reescribe todo lo demás que comparte ese bloque de datos: el borrador inacabado, los metadatos de autenticación, las búsquedas recientes, los anuncios descartados y los valores de formularios en caché. Si el autor mantiene datos obsoletos de alguno de estos elementos, la edición no relacionada vuelve a establecer el valor antiguo. Cuantos más temas se agrupen en una misma clave, mayor es la probabilidad de que algún autor no haya visto los cambios de otro.

También se ven afectadas las migraciones. Una nueva versión puede remodelar una parte del objeto y conservar el resto. Si la migración no se completa, la aplicación puede crear un objeto que mezcle campos de diferentes versiones, y una pestaña más antigua podrá guardar posteriormente su estructura anterior, anulando así efectivamente la migración.

Separar el almacenamiento según la propiedad y su ciclo de vida reduce estos conflictos. Las preferencias de temas, los borradores temporales, las notificaciones descartadas y las entradas de caché no necesitan compartir una versión, una política de vencimiento ni una ruta de escritura. Las claves independientes dificultan que un cambio en un área afecte a otra.

Tampoco debería ser un proceso mecánico. Crear una clave por cada pequeña propiedad se vuelve inmanejable y aún puede generar combinaciones contradictorias cuando varios valores deben cambiar al mismo tiempo. Una buena línea divisoria es un estado que tenga un único propietario, un único esquema y una única regla para las actualizaciones. Los valores que deben mantenerse consistentes como unidad pueden pertenecer juntos. Los valores que cambian por razones no relacionadas no ganan nada al compartir una instantánea, aparte de una exposición adicional.

Considere una clave como algo que alberga un registro coherente. No debería representar toda la memoria de la aplicación.

La deriva del esquema convierte los datos antiguos en nuevas corrupciones

Los datos del navegador suelen superar las expectativas. Un usuario puede mantener el mismo perfil a lo largo de docenas de versiones, y los valores escritos por una versión antigua pueden reaparecer meses después. Por lo tanto, el almacenamiento del navegador cuenta con un esquema, incluso si nadie lo diseñó.

Supongamos que una versión anterior almacenó un valor booleano:

{
  "notifications": true
}

Una versión más reciente lo reemplaza por un objeto más completo:

{
  "notifications": {
    "email": true,
    "push": false
  }
}

El código que asume la nueva estructura sin verificar ni migrar el valor anterior podría generar errores. O, de manera más insidiosa, podría sustituir los valores predeterminados y luego guardarlos sobre el registro almacenado, descartando lo que el usuario eligió anteriormente.

Los campos renombrados causan problemas similares. Una marca darkMode restante puede coexistir junto a un valor theme más reciente. Una parte de la aplicación lee el nuevo campo, mientras que una pestaña antigua escribe la estructura anterior, lo que hace que las preferencias del usuario cambien de forma impredecible entre sesiones.

TypeScript no soluciona esto. El compilador puede describir la estructura esperada actual, pero JSON.parse devuelve datos en tiempo de ejecución generados por código anterior desconocido. Una afirmación de tipo silencia al compilador y hace que la discrepancia sea más difícil de detectar:

const settings =
  JSON.parse(savedValue) as CurrentSettings;

Esa línea no transforma el almacenamiento antiguo según el esquema actual. Simplemente le indica a TypeScript que confíe en él.

Un código más seguro trata los valores persistidos como entrada externa. Los analiza, valida su estructura, verifica la versión y migra intencionadamente los formatos antiguos conocidos. Si un valor no puede interpretarse de forma segura, la aplicación debe decidir si descartarlo, conservarlo para recuperación o preguntarle al usuario.

Un número de versión almacenado junto a los datos hace que las transiciones sean explícitas:

{
  "version": 3,
  "data": {
    "theme": "dark"
  }
}

Un número de versión no realizará la migración, pero indica al código qué suposiciones siguen siendo válidas.

Un análisis exitoso no equivale a seguridad

Los desarrolladores suelen envolver a JSON.parse en try/catch y llamar a la capa de almacenamiento de forma protegida. Esto permite manejar JSON mal formado, lo cual es útil, pero la validez sintáctica es solo el primer requisito.

Un objeto analizado podría carecer de campos obligatorios, contener valores imposibles, seguir un esquema obsoleto o haber sido editado manualmente a través de herramientas de desarrollador o una extensión. Los valores también pueden tener el tipo correcto pero un significado erróneo. Un tamaño de página almacenado de -500 es un número válido dentro de JSON válido. Un tema llamado "ultraviolet" es una cadena válida. Un borrador que carece del ID que lo relaciona con un documento es un objeto válido. Ninguno de ellos merece confianza automática como estado de la aplicación, ya que el navegador solo almacena cadenas y no conoce las reglas de su dominio.

La hidratación fiable valida lo que la aplicación está dispuesta a aceptar, utilizando una biblioteca de esquemas, un pequeño analizador escrito a mano o constructores cuidadosamente diseñados. Lo importante es que la validación genere un modelo interno conocido en lugar de introducir datos analizados en bruto en la aplicación.

La validación es también el lugar donde deben aplicarse los valores predeterminados. Una preferencia opcional faltante puede recibir un valor predeterminado sin problemas. La falta de un identificador en un borrador no guardado puede significar que dicho borrador no puede adjuntarse a ningún documento y no debería restaurarse automáticamente. Tratar ambos casos como ausencias inofensivas genera una falsa sensación de seguridad que oculta los problemas. Una solución alternativa no debería ocultar la incertidumbre. Si la aplicación no puede demostrar que los datos almacenados pertenecen al usuario, esquema y registro actuales, restaurarlos puede causar más daño que ignorarlos.

La identidad debe formar parte del contrato de almacenamiento

Los navegadores compartidos introducen otra suposición errónea: que el almacenamiento pertenece a quien está conectado. En realidad, pertenece al origen del navegador.

Imagínese que una persona cierra sesión y otra se conecta en la misma máquina. Los borradores, filtros, preferencias o registros en caché restantes permanecen allí a menos que la aplicación los elimine o los organice en espacios de nombres específicos. Claves genéricas como draft, recent-items o dashboard-settings pueden hacer que los datos de la primera persona aparezcan en la sesión de la segunda. Incluso si no hay nada sensible involucrado, esto se considera una corrupción, ya que un valor válido se asignó al usuario incorrecto.

Borrar todo el almacenamiento al cerrar sesión ayuda, pero es un método brusco. Puede eliminar preferencias legítimas a nivel del dispositivo, como la configuración del idioma o de accesibilidad, y puede fallar cuando el cierre de sesión ocurre en otra pestaña mientras la pestaña actual mantiene una copia en memoria.

Un diseño más deliberado clasifica los datos en categorías específicas para el usuario, la sesión, el dispositivo y datos públicos. Los registros pertenecientes a un usuario pueden contener su ID en la clave o en un objeto de contenedor, y el proceso de carga puede confirmar que el propietario almacenado coincide con la identidad iniciada antes de aceptar el valor.

A menudo, los borradores también necesitan el ID del documento o recurso. Restaurar “el último borrador” de forma global es peligroso cuando hay varios registros abiertos, ya que el contenido de uno puede aparecer en otro si la clave no indica qué objeto lo posee. El uso de nombres espaciales va más allá de la organización; evita que los datos válidos terminen en el contexto incorrecto.

El cifrado no corrige la corrupción

Las discusiones sobre los riesgos del almacenamiento en los navegadores suelen centrarse en la seguridad. La información sensible no debería quedar almacenada de forma descuidada en áreas accesibles desde el navegador, especialmente cuando existen scripts que pueden leerla. Eso sí es un problema real, pero distinto a la cuestión de la consistencia.

El cifrado de un valor almacenado no impide que dos pestañas sobrescriban una a otra. No añade transacciones, verificaciones de versión, mecanismos de propiedad ni posibilidades de migración. Una aplicación puede cifrar una captura obsoleta sin problemas y, al mismo tiempo, destruir sin problemas una versión más reciente. A veces, los equipos recurren a la codificación, la ofuscación o el cifrado para resolver lo que en realidad es un problema de diseño. Esas medidas pueden ayudar con la confidencialidad bajo ciertos modelos de amenaza, pero mantienen el proceso de actualización igual de poco fiable.

De manera similar, pasar de localStorage a IndexedDB puede ofrecer más capacidad, almacenamiento estructurado, indexación y comportamiento transaccional. Por sí solo no genera una lógica de aplicación correcta. Una transacción debe abarcar los registros adecuados, las actualizaciones siguen teniendo que respetar la propiedad de los datos y las actualizaciones de esquema aún requieren ser diseñadas. La tecnología proporciona primitivas más potentes, y la aplicación debe utilizarlas bajo un modelo de consistencia claro.

Comience por la propiedad de los datos

En lugar de preguntarse qué API de almacenamiento elegir, comience preguntándose quién es el propietario de los datos y cómo se debe manejar el cambio realizado por otro usuario que también escriba en esos datos.

Las preferencias simples suelen funcionar bien con el principio del último escritor que gana. Si dos pestañas establecen temas diferentes, mantener el más reciente puede ser exactamente lo que se desea, aunque la aplicación aún debe estar atenta a los cambios entre pestañas si la interfaz visible necesita actualizarse de inmediato.

Un borrador inacabado es otra cuestión. Un sistema en el que solo prevalece la última edición silenciosa puede hacer que el usuario pierda tiempo valioso, por lo que el registro podría necesitar un número de revisión, una marca de tiempo, un ID de documento y una política de conflictos. Antes de sobrescribir, la aplicación puede comparar las revisiones, conservar ambas versiones en conflicto o advertir al usuario de que otra pestaña editó el mismo borrador.

Para registros importantes compartidos entre dispositivos, el almacenamiento del navegador en absoluto debería ser la fuente de verdad. Puede funcionar como caché sin conexión o copia de trabajo temporal, mientras que el servidor alberga las versiones oficiales y resuelve los conflictos.

El diseño adecuado depende de cuánto valen los datos y de cuántos usuarios los modifican. Si comparamos una preferencia de visualización con un formulario legal, un borrador de artículo largo o un registro financiero sin conexión, las consecuencias son claramente diferentes. Tratarlos a todos como cadenas bajo claves arbitrarias ignora el costo que implica perder una actualización.

Un único camino de escritura también es útil. Cuando cualquier componente puede leer, fusionar y reemplazar la misma clave cuando le plazca, nadie puede revisar las reglas de consistencia. Un módulo de almacenamiento puede centralizar la validación, la migración, la sincronización y la gestión de conflictos. No se necesita una capa de repositorio compleja para tres preferencias; simplemente evita que el código de persistencia se vuelva tan extenso que nadie pueda determinar qué instantánea es la actual.

Preserva los datos, no los reinterpretes en silencio

Los errores de almacenamiento más graves no se manifiestan con un QuotaExceededError ni con un fallo en el análisis de JSON. Estos son visibles y se pueden registrar, probar y solucionar. Lo que realmente causa daño es una escritura que tiene éxito al guardar la versión incorrecta de datos que, de otro modo, serían válidos.

Imagínese una pestaña obsoleta que edita un campo y devuelve cinco antiguos, una llamada de retorno retrasada que sobrescribe un borrador más reciente, una versión antigua que guarda un esquema desactualizado, o un nuevo inicio de sesión que hereda el estado de la cuenta anterior. Según lo que puede indicar la API de almacenamiento, cada una de esas operaciones funcionó correctamente. La corrupción existe porque la aplicación nunca definió versiones, propietarios ni reglas de conflicto.

El almacenamiento del navegador cobra sentido cuando sus limitaciones son adecuadas para la tarea: guardar preferencias, almacenar en caché valores no críticos, permitir el uso sin conexión y restaurar trabajos inconclusos. Pero persistir algo convierte un estado de corta duración en un dato de larga duración, y eso merece la misma atención que se le daría a los datos provenientes de un servidor o base de datos.

En la práctica, eso significa verificar qué se devuelve, versionarlo, mantener separados los registros no relacionados, añadir la identidad del usuario y del recurso donde sea importante la propiedad, y no reescribir snapshots enormes por ediciones pequeñas. También implica decidir deliberadamente si el principio del último que escribe gana es aceptable, para que no se convierta en política por casualidad.

El navegador mantendrá fielmente la última cadena que reciba. Asegurarse de que quien escribió por último merezca decidir qué sobrevive es tarea suya.

Leer más