Exécuter SQLite dans le navigateur avec OPFS
Utilisez SQLite dans le navigateur avec OPFS: configurer un Worker, choisir opfs ou opfs-sahpool, et éviter les retours silencieux en mémoire.
Compilé en WebAssembly, SQLite fonctionne par défaut entièrement en mémoire : chaque ligne écrite disparaît donc au rechargement de la page, à moins que la base de données ne soit adossée à un VFS persistant. Dans la version officielle, c’est l’Origin Private File System (OPFS) qui assure cette persistance.
Y parvenir demande un peu plus qu’un simple import suivi d’une requête. Cet article détaille le coût réel de la mise en place avec le paquet officiel @sqlite.org/sqlite-wasm : ouvrir une base de données persistante dans un Worker, la relier à l’interface, les deux contraintes qui font trébucher les premières implémentations, et comment choisir entre les deux VFS viables en production.
Points clés à retenir
- La version officielle est publiée sur npm sous le nom @sqlite.org/sqlite-wasm et, contrairement à sql.js, elle est maintenue par le projet SQLite lui-même, avec la persistance OPFS intégrée.
- Les handles d’accès synchrone OPFS n’existent que dans les threads Worker : une base adossée à OPFS ne peut donc jamais être ouverte sur le thread principal.
- Le VFS « opfs » par défaut exige les en-têtes COOP et COEP car il dépend de SharedArrayBuffer ; le VFS « opfs-sahpool » n’a besoin d’aucun en-tête.
- opfs-sahpool est l’option OPFS la plus rapide pour les traitements par lots, mais il n’autorise qu’une seule connexion ouverte : un second onglet ouvrant la même base échoue.
- Ne basculez jamais silencieusement vers une base en mémoire lorsque OPFS est indisponible ; l’application continuerait de fonctionner tout en jetant tout ce que l’utilisateur enregistre.
Qu’apporte OPFS à SQLite dans le navigateur ?
OPFS est un système de fichiers cloisonné, limité à l’origine, qui offre à SQLite ce dont il a réellement besoin : un accès aux fichiers synchrone, au niveau de l’octet, qui survit aux rechargements. Sans lui, la version Wasm conserve la base en mémoire et un rafraîchissement l’efface. Avec lui, vous disposez d’une véritable base SQL durable côté client — l’élément qui rend les fonctionnalités modernes de SQLite exploitables dans un contexte navigateur.
Utilisez le paquet officiel. Il existe d’anciens portages Wasm communautaires, mais @sqlite.org/sqlite-wasm est la version Wasm produite par le projet SQLite lui-même, republiée sous forme de module ES. La seule couche ajoutée est un ensemble de types TypeScript. La documentation sur la persistance décrit plusieurs backends de stockage ; les deux qui comptent en pratique sont le VFS « opfs » et le VFS « opfs-sahpool ». D’autres voies existent (kvvfs par-dessus localStorage, une variante « opfs-wl », le mode WAL avec verrouillage exclusif), toutes couvertes dans ce même document.
Ouvrir une base de données dans un Worker
Installez le paquet, puis effectuez tout le travail de base de données dans un Worker dédié. Cet exemple utilise le VFS « opfs-sahpool », qui doit être installé explicitement avec await sqlite3.installOpfsSAHPoolVfs(). Il normalise les noms de bases en chemins absolus : utilisez donc la barre oblique initiale de façon cohérente :
npm install @sqlite.org/sqlite-wasm
// worker.js
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
let db;
async function init() {
const sqlite3 = await sqlite3InitModule();
const poolUtil = await sqlite3.installOpfsSAHPoolVfs();
db = new poolUtil.OpfsSAHPoolDb('/app.sqlite3'); // names are normalised to an absolute path
db.exec('CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)');
}
init()
.then(() => postMessage({ type: 'ready' }))
.catch((err) => postMessage({ type: 'init-error', message: err.message }));
Notez ce que ce code ne fait pas : basculer vers new sqlite3.oo1.DB(...) lorsque OPFS est indisponible. Ce schéma apparaît dans beaucoup d’exemples de code, y compris l’exemple de worker du README officiel, et c’est un bug de perte de données déguisé. L’application continue de fonctionner sur une base en mémoire éphémère, l’utilisateur continue d’enregistrer, et un rechargement détruit tout. Si l’initialisation de la persistance échoue, remontez l’erreur à l’interface et informez-en l’utilisateur.
Dialoguer avec le Worker depuis l’interface
La bibliothèque exporte encore une API promiser pour l’accès depuis le thread principal, mais le README du paquet marque les API Worker1 et Promiser1 comme dépréciées dans un avis daté du 15/04/2026. Elles restent dans le paquet, ne font plus l’objet de développements, et les mainteneurs en détournent les utilisateurs. La voie documentée, c’est sqlite3InitModule associé à l’API oo1 à l’intérieur du Worker, avec un mince pont postMessage que vous écrivez vous-même :
// worker.js (continued)
onmessage = ({ data }) => {
const { id, sql, bind } = data;
try {
const rows = db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' });
postMessage({ id, result: rows });
} catch (err) {
postMessage({ id, error: err.message });
}
};
// db-client.js (main thread)
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
let nextId = 1;
const pending = new Map();
worker.onmessage = ({ data }) => {
const entry = pending.get(data.id);
if (!entry) return;
pending.delete(data.id);
data.error ? entry.reject(new Error(data.error)) : entry.resolve(data.result);
};
export function query(sql, bind = []) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, sql, bind });
});
}
Quarante lignes de pont, c’est tout le coût — et vous maîtrisez la forme des messages.
Écueil n° 1 : SQLite sur OPFS doit s’exécuter dans un Worker
SQLite adossé à OPFS ne peut pas s’exécuter sur le thread principal, point final. SQLite est un moteur synchrone, et l’accès synchrone aux fichiers dont il a besoin provient de FileSystemSyncAccessHandle, que la plateforme n’expose qu’à l’intérieur des Web Workers dédiés, précisément parce que les E/S synchrones bloquent le thread qui les exécute.
Lorsque cette contrainte est contournée plutôt que respectée, le session replay rend la défaillance impossible à manquer : la relecture montre des clics et des frappes clavier enregistrés alors que rien ne se rafraîchit pendant toute la durée d’une requête — la signature visuelle d’une E/S OPFS synchrone bloquant le thread de l’interface. Gardez le moteur dans le Worker, et le thread principal ne verra jamais passer une requête.
Écueil n° 2 : l’exigence des en-têtes COOP/COEP
Le VFS « opfs » par défaut utilise SharedArrayBuffer pour faire transiter les messages entre sa façade synchrone et le worker asynchrone qui se trouve derrière. Le serveur doit donc envoyer Cross-Origin-Opener-Policy: same-origin et Cross-Origin-Embedder-Policy: require-corp, sans quoi le VFS ne se chargera pas. Pour Vite, le README officiel fournit cette configuration, y compris l’exclusion optimizeDeps requise :
import { defineConfig } from 'vite';
export default defineConfig({
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm'],
},
});
Les serveurs de production ont besoin de ces deux mêmes en-têtes. Mais si vous ne pouvez pas définir d’en-têtes (hébergement statique, intégrations tierces que COEP casse), nul besoin d’un bricolage à base de service worker : le VFS « opfs-sahpool » n’exige aucun en-tête COOP/COEP, ce qui explique pourquoi le code ci-dessus l’utilise.
Choisir entre les deux VFS OPFS
Optez pour « opfs » lorsque plusieurs onglets doivent partager une même base et que vous maîtrisez les en-têtes ; optez pour « opfs-sahpool » lorsque vous visez la vitesse maximale sans contrainte d’en-têtes et que vous pouvez vous contenter d’une seule connexion. La documentation sur la persistance de SQLite désigne sahpool comme le plus rapide des backends OPFS qu’elle couvre. Vous ne sentirez pas la différence en enregistrant un seul enregistrement. Vous la sentirez sur des traitements en masse.
| « opfs » | « opfs-sahpool » | |
|---|---|---|
| En-têtes COOP/COEP | Requis | Non requis |
| Connexions/onglets multiples | Oui, avec gestion de SQLITE_BUSY | Non, un seul à la fois |
| Performances | Bonnes | Le plus rapide pour les traitements par lots, selon la doc SQLite |
| Enregistrement | Automatique lorsqu’il est pris en charge | Explicite via installOpfsSAHPoolVfs() |
| Safari 16.4 à 16.x | Cassé par un bug de sous-worker WebKit | Fonctionne |
Le multi-onglet n’est pas gratuit, même avec « opfs ». L’acquisition d’un handle d’accès synchrone verrouille le fichier de manière exclusive, et une lecture prend elle aussi ce verrou : un second onglet ouvrant la même base se heurte donc à une erreur de verrouillage qui remonte sous la forme d’un SQLITE_BUSY ou d’une erreur d’E/S générique. Gérez-la plutôt que de la considérer comme fatale :
async function withRetry(fn, attempts = 5, delayMs = 100) {
for (let i = 0; i < attempts; i++) {
try {
return fn();
} catch (err) {
if (!/SQLITE_BUSY/.test(String(err.message)) || i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
Gardez des transactions courtes et des instructions réinitialisées, et une concurrence inter-onglets modérée fonctionne. Avec sahpool, l’appel à installOpfsSAHPoolVfs() d’un second onglet échoue purement et simplement : le pool s’approprie le verrou de la base, si bien qu’une seule connexion constitue le plafond. Détectez ce cas et faites transiter le second onglet par le premier. SQLite 3.50 a ajouté pauseVfs() et unpauseVfs() pour ce type de passation coopérative.
Quand faut-il préférer SQLite à IndexedDB ?
Tournez-vous vers SQLite sur OPFS lorsque vos données sont relationnelles : jointures entre entités, agrégats, filtrage ad hoc, index SQL complets, ou livraison d’un jeu de données préconstruit sous forme d’un fichier de base unique importé une bonne fois pour toutes. Ce sont les charges de travail pour lesquelles IndexedDB vous oblige à réimplémenter un moteur de requêtes dans le code applicatif.
C’est surdimensionné pour de l’état clé-valeur, de petits caches ou quelques centaines d’enregistrements. Ce travail ne justifie pas un binaire Wasm, un Worker et un pont de messages ; localStorage ou IndexedDB brut sont à la bonne échelle.
Conclusion
Le coût de mise en place est réel mais circonscrit : un Worker, un pont de messages et une décision sur le VFS qui dépend de votre besoin d’accès multi-onglets ou d’un déploiement sans en-têtes. Commencez par « opfs-sahpool » pour une application local-first mono-document, passez à « opfs » avec gestion de SQLITE_BUSY lorsque les onglets doivent partager la base, et ne laissez jamais un échec d’initialisation d’OPFS se dégrader silencieusement en base en mémoire.
FAQ
Quels navigateurs prennent en charge SQLite Wasm avec la persistance OPFS ?
Les handles d'accès synchrone OPFS sont disponibles à partir de Chromium 108, Firefox 111 et Safari 16.4. Une réserve : les versions de Safari antérieures à 17 sont affectées par un bug de sous-worker WebKit qui casse le VFS « opfs » par défaut, et la documentation SQLite désigne « opfs-sahpool » comme l'option qui continue d'y fonctionner. Safari 17 et versions ultérieures font tourner les deux VFS.
Puis-je livrer un fichier de base SQLite préconstruit et le charger dans OPFS ?
Oui. Avec le VFS « opfs-sahpool », récupérez le fichier .db sous forme d'ArrayBuffer et passez-le à importDb() sur l'objet PoolUtil auquel se résout installOpfsSAHPoolVfs(), puis ouvrez la base normalement. Passez exactement la même chaîne de nom aux deux appels : importDb() enregistre le nom tel que vous le fournissez, tandis que l'ouverture d'une base le normalise en chemin absolu ; importer 'data.db' puis ouvrir '/data.db' vous laisse donc avec une base vide. PoolUtil fournit également exportFile() pour extraire une base à des fins de sauvegarde, et getFileNames() pour lister ce que contient le pool. Cela convient aux applications qui livrent des jeux de données de référence sous forme d'un fichier unique.
Quelle quantité de données une base SQLite adossée à OPFS peut-elle stocker ?
Il n'existe pas de limite fixe. Le stockage OPFS relève des quotas gérés par le navigateur, généreux mais variables selon le navigateur, l'appareil et l'espace disque disponible : vérifiez donc navigator.storage.estimate() à l'exécution plutôt que de tabler sur un chiffre. Les fenêtres privées ou de navigation privée peuvent réduire, voire supprimer totalement la persistance, et l'effacement des données du site supprime la base en même temps que le reste du stockage de l'origine.
Comment inspecter les fichiers OPFS créés par SQLite pendant le débogage ?
Les DevTools des navigateurs n'affichent pas nativement le contenu d'OPFS. L'extension OPFS Explorer pour les DevTools de Chrome affiche la hiérarchie des fichiers OPFS de l'origine et permet de télécharger des fichiers individuels. Notez que « opfs-sahpool » stocke les bases à l'intérieur de fichiers de pool opaques, sous son propre mappage de noms virtuels : le nom de fichier que vous avez fourni n'apparaîtra donc pas directement. Utilisez plutôt getFileNames() et exportFile() de PoolUtil pour lister et extraire ces bases.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k