Запуск SQLite в браузере с OPFS
Используйте SQLite в браузере с OPFS: настройте Worker, выберите opfs или opfs-sahpool и не допускайте тихого перехода в память.
SQLite, скомпилированный в WebAssembly, по умолчанию работает целиком в памяти, поэтому каждая записанная строка исчезает при обновлении страницы, если база данных не опирается на постоянный VFS, — и именно Origin Private File System (OPFS) обеспечивает такую постоянность в официальной сборке.
Чтобы к этому прийти, потребуется чуть больше, чем один импорт и запрос. В этой статье разбирается реальная стоимость настройки с официальным пакетом @sqlite.org/sqlite-wasm: открытие постоянной базы данных в Worker, её подключение к UI, два ограничения, о которые спотыкаются первые реализации, и как выбрать между двумя пригодными для продакшена VFS.
Ключевые выводы
- Официальная сборка публикуется в npm как @sqlite.org/sqlite-wasm и, в отличие от sql.js, поддерживается самим проектом SQLite, а постоянство на базе OPFS встроено в неё изначально.
- Дескрипторы синхронного доступа OPFS существуют только в потоках Worker, поэтому базу данных на основе OPFS никогда нельзя открыть в основном потоке.
- VFS «opfs» по умолчанию требует заголовков COOP и COEP, поскольку зависит от SharedArrayBuffer; VFS «opfs-sahpool» не требует заголовков вообще.
- opfs-sahpool — самый быстрый вариант OPFS для пакетных операций, но допускает только одно открытое соединение, поэтому вторая вкладка, открывающая ту же базу данных, завершится ошибкой.
- Никогда не переключайтесь молча на базу данных в памяти, когда OPFS недоступен; так приложение продолжит работать, отбрасывая всё, что сохраняет пользователь.
Что OPFS даёт SQLite в браузере?
OPFS — это изолированная файловая система в пределах origin, которая даёт SQLite именно то, что ему нужно: синхронный побайтовый доступ к файлам, сохраняющийся между перезагрузками. Без него Wasm-сборка держит базу данных в памяти, и обновление страницы её стирает. С ним вы получаете настоящую надёжную SQL-базу данных на клиенте — тот самый элемент, который вообще делает современные возможности SQLite применимыми в контексте браузера.
Используйте официальный пакет. Существуют более старые сообществом сделанные Wasm-порты, но @sqlite.org/sqlite-wasm — это собственная Wasm-сборка проекта SQLite, переопубликованная как ES-модуль. Единственное, что добавлено сверху, — набор TypeScript-типов. В документации по постоянству описано несколько бэкендов хранения; на практике значимы два — VFS «opfs» и VFS «opfs-sahpool». Есть и другие пути (kvvfs через localStorage, вариант «opfs-wl», режим WAL с эксклюзивной блокировкой) — все они описаны в том же документе.
Открытие базы данных внутри Worker
Установите пакет, а затем выполняйте всю работу с базой данных в выделенном Worker. В этом примере используется VFS «opfs-sahpool», который необходимо устанавливать явно через 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 }));
Обратите внимание, чего этот код не делает: он не откатывается к new sqlite3.oo1.DB(...), когда OPFS недоступен. Такой шаблон встречается во многих примерах кода, в том числе в примере worker из официального README, и это замаскированный баг с потерей данных. Приложение продолжает работать с временной базой данных в памяти, пользователь продолжает сохранять, а обновление страницы уничтожает всё. Если инициализация постоянного хранилища не удалась, покажите ошибку в UI и сообщите об этом пользователю.
Взаимодействие с Worker из UI
Библиотека по-прежнему экспортирует promiser API для доступа из основного потока, но README пакета помечает API Worker1 и Promiser1 как устаревшие в уведомлении от 15.04.2026. Они остаются в пакете, дальше не развиваются, и мейнтейнеры отводят от них пользователей. Документированный путь — sqlite3InitModule плюс oo1 API внутри Worker и тонкий собственный мост на 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 });
});
}
Сорок строк моста — это вся цена, и вы полностью контролируете формат сообщений.
Ловушка первая: SQLite с OPFS обязан работать в Worker
SQLite на основе OPFS не может работать в основном потоке — и точка. SQLite — синхронный движок, а нужный ему синхронный доступ к файлам обеспечивает FileSystemSyncAccessHandle, который платформа предоставляет только внутри выделенных Web Worker — именно потому, что синхронный ввод-вывод блокирует поток, в котором выполняется.
Когда это ограничение обходят, а не соблюдают, session replay делает сбой абсолютно очевидным: в записи видно, как регистрируются клики и нажатия клавиш, тогда как ничего не перерисовывается на протяжении всего запроса — визуальная подпись синхронного ввода-вывода OPFS, блокирующего поток UI. Держите движок в Worker, и основной поток никогда не увидит запроса.
Ловушка вторая: требование заголовков COOP/COEP
VFS «opfs» по умолчанию использует SharedArrayBuffer для передачи сообщений между своим синхронным фронтендом и асинхронным worker’ом за ним, поэтому сервер должен отдавать 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'],
},
});
Продакшен-серверам нужны те же два заголовка. Но если вы не можете задавать заголовки (статический хостинг, сторонние встраивания, которые ломает COEP), обходной путь через service worker не потребуется: VFS «opfs-sahpool» не требует заголовков COOP/COEP вообще — именно поэтому он используется в коде выше.
Выбор между двумя VFS на основе OPFS
Выбирайте «opfs», когда несколько вкладок должны разделять одну базу данных и вы управляете заголовками; выбирайте «opfs-sahpool», когда нужна максимальная скорость без требований к заголовкам и вы готовы жить с единственным соединением. Собственная документация по постоянству SQLite оценивает sahpool как самый быстрый из рассматриваемых OPFS-бэкендов. При сохранении одной записи разницы вы не почувствуете. Почувствуете — на объёмных операциях.
| «opfs» | «opfs-sahpool» | |
|---|---|---|
| Заголовки COOP/COEP | Требуются | Не требуются |
| Несколько соединений/вкладок | Да, с обработкой SQLITE_BUSY | Нет, по одному за раз |
| Производительность | Хорошая | Самая быстрая для пакетных операций, по документации SQLite |
| Регистрация | Автоматическая при поддержке | Явный вызов installOpfsSAHPoolVfs() |
| Safari с 16.4 по 16.x | Не работает из-за бага с sub-worker в WebKit | Работает |
Работа с несколькими вкладками не бесплатна даже на «opfs». Получение дескриптора синхронного доступа эксклюзивно блокирует файл, и чтение тоже берёт эту блокировку, поэтому вторая вкладка, открывающая ту же базу данных, натыкается на ошибку блокировки, которая проявляется как SQLITE_BUSY или как общая ошибка ввода-вывода. Обрабатывайте её, а не считайте фатальной:
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 вызов installOpfsSAHPoolVfs() во второй вкладке падает сразу: пул забирает блокировку базы данных себе, поэтому одно соединение — это предел. Обнаруживайте эту ситуацию и направляйте вторую вкладку через первую. В SQLite 3.50 для такой кооперативной передачи добавлены pauseVfs() и unpauseVfs().
Когда стоит выбрать SQLite вместо IndexedDB?
Берите SQLite поверх OPFS, когда ваши данные реляционные: соединения между сущностями, агрегаты, произвольная фильтрация, полноценные SQL-индексы или поставка предварительно собранного набора данных как единого файла базы, который вы импортируете один раз. Это те задачи, где IndexedDB вынуждает вас переизобретать движок запросов в прикладном коде.
Это перебор для key-value состояния, небольших кэшей или пары сотен записей. Такая работа не оправдывает Wasm-бинарник, Worker и мост сообщений; localStorage или обычный IndexedDB здесь подходят по размеру.
Подводя итоги
Стоимость настройки реальна, но ограничена: один Worker, один мост сообщений и одно решение по VFS, которое зависит от того, нужен ли вам доступ из нескольких вкладок или развёртывание без заголовков. Начните с «opfs-sahpool» для однодокументного local-first приложения, переходите на «opfs» плюс обработку SQLITE_BUSY, когда вкладкам нужно разделять базу, и никогда не позволяйте сбою инициализации OPFS молча деградировать до базы данных в памяти.
Часто задаваемые вопросы
Какие браузеры поддерживают SQLite Wasm с постоянством на OPFS?
Дескрипторы синхронного доступа OPFS доступны начиная с Chromium 108, Firefox 111 и Safari 16.4. Одна оговорка: версии Safari ниже 17 содержат баг с sub-worker в WebKit, который ломает VFS 'opfs' по умолчанию, и документация SQLite указывает на 'opfs-sahpool' как на вариант, который там всё же работает. Safari 17 и новее поддерживает оба VFS.
Можно ли поставлять предварительно собранный файл базы данных SQLite и загружать его в OPFS?
Да. С VFS 'opfs-sahpool' получите файл .db как ArrayBuffer и передайте его в importDb() у объекта PoolUtil, которым разрешается installOpfsSAHPoolVfs(), а затем откройте базу данных обычным образом. Передавайте в оба вызова идентичную строку имени: importDb() сохраняет имя точно в том виде, в каком вы его дали, тогда как открытие базы данных нормализует его до абсолютного пути, поэтому импорт 'data.db' и последующее открытие '/data.db' оставят вас с пустой базой данных. PoolUtil также предоставляет exportFile() для извлечения базы данных на резервное копирование и getFileNames() для перечисления того, что содержит пул. Это подходит приложениям, которые поставляют справочные наборы данных одним файлом.
Сколько данных может хранить база данных SQLite на основе OPFS?
Фиксированного предела нет. Хранилище OPFS подпадает под управляемые браузером квоты, которые щедры, но различаются в зависимости от браузера, устройства и доступного места на диске, поэтому проверяйте navigator.storage.estimate() во время выполнения, а не предполагайте конкретное число. Приватные окна и режим инкогнито могут сократить или полностью исключить постоянство, а очистка данных сайта удаляет базу данных вместе с остальным хранилищем origin.
Как посмотреть файлы OPFS, которые создаёт SQLite, во время отладки?
Браузерные DevTools не показывают содержимое OPFS штатно. Расширение OPFS Explorer для Chrome DevTools отображает иерархию файлов OPFS для origin и позволяет скачивать отдельные файлы. Учтите, что 'opfs-sahpool' хранит базы данных внутри непрозрачных файлов пула по собственному виртуальному отображению имён, поэтому переданное вами имя файла напрямую не появится; вместо этого используйте getFileNames() и exportFile() из PoolUtil, чтобы перечислить и извлечь эти базы данных.
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