OPFS を使ってブラウザで SQLite を動かす
OPFSでブラウザ内SQLiteを使う手順: Worker設定、opfsかopfs-sahpoolの選択、静かなメモリ退避を防ぐ方法を解説。
WebAssembly にコンパイルされた SQLite は、デフォルトでは完全にメモリ上で動作します。そのため、データベースが永続的な VFS によってバックアップされていない限り、書き込んだ行はページを更新するたびにすべて消えてしまいます。そして公式ビルドにおいて、その永続性を提供するのが Origin Private File System (OPFS) です。
ただし、そこに至るには import 1 行とクエリ 1 本では済みません。この記事では、公式の @sqlite.org/sqlite-wasm パッケージを使った実際のセットアップコストを、順を追って見ていきます。Worker で永続データベースを開く方法、UI との接続、最初の実装でつまずきがちな 2 つの制約、そしてプロダクションで実用に耐える 2 つの VFS のどちらを選ぶべきかを扱います。
要点
- 公式ビルドは npm に @sqlite.org/sqlite-wasm として公開されており、sql.js とは異なり SQLite プロジェクト自身がメンテナンスし、OPFS による永続化が組み込まれています。
- OPFS の同期アクセスハンドルは Worker スレッド内にしか存在しないため、OPFS にバックアップされたデータベースをメインスレッドで開くことは決してできません。
- デフォルトの “opfs” VFS は SharedArrayBuffer に依存しているため COOP および COEP ヘッダーが必要です。一方、“opfs-sahpool” VFS はヘッダーを一切必要としません。
- opfs-sahpool はバッチ処理において最速の OPFS オプションですが、接続を 1 つしか開けないため、2 つ目のタブが同じデータベースを開こうとすると失敗します。
- OPFS が利用できないときに、黙ってインメモリデータベースにフォールバックしてはいけません。それはアプリを動作させ続けながら、ユーザーが保存したものをすべて捨ててしまいます。
OPFS はブラウザ上の SQLite に何をもたらすのか
OPFS はサンドボックス化されたオリジンスコープのファイルシステムであり、SQLite が実際に必要とするもの、すなわちリロードを越えて生き残る同期的なバイトレベルのファイルアクセスを提供します。これがなければ、Wasm ビルドはデータベースをメモリ上に保持し、リフレッシュ 1 回ですべてが消えます。OPFS があれば、クライアント側に本物の永続的な SQL データベースが手に入ります。これこそが、modern SQLite features をブラウザという文脈で使えるものにしている要素です。
公式パッケージを使ってください。コミュニティによる古い Wasm ポートも存在しますが、@sqlite.org/sqlite-wasm は SQLite プロジェクト自身の Wasm ビルドを ES モジュールとして再公開したものです。その上に載っているのは TypeScript の型定義一式だけです。永続化に関するドキュメントではいくつかのストレージバックエンドが説明されていますが、実務上重要なのは “opfs” VFS と “opfs-sahpool” VFS の 2 つです。他の手段(localStorage 上の kvvfs、“opfs-wl” バリアント、排他ロックを伴う WAL モードなど)も存在し、いずれも同じドキュメントで解説されています。
Worker 内でデータベースを開く
パッケージをインストールしたら、データベース関連の処理はすべて専用の Worker 内で行います。次の例では “opfs-sahpool” VFS を使用しています。この VFS は await sqlite3.installOpfsSAHPoolVfs() で明示的にインストールする必要があります。データベース名は絶対パスに正規化されるため、先頭のスラッシュを一貫して使ってください。
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 }));
このコードが「やっていないこと」に注目してください。OPFS が利用できないときに new sqlite3.oo1.DB(...) へフォールバックしていません。このパターンは公式 README の worker サンプルを含む多くのサンプルコードに登場しますが、これは体裁を装ったデータ損失バグです。アプリは一時的なインメモリデータベースを相手に動き続け、ユーザーは保存を続け、リフレッシュ 1 回ですべてが破壊されます。永続化の初期化に失敗したら、エラーを UI に表面化させ、ユーザーに知らせてください。
UI から Worker と通信する
ライブラリは今もメインスレッドからアクセスするための promiser API をエクスポートしていますが、パッケージの README では 2026-04-15 付の告知で Worker1 および Promiser1 API が非推奨であると明記されています。これらはパッケージ内には残りますが、今後の開発は行われず、メンテナーは利用者をそこから遠ざけています。ドキュメントで示されている道筋は、Worker 内で sqlite3InitModule と oo1 API を使い、自前の薄い postMessage ブリッジを挟む方法です。
// 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 });
});
}
ブリッジのコストは 40 行程度がすべてであり、しかもメッセージの形式は自分でコントロールできます。
つまずきポイント 1: OPFS 版 SQLite は Worker で動かすしかない
OPFS にバックアップされた SQLite はメインスレッドでは動きません。例外はありません。SQLite は同期エンジンであり、それが必要とする同期的なファイルアクセスは FileSystemSyncAccessHandle から得られます。そしてこの API は、同期 I/O が実行スレッドをブロックするというまさにその理由から、専用の Web Worker 内でのみ公開されています。
この制約を尊重せず回避しようとすると、セッションリプレイがその失敗を紛れもなく可視化します。リプレイ上ではクリックやキー入力は記録されているのに、クエリの実行時間のあいだ画面が一切再描画されない——これは同期的な OPFS I/O が UI スレッドをブロックしているときの視覚的なシグネチャです。エンジンを Worker 内に閉じ込めておけば、メインスレッドがクエリを目にすることはありません。
つまずきポイント 2: COOP/COEP ヘッダー要件
デフォルトの “opfs” VFS は、同期的なフロントエンドとその背後にある非同期 worker との間でメッセージをやり取りするために SharedArrayBuffer を使用します。したがって、サーバーは Cross-Origin-Opener-Policy: same-origin と Cross-Origin-Embedder-Policy: require-corp を送信しなければならず、そうでなければ VFS はロードされません。Vite については、公式 README が次の設定を提示しています。必須の optimizeDeps の除外設定も含まれています。
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'],
},
});
本番サーバーでも同じ 2 つのヘッダーが必要です。しかし、ヘッダーを設定できない場合(静的ホスティング、COEP が壊してしまうサードパーティ埋め込みなど)、Service Worker を使ったハックに頼る必要はありません。“opfs-sahpool” VFS は COOP/COEP ヘッダーを一切必要としません。上のコードがこれを使っているのはそのためです。
2 つの OPFS VFS のどちらを選ぶか
複数タブで 1 つのデータベースを共有する必要があり、かつヘッダーをコントロールできるなら “opfs” を、最大限の速度が欲しくヘッダー要件も避けたい、そして接続 1 本で済ませられるなら “opfs-sahpool” を選んでください。SQLite 自身の永続化ドキュメントでは、扱っている OPFS バックエンドの中で sahpool を最速と評価しています。レコードを 1 件保存する程度では違いは体感できませんが、一括処理では確実に感じられます。
| “opfs" | "opfs-sahpool” | |
|---|---|---|
| COOP/COEP ヘッダー | 必要 | 不要 |
| 複数接続/複数タブ | 可能(SQLITE_BUSY のハンドリングが必要) | 不可、同時に 1 つのみ |
| パフォーマンス | 良好 | SQLite のドキュメントによればバッチ処理で最速 |
| 登録 | サポートされていれば自動 | 明示的な installOpfsSAHPoolVfs() |
| Safari 16.4 〜 16.x | WebKit のサブワーカーのバグにより動作しない | 動作する |
“opfs” であっても、マルチタブがタダで手に入るわけではありません。同期アクセスハンドルの取得はファイルを排他的にロックし、読み取りもそのロックを取得します。そのため、2 つ目のタブが同じデータベースを開こうとするとロックエラーが発生し、SQLITE_BUSY あるいは一般的な I/O エラーとして表面化します。これを致命的なものとして扱うのではなく、ハンドリングしましょう。
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));
}
}
}
トランザクションを短く保ち、ステートメントをリセットしておけば、ほどほどのタブ間並行性は機能します。sahpool の場合、2 つ目のタブでの installOpfsSAHPoolVfs() 呼び出しは完全に失敗します。プールがデータベースロックを自分のものとして確保するため、接続数の上限は 1 つです。これを検出し、2 つ目のタブの処理を最初のタブ経由でルーティングしてください。SQLite 3.50 では、この種の協調的なハンドオフのために pauseVfs() と unpauseVfs() が追加されました。
IndexedDB ではなく SQLite を選ぶべきなのはどんなときか
データがリレーショナルであるとき、すなわちエンティティ間の join、集計、アドホックなフィルタリング、本格的な SQL インデックス、あるいは事前構築済みのデータセットを単一のデータベースファイルとして配布し一度だけインポートするといったケースでは、OPFS 上の SQLite に手を伸ばしてください。これらは、IndexedDB ではアプリケーションコード内でクエリエンジンを再実装せざるを得なくなるワークロードです。
一方、キーバリューの状態管理、小さなキャッシュ、数百件程度のレコードにはオーバーキルです。そうした用途に Wasm バイナリと Worker とメッセージブリッジを持ち出す価値はありません。localStorage や素の IndexedDB がちょうどよいサイズです。
まとめ
セットアップコストは確かに存在しますが、有限です。Worker 1 つ、メッセージブリッジ 1 つ、そしてマルチタブアクセスが必要か、ヘッダー不要のデプロイが必要かで決まる VFS の選択 1 つです。単一ドキュメントのローカルファーストアプリなら “opfs-sahpool” から始め、タブ間で共有する必要が出てきたら “opfs” と SQLITE_BUSY のハンドリングに移行してください。そして、OPFS の初期化失敗が黙ってインメモリデータベースへと劣化することは決して許さないでください。
FAQ
OPFS による永続化を備えた SQLite Wasm はどのブラウザでサポートされていますか?
OPFS の同期アクセスハンドルは Chromium 108、Firefox 111、Safari 16.4 以降で利用できます。ただし注意点が 1 つあります。Safari 17 未満のバージョンには WebKit のサブワーカーのバグがあり、デフォルトの 'opfs' VFS が動作しません。SQLite のドキュメントは、その環境でも動作する選択肢として 'opfs-sahpool' を挙げています。Safari 17 以降では両方の VFS が動作します。
事前に構築した SQLite データベースファイルを配布して OPFS に読み込ませることはできますか?
できます。'opfs-sahpool' VFS では、.db ファイルを ArrayBuffer として fetch し、installOpfsSAHPoolVfs() が解決する PoolUtil オブジェクトの importDb() に渡したうえで、通常どおりデータベースを開きます。両方の呼び出しに完全に同一の名前文字列を渡してください。importDb() は与えられた名前をそのまま保存する一方、データベースを開く際には絶対パスに正規化されます。そのため 'data.db' としてインポートしてから '/data.db' を開くと、空のデータベースができあがってしまいます。PoolUtil はバックアップ用にデータベースを取り出す exportFile() と、プールが保持している内容を一覧する getFileNames() も提供しています。これは参照用データセットを単一ファイルとして配布するアプリに適しています。
OPFS にバックアップされた SQLite データベースはどれくらいのデータを保存できますか?
固定的な上限はありません。OPFS のストレージはブラウザが管理するクォータの下にあり、その値は寛大ではあるもののブラウザ、デバイス、利用可能なディスク容量によって変わります。したがって数値を決め打ちせず、実行時に navigator.storage.estimate() を確認してください。プライベートウィンドウやシークレットウィンドウでは永続化が縮小、あるいは完全に無効化される場合があり、サイトデータを消去するとそのオリジンの他のストレージともどもデータベースが削除されます。
デバッグ中に SQLite が作成した OPFS 上のファイルを確認するにはどうすればよいですか?
ブラウザの DevTools は OPFS の中身をネイティブには表示しません。Chrome DevTools 向けの OPFS Explorer 拡張機能を使うと、そのオリジンの OPFS ファイル階層を表示し、個別のファイルをダウンロードできます。なお 'opfs-sahpool' は独自の仮想的な名前マッピングの下で、不透明なプールファイルの中にデータベースを格納します。そのため渡したファイル名がそのまま現れることはありません。これらのデータベースを一覧・抽出するには、PoolUtil の getFileNames() と exportFile() を使ってください。
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