Comment le stockage du navigateur endommage silencieusement les données de l’utilisateur et comment y remédier
Le stockage du navigateur peut endommager les données de l’utilisateur sans erreur : mises à jour manquantes, captures d’écran obsolètes, dérive du schéma et appareils partagés. Modèles pour localStorage qui résistent.
Sources: The Browser Storage Mistake That Quietly Corrupts User Data
Le stockage dans le navigateur est incroyablement simple. Sérialisez un objet avec JSON.stringify, lisez-le à nouveau avec JSON.parse, et les préférences de l’utilisateur, le formulaire partiellement rempli, les filtres ou les brouillons survivent au prochain chargement. Pas de schéma, pas d’appel réseau, pas de migration côté serveur. Quelques lignes de JavaScript suffisent pour que une application paraisse persistante en quelques minutes.
Cette simplicité conduit à une supposition risquée : tout ce qui est stocké dans le navigateur peut être considéré comme une petite base de données locale.
Les problèmes commencent généralement avec un grand objet d’état. L’application charge l’ensemble depuis localStorage, modifie une propriété en mémoire puis réécrit tout le contenu. Chaque étape semble logique : le JSON est valide, l’écriture réussit et l’écran affiche la valeur attendue. Rien ne semble défaillant.
Des problèmes apparaissent lorsque une autre partie de l’application conserve une version obsolète du même objet. Peut-être qu’une deuxième fenêtre modifie une préférence, qu’une tâche en arrière-plan édite un brouillon, ou qu’une version antérieure de l’application interprète l’objet d’une autre manière. Il se peut aussi qu’un composant charge à nouveau des données anciennes juste après que l’utilisateur ait modifié quelque chose. Comme chacun de ces éléments enregistre une capture complète, modifier un seul champ peut faire réapparaître silencieusement des valeurs anciennes dans tous les autres champs.
Aucune erreur de syntaxe n’est nécessaire, et aucune exception de stockage n’est levée. Chaque élément peut conserver des données parfaitement valides tout en effaçant la mise à jour valide d’un autre élément.
L’API de stockage n’est pas vraiment responsable. L’erreur réside dans l’attente qu’un stockage de chaînes de caractères simples, où la dernière écriture l’emporte, se comporte comme s’il connaissait les enregistrements, les champs, les transactions, les versions, la propriété et l’accès concurrent. Il ne connaît rien de tout cela et se contente de se souvenir de la dernière chaîne de caractères assignée à une clé.
La lecture-modification-écriture innocente
Une implémentation typique ressemble à ceci :
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)
);
}
Avec une seule fenêtre et une seule version actuelle de l’application, celle-ci peut fonctionner pendant des années. Chaque mise à jour lit la valeur la plus récente, fusionne les modifications et enregistre le résultat. Ce système est concis, lisible et généralement suffisant pour les préférences de base.
Le risque apparaît lorsque s’écoule du temps entre la lecture et l’enregistrement, ou lorsque l’application conserve une copie en mémoire de l’objet.
Supposons que deux fenêtres chargent les mêmes paramètres :
{
"theme": "light",
"fontSize": 16,
"sidebarCollapsed": false
}
Le premier onglet change le thème en mode sombre. Le deuxième augmente la taille de la police à dix-huit. Si chacun modifie sa propre copie en mémoire et enregistre l’objet complet, celui qui enregistre en dernier efface les modifications de l’autre.
Le premier onglet pourrait stocker ceci :
{
"theme": "dark",
"fontSize": 16,
"sidebarCollapsed": false
}
Plus tard, la deuxième onglet, qui conserve toujours l’ancienne capture d’écran, enregistre ce qui suit :
{
"theme": "light",
"fontSize": 18,
"sidebarCollapsed": false
}
Le changement de taille de police persiste, et le thème revient silencieusement à la version claire.
C’est une corruption due à une mise à jour manquante. L’objet final n’est pas déformé, et chaque champ contient une valeur valide. Ce qui s’est passé, c’est qu’une mise à jour valide a disparu sans aucun signe d’un conflit.
Ces bugs restent cachés pendant le développement parce que les développeurs testent une seule fenêtre, un seul appareil et une seule action à la fois. Les utilisateurs réels gardent les applications ouvertes pendant des heures, créent des fenêtres dupliquées, restaurent des sessions, changent de fenêtres et reviennent sur des pages dont l’état en mémoire est plus ancien que celui actuellement enregistré.
L’API de stockage ne sait pas qu’une fenêtre a uniquement modifié le thème et une autre uniquement la taille de police. Elle voit deux chaînes complètes, et c’est la dernière qui l’emporte.
Un instantané n’est pas l’état actuel
La plus grave erreur consiste à croire qu’un objet chargé depuis le stockage reste connecté à ce dernier. Ce n’est pas le cas. JSON.parse crée une valeur en mémoire représentant la chaîne de caractères stockée à un instant donné. Après cela, il ne s’agit que d’une capture instantanée. Pendant ce temps, une autre fenêtre peut mettre à jour la clé, un composant peut la remplacer, ou une version plus récente de l’application peut la migrer, sans que la copie déjà en mémoire en soit avertie.
La gestion de l’état aggrave encore la situation. Une application peut charger les données depuis localStorage au démarrage, puis conserver l’intégralité du stockage après chaque modification. Plusieurs composants travaillent sur cette capture instantanée, tandis que des modifications sont apportées à la même clé ailleurs dans l’application.
Les enregistrements différés élargissent la fenêtre de temps. De nombreuses applications utilisent un mécanisme de débouncing pour que localStorage.setItem ne s’exécute pas à chaque frappe. L’utilisateur modifie un champ, un enregistrement est planifié quelques centaines de millisecondes plus tard, et la fonction de clôture conserve l’objet d’état de ce moment-là. Avant que l’enregistrement ne soit effectué, une autre fenêtre ou un autre composant peut stocker des données plus récentes. Lorsque la fonction de rappel s’exécute enfin, elle écrase les valeurs plus récentes par son snapshot plus ancien.
Le débouncing améliorait les performances mais nuisait à la cohérence, car personne n’avait défini ce qu’il fallait faire des snapshots obsolètes.
L’initialisation asynchrone produit le même schéma. Un composant s’affiche avec des valeurs par défaut, l’utilisateur interagit immédiatement, et l’état enregistré arrive plus tard grâce à un effet ou une abstraction du framework. L’étape d’hydratation remplace alors l’état actuel par une version plus ancienne enregistrée. Pour l’utilisateur, il semble que ses modifications soient annulées un instant après qu’il les a effectuées.
Ces bugs semblent aléatoires car chaque valeur impliquée est valide. Le véritable problème réside dans l’autorité temporelle : faut-il privilégier la valeur chargée en premier, l’action de l’utilisateur ultérieure, ou l’entrée écrite dans un autre contexte ? S’il n’existe aucune règle définie, c’est la dernière opération à s’exécuter qui tranche la question.
Persistence sans transactions
localStorage est généralement décrit comme un stockage synchrone clé-valeur. C’est exact, mais cela peut inspirer plus de confiance que l’API ne le mérite.
Une seule appel à setItem remplace une valeur de manière synchrone dans le contexte actuel. Cette atomicité ne concerne qu’un seul appel. Une séquence de lecture, de modification et d’écriture répartie sur plusieurs étapes n’est pas transactionnelle entre des onglets ou des travailleurs. Il n’y a ni comparaison et échange, ni verrouillage de ligne, ni conflit de version, ni fusion au niveau des champs, ni annulation.
Conceptuellement, la séquence reste la même :
const current = read();
const next = modify(current);
write(next);
Un autre contexte peut exécuter sa propre séquence sur un snapshot différent. Chaque lecture et écriture réussit, mais le résultat global entraîne la perte de données.
Diviser les catégories en clés distinctes réduit les conflits. Un thème stocké sous une clé ne peut pas écraser un brouillon non lié sous une autre clé. C’est mieux que de conserver un seul objet d’état énorme, bien que cela ne résolve pas tous les problèmes de concurrence, car deux utilisateurs peuvent toujours se disputer le même brouillon ou enregistrement de préférences.
Le navigateur déclenche également un événement storage pour informer les autres documents lorsqu’une clé de localStorage change. Cela facilite la synchronisation, mais ce n’est pas un mécanisme de transaction. Une fois qu’une autre fenêtre a reçu l’événement, elle a déjà pu agir sur un état obsolète ou avoir planifié une écriture qui restaure sa version antérieure. L’événement indique qu’une autre écriture a eu lieu. L’application doit encore décider comment la concilier avec les modifications locales non enregistrées.
Ici, de nombreuses implémentations deviennent complexes. Les développeurs ajoutent des écouteurs, fusionnent des objets, mettent à jour les bases de données et suppriment les écritures circulaires, et bientôt ils ont construit un système de synchronisation partielle sur le dos d’une API choisie parce qu’elle semblait plus simple qu’une véritable couche de données.
Rien de tout cela ne signifie que localStorage ne devrait pas conserver des données d’application. Cela veut dire que les garanties dont vous avez besoin doivent correspondre à ce que l’API offre. Pour de petites valeurs ayant un propriétaire évident et une tolérance aux écrasements, c’est une bonne solution. Cependant, cela devient fragile lorsque plusieurs utilisateurs modifient indépendamment une même entrée structurée partagée.
Un gros bloc JSON a un grand rayon d’action
Conserver tout l’état sous une seule clé est pratique, car l’hydratation nécessite une seule lecture et une seule analyse. Cela signifie également que chaque mise à jour efface tout le contenu.
Un ajustement d’une configuration de barre latérale réécrit alors tout le reste qui partage ce même bloc de données : le brouillon inachevé, les métadonnées d’authentification, les recherches récentes, les bannières ignorées, les valeurs de formulaire mémorisées. Si l’auteur conserve des données obsolètes pour l’un de ces éléments, la modification non liée remet en place la valeur ancienne. Plus de sujets sont regroupés sous une même clé, plus il est probable qu’un auteur n’ait pas vu les modifications d’un autre.
Les migrations subissent également des problèmes. Une nouvelle version peut modifier une partie de l’objet tout en conservant le reste. Si la migration est incomplète, l’application peut enregistrer un objet mélangeant des champs provenant de différentes versions, et une version plus ancienne peut par la suite conserver sa structure initiale, annulant ainsi efficacement la migration.
En séparant le stockage en fonction de la propriété et du cycle de vie, on réduit ces conflits. Les préférences de thème, les brouillons temporaires, les notifications supprimées et les entrées de cache n’ont pas besoin de partager une même version, une même politique d’expiration ou un même chemin d’écriture. Des clés indépendantes rendent plus difficile le fait qu’un changement lié à un domaine efface ceux d’un autre.
La séparation ne doit pas non plus être mécanique. Créer une clé pour chaque petite propriété devient difficile à gérer et peut encore entraîner des combinaisons contradictoires lorsque plusieurs valeurs doivent être modifiées en même temps. Une bonne ligne de séparation est un élément d’état ayant un seul propriétaire, un seul schéma et une seule règle de mise à jour. Les valeurs qui doivent rester cohérentes en tant qu’unité peuvent être regroupées. Les valeurs qui changent pour des raisons indépendantes ne gagnent rien à partager un instantané, si ce n’est une exposition supplémentaire.
Considérez une clé comme contenant un enregistrement cohérent. Elle ne devrait pas représenter toute la mémoire de l’application.
Le dérive de schéma transforme les données anciennes en nouvelles corruption
Les données des navigateurs survivent souvent plus longtemps que prévu. Un utilisateur peut conserver le même profil au fil de dizaines de mises à jour, et les valeurs enregistrées par une ancienne version peuvent réapparaître des mois plus tard. Ainsi, le stockage des navigateurs possède un schéma, même si personne ne l’a conçu.
Supposons qu’une ancienne version ait stocké une valeur booléenne :
{
"notifications": true
}
Une version plus récente le remplace par un objet plus riche :
{
"notifications": {
"email": true,
"push": false
}
}
Le code qui suppose la nouvelle structure sans vérifier ou migrer la valeur ancienne peut provoquer des erreurs. Ou, de manière plus insidieuse, il peut substituer des valeurs par défaut puis les enregistrer à la place du record stocké, éliminant ainsi ce que l’utilisateur avait choisi précédemment.
Les champs renommés causent des problèmes similaires. Un indicateur darkMode resté en place peut coexister avec une valeur theme plus récente. Une partie de l’application lit le nouveau champ, tandis qu’une ancienne version écrit la structure précédente, ce qui fait que les préférences de l’utilisateur changent de manière imprévisible d’une session à l’autre.
TypeScript ne résout pas ce problème. Le compilateur peut décrire la structure attendue actuellement, mais JSON.parse renvoie des données exécutées générées par du code ancien inconnu. Une assertion de type fait taire le compilateur et rend plus difficile la détection de cette incohérence :
const settings =
JSON.parse(savedValue) as CurrentSettings;
Cette ligne ne transforme pas les anciens enregistrements selon le schéma actuel. Elle se contente d’indiquer à TypeScript de lui faire confiance.
Un code plus sûr traite les valeurs persistées comme des entrées externes. Il les analyse, vérifie leur structure, contrôle la version et migre délibérément les formats plus anciens connus. Si une valeur ne peut pas être interprétée en toute sécurité, l’application doit choisir entre la supprimer, la conserver pour une restauration ou demander l’avis de l’utilisateur.
Un numéro de version stocké à côté des données rend les transitions explicites :
{
"version": 3,
"data": {
"theme": "dark"
}
}
Un numéro de version ne permet pas de réaliser la migration, mais il indique au code quelles hypothèses restent valables.
Un parsing réussi ne garantit pas la sécurité
Les développeurs enveloppent souvent JSON.parse dans des blocs try/catch afin de protéger la couche de stockage. Cela permet de gérer les données JSON mal formatées, ce qui est utile, mais la validité syntaxique n’est que la première exigence.
Un objet analysé peut manquer de champs requis, contenir des valeurs impossibles, suivre un schéma obsolète, ou avoir été modifié manuellement à l’aide d’outils de développement ou d’une extension. Les valeurs peuvent également avoir le bon type mais le mauvais sens. Une taille de page stockée égale à -500 est un nombre valide au sein d’un JSON valide. Un thème nommé "ultraviolet" est une chaîne de caractères valide. Un brouillon ne possédant pas l’ID qui le lie à un document est un objet valide. Aucun d’eux ne mérite une confiance automatique en tant qu’état de l’application, car le navigateur ne fait que conserver les chaînes de caractères sans connaître vos règles de domaine.
L’hydratation fiable valide ce que l’application est prête à accepter, en utilisant une bibliothèque de schémas, un petit analyseur écrit manuellement ou des constructeurs soigneusement conçus. Ce qui importe, c’est que la validation produise un modèle interne connu plutôt que de fournir des données analysées brutes à l’application.
La validation est également le lieu où se trouvent les valeurs par défaut. Une préférence optionnelle manquante peut recevoir en toute sécurité une valeur par défaut. L’absence d’identifiant dans un brouillon non enregistré peut signifier que ce dernier ne peut être associé à aucun document et ne devrait pas être restauré automatiquement. Considérer les deux cas comme des absences inoffensives crée une fausse confiance qui permet aux corruption de rester cachées. Une solution de secours ne doit pas masquer l’incertitude. Si l’application ne peut pas prouver que les données stockées appartiennent à l’utilisateur actuel, au schéma et à l’enregistrement concernés, les restaurer peut causer plus de dommages que de les ignorer.
L’identité doit faire partie du contrat de stockage
Les navigateurs partagés introduisent une autre supposition erronée, à savoir que le stockage appartient à la personne qui est connectée. En réalité, il appartient à l’origine du navigateur.
Imaginez qu’une personne se déconnecte et qu’une autre se connecte sur la même machine. Les brouillons, filtres, préférences ou enregistrements en mémoire cache restent en place à moins que l’application ne les supprime ou ne les organise dans des espaces de noms distincts. Des clés génériques telles que draft, recent-items ou dashboard-settings peuvent faire en sorte que les données de la première personne soient intégrées à la session de la deuxième. Même en l’absence d’informations sensibles, cela peut être interprété comme une corruption, car une valeur valide a été associée au mauvais utilisateur.
Supprimer tout le stockage lors de la déconnexion aide, mais c’est une solution brutale. Elle peut effacer des préférences légitimes au niveau de l’appareil, comme les paramètres de langue ou d’accessibilité, et peut échouer si la déconnexion a lieu dans une autre fenêtre tandis que la fenêtre actuelle conserve une copie en mémoire.
Une conception plus réfléchie classe les données en fonction de l’utilisateur, de la session, du dispositif et en données publiques. Les enregistrements appartenant à un utilisateur peuvent contenir son identifiant dans la clé ou dans un objet d’emballage, et le processus de chargement peut vérifier que le propriétaire enregistré correspond à l’identité connectée avant d’accepter la valeur.
Les versions provisoires ont souvent également besoin de l’ID du document ou de la ressource. Restaurer « la dernière version provisoire » de manière globale est dangereux lorsque plusieurs enregistrements sont ouverts, car le contenu d’un enregistrement peut apparaître dans un autre si la clé ne précise pas quel objet en est le propriétaire. L’organisation par espace de noms va au-delà de la simple propreté ; elle empêche les données valides d’être placées dans le mauvais contexte.
Le chiffrement ne corrige pas les corruptions
Les discussions sur les risques liés à l’espace de stockage des navigateurs portent souvent sur la sécurité. Les informations sensibles ne devraient pas être stockées de manière négligente dans un espace accessible depuis le navigateur, surtout lorsque des scripts injectés peuvent y accéder. C’est là une vraie préoccupation, mais différente de celle liée à la cohérence.
Le chiffrement d’une valeur stockée ne permet pas d’éviter que deux onglets ne s’écrasent mutuellement. Il ne prévoit ni transactions, ni vérifications de version, ni mécanismes de propriété ou de migration. Une application peut chiffrer parfaitement une capture obsolète avant de détruire sans problème une version plus récente. Les équipes ont parfois recours à l’encodage, à l’obfuscation ou au chiffrement pour résoudre ce qui est en réalité un problème de conception. Ces mesures peuvent aider à assurer la confidentialité dans certains modèles de menace, mais elles laissent le processus de mise à jour tout aussi peu fiable.
De même, passer de localStorage à IndexedDB peut offrir plus d’espace de stockage, un stockage structuré, des fonctionnalités d’indexation et un comportement transactionnel. Cela ne génère pas à lui seul une logique d’application correcte. Une transaction doit couvrir les bons enregistrements, les mises à jour doivent toujours respecter les règles de propriété, et les mises à niveau du schéma nécessitent encore une conception adaptée. La technologie propose des primitives plus puissantes, et l’application doit les utiliser dans le cadre d’un modèle de cohérence clair.
Partir de la notion de propriété
Plutôt que de se demander quel API de stockage choisir, il convient de commencer par se demander qui est le propriétaire des données et comment gérer les modifications apportées par un autre utilisateur.
Les préférences simples peuvent souvent fonctionner selon le principe du dernier enregistrement gagnant. Si deux onglets définissent des thèmes différents, conserver la dernière version peut être exactement ce qui est souhaité, bien que l’application doive néanmoins surveiller les changements entre onglets si l’interface visible doit être mise à jour immédiatement.
Un brouillon inachevé, c’est une autre histoire. Un système où le dernier enregistrement l’emporte peut coûter beaucoup de travail à l’utilisateur, il est donc possible que le document nécessite un numéro de version, une date et heure d’enregistrement, un identifiant de document ainsi qu’une politique de gestion des conflits. Avant d’écraser les données, l’application peut comparer les différentes versions, conserver les deux versions en conflit ou avertir l’utilisateur qu’une autre fenêtre a modifié le même brouillon.
Pour les documents importants partagés entre plusieurs appareils, le stockage du navigateur ne devrait absolument pas constituer la source de vérité. Il peut servir de cache hors ligne ou de copie de travail temporaire, tandis que le serveur conserve les versions officielles et résout les conflits.
La conception appropriée dépend de la valeur des données et du nombre de personnes qui les modifient. En comparant une préférence d’affichage à un formulaire juridique, à un brouillon d’article long ou à un registre financier hors ligne, on constate clairement que les enjeux diffèrent. Les traiter tous comme des chaînes de caractères sous des clés arbitraires revient à ignorer le coût potentiel de la perte d’une mise à jour.
Un seul chemin d’écriture est également utile. Lorsque n’importe quel composant peut lire, fusionner et remplacer la même clé à sa guise, personne ne peut vérifier les règles de cohérence. Un module de stockage peut centraliser la validation, la migration, la synchronisation et la gestion des conflits. Cela ne nécessite pas une couche de répertoire complexe pour trois préférences seulement ; cela empêche simplement le code de persistance de s’étendre au point que personne ne puisse déterminer quelle version est la plus récente.
Préserver les données, ne pas les interpréter secrètement
Les bugs de stockage les plus graves ne se manifestent pas par une QuotaExceededError ou une erreur de parsing JSON. Ceux-ci sont visibles et peuvent être enregistrés, testés et gérés. Ce qui cause vraiment des dommages, c’est une écriture qui réussit tout en enregistrant la mauvaise version de données qui seraient sinon valides.
Imaginez une étiquette obsolète qui modifie un champ tout en réintroduisant cinq anciens, un appel de retour retardé qui efface une version plus récente, une version ancienne qui enregistre un schéma dépassé, ou un nouveau connexion qui hérite de l’état du dernier compte. D’un point de vue de l’API de stockage, chacune de ces opérations semble avoir fonctionné. La corruption apparaît parce que l’application n’a jamais défini de versions, de règles de propriété ni de règles de conflit.
Le stockage du navigateur trouve son utilité lorsque ses limites conviennent à la tâche : conserver les préférences, mettre en cache des valeurs non critiques, permettre le fonctionnement hors ligne, restaurer des travaux inachevés. Mais le fait de persister des données transforme un état éphémère en une entrée pérenne, ce qui exige les mêmes précautions que celles appliquées aux données provenant d’un serveur ou d’une base de données.
En pratique, cela signifie vérifier ce qui est renvoyé, le versionner, conserver les enregistrements non liés séparément, indiquer l’identité de l’utilisateur et du ressource lorsque la propriété est importante, et ne pas réécrire de gros snapshots pour de petites modifications. Cela signifie également choisir délibérément si le principe du dernier à écrire l’emporte est acceptable, afin qu’il ne devienne pas par hasard une politique officielle.
Le navigateur conservera fidèlement la dernière chaîne de caractères qu’il reçoit. Il revient à vous de vous assurer que le dernier auteur mérite bien de décider ce qui sera conservé.