Four variants of the same sync library (IndexedDB, NeDB, SQLite WASM, sql.js) plus a paste-bin demo app for testing multi-browser sync via shared folders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
IndexSyncFile
A local-first key-value and document store for the browser that syncs across multiple browser windows via a user-selected folder on disk.
No server. No backend. No cloud. Just browsers and a shared folder.
The experiment
Can multiple browser tabs (or separate browsers entirely) stay in sync using nothing but a shared folder on the local file system?
This repo explores that question by building the same sync library four times, each with a different in-browser storage engine. The sync layer and public API are identical. Only the local cache differs.
The goal is to compare trade-offs: bundle size, startup speed, query power, persistence behavior, and file:// compatibility.
How it works
Browser A Shared Folder Browser B
--------- ------------- ---------
kv.set("x", 1)
|
+---> writes to local
| cache (fast)
|
+---> writes event file ---> /events/1234_abc.json
<--- sync() reads new events
|
+---> applies to
local cache
- Each browser keeps a fast local cache for reads/writes (IndexedDB, SQLite WASM, NeDB, or sql.js)
- Every mutation is written as an immutable JSON file in
/events/inside a user-selected folder - When any browser calls
sync(), it scans/events/, finds files it hasn't seen, and merges them into its local cache - Conflicts are resolved by last-write-wins (configurable)
Why events?
The event log is what makes multi-browser sync possible. Each mutation is a separate file, so concurrent writes from different browsers never overwrite each other. Both sides' changes are captured and merged on the next sync().
Without events, if two browsers both wrote a single database file, the last save would silently destroy the other browser's changes.
Four variants
Each variant uses a different local cache engine. The API and sync behavior are identical.
| Variant | Local cache | Persistence | file:// works? |
Dependencies |
|---|---|---|---|---|
indexeddb/ |
IndexedDB | Browser-managed | Yes | Zero |
nedb/ |
NeDB in-memory | NDJSON snapshots to /data/ |
Yes | @seald-io/nedb |
sqlite/ |
SQLite WASM | OPFS or in-memory | No (needs HTTP + headers) | @sqlite.org/sqlite-wasm |
sql-js/ |
sql.js (asm.js) | SQLite binary to /data/store.sqlite |
Yes | sql.js |
Which one should I use?
- Just want it to work — use
indexeddb/. Zero deps, works fromfile://, instant startup. - Need MongoDB-style queries (
$gt,$in,$regex) — usenedb/. - Need real SQL + proper numeric range queries — use
sql-js/. Works fromfile://, no special headers. - Need OPFS-backed persistence + SQL — use
sqlite/. Requires HTTP server and may need COOP/COEP headers.
Quick start (same for all variants)
import { FolderSyncDB } from 'index-sync-file'; // pick your variant
const db = await FolderSyncDB.open({
autoSyncIntervalMs: 5000,
});
// User picks a folder (one-time, persisted across sessions)
await db.selectFolder();
// Key-value
await db.kv.set('theme', 'dark');
const theme = await db.kv.get('theme'); // 'dark'
// Document collections with indexes
const todos = db.collection({
name: 'todos',
indexes: [{ name: 'byStatus', fields: ['status'] }],
});
await todos.put({ id: '1', title: 'Ship it', status: 'active' });
await todos.put({ id: '2', title: 'Test it', status: 'done' });
const active = await todos.findByIndex('byStatus', 'active');
// [{ id: '1', title: 'Ship it', status: 'active' }]
// Listen for changes (including from other browsers)
db.on('change', (e) => console.log('changed:', e));
db.on('conflict', (e) => console.log('conflict resolved:', e));
Folder layout on disk
your-selected-folder/
events/
1710000000000_a1b2c3d4e5f6.json <-- immutable event files
1710000001000_f6e5d4c3b2a1.json
...
data/ <-- nedb and sql-js variants
kv.db <-- nedb: NDJSON snapshot
docs.db
store.sqlite <-- sql-js: full SQLite database file
Demo app: Paste
The paste/ folder contains a working paste-bin demo app built with all four variants. Each compiles to a single self-contained HTML file that you can open directly from disk (except the SQLite WASM variant, which needs HTTP).
See the paste README for details.
Browser requirements
Requires the File System Access API (showDirectoryPicker). Supported in:
- Chrome 86+
- Edge 86+
- Brave
- Opera 72+
Firefox and Safari do not support showDirectoryPicker. The library throws a clear error if the API is unavailable.
Building
Each variant is a self-contained npm package:
cd indexeddb && npm install && npm run build
cd nedb && npm install && npm run build
cd sqlite && npm install && npm run build
cd sql-js && npm install && npm run build
The paste demo uses Bun:
cd paste && bun run build.ts
API reference
See the README in each variant's folder for variant-specific details.
FolderSyncDB
| Method | Description |
|---|---|
FolderSyncDB.open(options?) |
Create and initialize the database |
selectFolder() |
Prompt user to pick a sync folder |
hasFolderAccess() |
Check if folder permission is granted |
requestFolderAccess() |
Re-request permission (needs user gesture) |
sync() |
Manually trigger a sync with the folder |
close() |
Stop auto-sync, flush writes, release resources |
kv |
Key-value store (see below) |
collection(options) |
Create/get a document collection (see below) |
on(event, handler) |
Listen for events, returns unsubscribe function |
kv (Key-Value Store)
| Method | Description |
|---|---|
get(key) |
Get value by key |
set(key, value) |
Set a key-value pair |
delete(key) |
Delete a key |
has(key) |
Check if key exists |
keys() |
Get all keys |
entries() |
Get all [key, value] pairs |
collection(options) (Document Collections)
| Method | Description |
|---|---|
get(id) |
Get document by ID |
put(doc) |
Insert or update a document (must have id field) |
delete(id) |
Delete a document (tombstone) |
all() |
Get all documents |
findByIndex(indexName, value) |
Exact-match query on an index |
queryByIndex(indexName, range) |
Range query ({ gte, lte, gt, lt }) |
Events
| Event | Payload | When |
|---|---|---|
sync:start |
-- | Sync begins |
sync:end |
{ imported } or { error } |
Sync completes |
change |
{ type, store, key, id, data } |
Any data mutation (local or synced) |
conflict |
{ filename, event } |
A conflict was detected and resolved |
folder:lost-permission |
error? | Folder access was lost |