12k
All articles

SQLite im Browser mit OPFS betreiben

Nutzen Sie SQLite im Browser mit OPFS: Worker einrichten, opfs oder opfs-sahpool wählen und stille In-Memory-Fallbacks vermeiden.

OpenReplay Team
OpenReplay Team
SQLite im Browser mit OPFS betreiben

Nach WebAssembly kompiliertes SQLite läuft standardmäßig vollständig im Speicher. Jede geschriebene Zeile verschwindet daher beim Neuladen der Seite, sofern die Datenbank nicht über ein persistentes VFS abgesichert ist – und im offiziellen Build ist es das Origin Private File System (OPFS), das diese Persistenz bereitstellt.

Dorthin zu kommen erfordert etwas mehr als einen Import und eine Query. Dieser Artikel führt durch den tatsächlichen Einrichtungsaufwand mit dem offiziellen Paket @sqlite.org/sqlite-wasm: das Öffnen einer persistenten Datenbank in einem Worker, die Anbindung an die UI, die zwei Einschränkungen, über die erste Implementierungen stolpern, und die Wahl zwischen den beiden produktionstauglichen VFSes.

Die wichtigsten Erkenntnisse

  • Der offizielle Build wird auf npm als @sqlite.org/sqlite-wasm veröffentlicht und wird – anders als sql.js – vom SQLite-Projekt selbst gepflegt, mit integrierter OPFS-Persistenz.
  • Synchronous Access Handles von OPFS existieren ausschließlich in Worker-Threads. Eine OPFS-gestützte Datenbank kann daher niemals im Main Thread geöffnet werden.
  • Das standardmäßige „opfs“-VFS benötigt die COOP- und COEP-Header, da es auf SharedArrayBuffer aufbaut; das „opfs-sahpool“-VFS benötigt überhaupt keine Header.
  • opfs-sahpool ist die schnellste OPFS-Option für Batch-Verarbeitung, erlaubt aber nur eine offene Verbindung – ein zweiter Tab, der dieselbe Datenbank öffnet, schlägt fehl.
  • Fallen Sie niemals stillschweigend auf eine In-Memory-Datenbank zurück, wenn OPFS nicht verfügbar ist; das hält die Anwendung am Laufen, verwirft aber alles, was der Nutzer speichert.

Was bringt OPFS für SQLite im Browser?

OPFS ist ein sandboxed, auf die Origin beschränktes Dateisystem, das SQLite genau das gibt, was es tatsächlich braucht: synchronen Dateizugriff auf Byte-Ebene, der Reloads übersteht. Ohne OPFS hält der Wasm-Build die Datenbank im Speicher, und ein Refresh löscht sie. Mit OPFS erhalten Sie eine echte, dauerhafte SQL-Datenbank auf dem Client – und das ist der Baustein, der moderne SQLite-Features im Browser-Kontext überhaupt nutzbar macht.

Verwenden Sie das offizielle Paket. Es gibt ältere Wasm-Ports aus der Community, aber @sqlite.org/sqlite-wasm ist der Wasm-Build des SQLite-Projekts selbst, neu veröffentlicht als ES-Modul. Das Einzige, was darüber hinaus ergänzt wurde, ist ein Satz TypeScript-Typen. Die Dokumentation zur Persistenz beschreibt mehrere Storage-Backends; die zwei, die in der Praxis relevant sind, sind das „opfs“-VFS und das „opfs-sahpool“-VFS. Es gibt weitere Wege (kvvfs über localStorage, eine „opfs-wl“-Variante, WAL-Modus mit exklusivem Locking), alle in demselben Dokument beschrieben.

Eine Datenbank innerhalb eines Workers öffnen

Installieren Sie das Paket und erledigen Sie dann alle Datenbankarbeit in einem dedizierten Worker. Dieses Beispiel verwendet das „opfs-sahpool“-VFS, das explizit mit await sqlite3.installOpfsSAHPoolVfs() installiert werden muss. Es normalisiert Datenbanknamen zu absoluten Pfaden – verwenden Sie den führenden Slash daher konsistent:

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 }));

Beachten Sie, was dieser Code nicht tut: auf new sqlite3.oo1.DB(...) zurückfallen, wenn OPFS nicht verfügbar ist. Dieses Muster findet sich in vielen Codebeispielen, einschließlich des Worker-Beispiels im offiziellen README, und es ist ein Datenverlust-Bug in Verkleidung. Die Anwendung arbeitet weiter – gegen eine flüchtige In-Memory-Datenbank –, der Nutzer speichert weiter, und ein Refresh zerstört alles. Wenn die Initialisierung der Persistenz fehlschlägt, geben Sie den Fehler an die UI weiter und informieren Sie den Nutzer.

Aus der UI mit dem Worker kommunizieren

Die Library exportiert weiterhin eine Promiser-API für den Zugriff vom Main Thread, aber das Paket-README kennzeichnet die Worker1- und Promiser1-APIs in einem Hinweis vom 15.04.2026 als deprecated. Sie bleiben im Paket enthalten, werden aber nicht weiterentwickelt, und die Maintainer raten von ihrer Nutzung ab. Der dokumentierte Weg ist sqlite3InitModule plus die oo1-API innerhalb des Workers, mit einer eigenen, schlanken postMessage-Bridge:

// 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 });
  });
}

Vierzig Zeilen Bridge sind der gesamte Aufwand – und Sie kontrollieren das Format der Nachrichten.

Stolperstein eins: OPFS-SQLite muss in einem Worker laufen

OPFS-gestütztes SQLite kann nicht im Main Thread laufen, Punkt. SQLite ist eine synchrone Engine, und der synchrone Dateizugriff, den es benötigt, kommt von FileSystemSyncAccessHandle – und die Plattform stellt diesen ausschließlich innerhalb dedizierter Web Workers bereit, genau deshalb, weil synchrone I/O denjenigen Thread blockiert, der sie ausführt.

Wenn diese Einschränkung umgangen statt respektiert wird, macht Session Replay das Problem unmissverständlich sichtbar: Die Wiedergabe zeigt, wie Klicks und Tastatureingaben registriert werden, während für die Dauer einer Query nichts neu gezeichnet wird – die visuelle Signatur synchroner OPFS-I/O, die den UI-Thread blockiert. Halten Sie die Engine im Worker, dann sieht der Main Thread niemals eine Query.

Stolperstein zwei: Die COOP/COEP-Header-Anforderung

Das standardmäßige „opfs“-VFS nutzt SharedArrayBuffer, um Nachrichten zwischen seinem synchronen Frontend und dem dahinterliegenden asynchronen Worker auszutauschen. Der Server muss deshalb Cross-Origin-Opener-Policy: same-origin und Cross-Origin-Embedder-Policy: require-corp senden, sonst wird das VFS nicht geladen. Für Vite gibt das offizielle README diese Konfiguration an, inklusive des erforderlichen optimizeDeps-Ausschlusses:

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'],
  },
});

Produktionsserver brauchen dieselben zwei Header. Wenn Sie jedoch keine Header setzen können (Static Hosting, Drittanbieter-Embeds, die durch COEP kaputtgehen), brauchen Sie keinen Service-Worker-Hack: Das „opfs-sahpool“-VFS benötigt überhaupt keine COOP/COEP-Header – deshalb verwendet der Code oben genau dieses.

Die Wahl zwischen den beiden OPFS-VFSes

Wählen Sie „opfs“, wenn mehrere Tabs eine Datenbank gemeinsam nutzen müssen und Sie die Header kontrollieren; wählen Sie „opfs-sahpool“, wenn Sie maximale Geschwindigkeit ohne Header-Anforderung wollen und mit einer einzigen Verbindung leben können. SQLites eigene Persistenz-Dokumentation bewertet sahpool als das schnellste der dort behandelten OPFS-Backends. Beim Speichern eines einzelnen Datensatzes werden Sie den Unterschied nicht spüren. Bei Massenverarbeitung schon.

„opfs“„opfs-sahpool“
COOP/COEP-HeaderErforderlichNicht erforderlich
Mehrere Verbindungen/TabsJa, mit SQLITE_BUSY-BehandlungNein, nur eine zur Zeit
PerformanceGutLaut SQLite-Doku am schnellsten für Batch-Verarbeitung
RegistrierungAutomatisch, wenn unterstütztExplizit via installOpfsSAHPoolVfs()
Safari 16.4 bis 16.xDurch einen WebKit-Sub-Worker-Bug defektFunktioniert

Multi-Tab ist selbst mit „opfs“ nicht kostenlos. Das Anfordern eines Sync Access Handle sperrt die Datei exklusiv, und auch Lesevorgänge nehmen diese Sperre in Anspruch. Ein zweiter Tab, der dieselbe Datenbank öffnet, läuft daher in einen Locking-Fehler, der sich als SQLITE_BUSY oder als generischer I/O-Fehler zeigt. Behandeln Sie ihn, anstatt ihn als fatal zu betrachten:

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));
    }
  }
}

Halten Sie Transaktionen kurz und setzen Sie Statements zurück, dann funktioniert moderate tab-übergreifende Parallelität. Bei sahpool schlägt der installOpfsSAHPoolVfs()-Aufruf eines zweiten Tabs direkt fehl: Der Pool nimmt die Datenbanksperre für sich in Anspruch, eine Verbindung ist also die Obergrenze. Erkennen Sie das und leiten Sie den zweiten Tab über den ersten. SQLite 3.50 hat pauseVfs() und unpauseVfs() für genau diese Art kooperativer Übergabe ergänzt.

Wann sollten Sie SQLite gegenüber IndexedDB wählen?

Greifen Sie zu SQLite über OPFS, wenn Ihre Daten relational sind: Joins über Entitäten hinweg, Aggregate, Ad-hoc-Filterung, vollwertige SQL-Indizes oder das Ausliefern eines vorgefertigten Datensatzes als einzelne Datenbankdatei, die Sie einmalig importieren. Das sind die Workloads, bei denen IndexedDB Sie zwingt, eine Query-Engine im Anwendungscode nachzubauen.

Für Key-Value-State, kleine Caches oder ein paar hundert Datensätze ist es überdimensioniert. Diese Aufgaben rechtfertigen kein Wasm-Binary, keinen Worker und keine Message-Bridge; localStorage oder einfaches IndexedDB haben die passende Größe.

Fazit

Der Einrichtungsaufwand ist real, aber begrenzt: ein Worker, eine Message-Bridge und eine VFS-Entscheidung, die davon abhängt, ob Sie Multi-Tab-Zugriff oder ein header-freies Deployment brauchen. Beginnen Sie mit „opfs-sahpool“ für eine Single-Document-Local-First-App, wechseln Sie zu „opfs“ plus SQLITE_BUSY-Behandlung, sobald Tabs sich eine Datenbank teilen müssen – und lassen Sie ein Fehlschlagen der OPFS-Initialisierung niemals stillschweigend in eine In-Memory-Datenbank abgleiten.

FAQs

Welche Browser unterstützen SQLite Wasm mit OPFS-Persistenz?

Synchronous Access Handles von OPFS sind ab Chromium 108, Firefox 111 und Safari 16.4 verfügbar. Ein Vorbehalt: Safari-Versionen unter 17 enthalten einen WebKit-Sub-Worker-Bug, der das standardmäßige 'opfs'-VFS unbrauchbar macht; die SQLite-Doku verweist dort auf 'opfs-sahpool' als die Option, die weiterhin funktioniert. Safari 17 und neuer führt beide VFSes aus.

Kann ich eine vorgefertigte SQLite-Datenbankdatei ausliefern und in OPFS laden?

Ja. Mit dem 'opfs-sahpool'-VFS holen Sie die .db-Datei als ArrayBuffer und übergeben sie an importDb() auf dem PoolUtil-Objekt, zu dem installOpfsSAHPoolVfs() auflöst; danach öffnen Sie die Datenbank wie gewohnt. Übergeben Sie beiden Aufrufen exakt denselben Namens-String: importDb() speichert den Namen genau so, wie Sie ihn angeben, während das Öffnen einer Datenbank ihn zu einem absoluten Pfad normalisiert – wenn Sie also 'data.db' importieren und dann '/data.db' öffnen, bleibt Ihnen eine leere Datenbank. PoolUtil stellt außerdem exportFile() bereit, um eine Datenbank für ein Backup zu extrahieren, und getFileNames(), um aufzulisten, was der Pool enthält. Das passt zu Anwendungen, die Referenzdatensätze als einzelne Datei ausliefern.

Wie viele Daten kann eine OPFS-gestützte SQLite-Datenbank speichern?

Es gibt kein festes Limit. OPFS-Speicher fällt unter browser-verwaltete Quotas, die großzügig sind, aber je nach Browser, Gerät und verfügbarem Festplattenplatz variieren. Prüfen Sie daher navigator.storage.estimate() zur Laufzeit, anstatt von einem festen Wert auszugehen. Private und Inkognito-Fenster können die Persistenz einschränken oder vollständig unterbinden, und das Löschen der Website-Daten entfernt die Datenbank zusammen mit dem übrigen Storage der Origin.

Wie inspiziere ich beim Debuggen die von SQLite erzeugten OPFS-Dateien?

Die Browser-DevTools zeigen OPFS-Inhalte nicht von Haus aus an. Die Erweiterung OPFS Explorer für die Chrome DevTools stellt die OPFS-Dateihierarchie der Origin dar und erlaubt das Herunterladen einzelner Dateien. Beachten Sie, dass 'opfs-sahpool' Datenbanken innerhalb opaker Pool-Dateien unter einem eigenen virtuellen Namens-Mapping ablegt – der von Ihnen übergebene Dateiname erscheint dort also nicht direkt. Nutzen Sie stattdessen getFileNames() und exportFile() von PoolUtil, um diese Datenbanken aufzulisten und zu extrahieren.

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.