Right now every visit pays the seeding cost, because the database lives in
memory and memory does not survive a reload. By the end of this lesson the
database is a file in private browser storage, the second visit restores twenty
thousand rows instead of generating them, and the page tells you which of those
two things just happened. Checkpoint:
engine-04.
OPFS is a filesystem, not a key-value store
The Origin Private File System is browser storage with real file semantics —
seekable, writable at offsets, private to your origin, and invisible in the
user’s file picker. That distinction is the reason it exists here. A database
engine does not want to serialize itself into a blob and hand it to
localStorage; it wants to write pages at offsets in a file. OPFS is the first
browser storage that lets it.
DuckDB takes a path with an opfs:// scheme and treats it as exactly that:
const OPFS_PATH = 'opfs://sales-demo.duckdb';
const tOpen = performance.now();
let persistent = true;
try {
await db.open({
path: OPFS_PATH,
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});
} catch (error) {
console.warn('DB Worker: OPFS unavailable, using in-memory database:', error);
persistent = false;
await db.open({});
}
conn = await db.connect();
const openMs = performance.now() - tOpen;
db.open({}) with no path is the in-memory database you have been using for
three lessons — it was there all along as the default.
Two ways this legitimately fails
The fallback is not superstition. There are two real conditions where opening the file throws, and they are different from each other.
The first is capability: a browser without OPFS, or a context where it is unavailable. The second is contention, and it is the one that will actually happen to you: a second tab on the same origin. The database file is held for read-write access by whoever got there first, and the second tab’s open call fails. Falling back to memory means that tab still works — with its own throwaway data — instead of showing an error page.
Which is why persistent is reported rather than assumed. The page says
“persistent (OPFS)” or “in-memory”, and if you open a second tab you will watch
it say the second thing. A UI that silently pretends data was saved is worse
than one that admits it was not.
Seed once, restore forever
With a file behind it, init() can answer a question it could not before: is
there already data here?
const existingRows = Number(
(await conn.query(`SELECT COUNT(*) AS c FROM sales;`)).toArray()[0].c
);
return { bundle: bundleKind, duckdbVersion, instantiateMs, openMs, persistent, existingRows };
InitMetrics grows three fields — openMs, persistent, existingRows — and
the page turns the last one into a branch:
if (metrics.existingRows === 0) {
log(`Empty database — seeding ${SEED_ROWS.toLocaleString()} rows…`);
const seed = await db.seedRandom(ITEMS, DATE_MIN, DATE_MAX, SEED_ROWS);
log(`Inserted ${seed.insertedRows.toLocaleString()} rows in ${seed.seedMs.toFixed(0)} ms.`);
} else {
log(`Restored ${metrics.existingRows.toLocaleString()} rows — no seeding needed.`);
}
That is the whole persistence strategy: an empty table means a first visit.
CREATE TABLE IF NOT EXISTS from the previous lesson is what makes the count
safe to run before you know whether the table existed.
The write that actually reaches the disk
One line in the seeder is doing more than it looks:
await conn.query(`INSERT INTO sales VALUES ${values.join(",")};`);
// Flush to the OPFS file so the data survives reloads
await conn.query('CHECKPOINT;');
CHECKPOINT forces the write-ahead log into the database file. Without it your
rows are durable in DuckDB’s sense — the WAL has them — but you are relying on a
flush happening before the tab closes, and tabs close abruptly. Being explicit
costs nothing here, because seeding happens once.
Two more small methods land now because they belong to the same subject.
resetData deletes every row and checkpoints, which is what a reseed control
will call in the dashboard course. getStorageInfo asks the browser how much
origin storage you are using:
async function getStorageInfo(): Promise<StorageInfo> {
if ('storage' in navigator && navigator.storage?.estimate) {
const estimate = await navigator.storage.estimate();
return { usageBytes: estimate.usage ?? null, quotaBytes: estimate.quota ?? null };
}
return { usageBytes: null, quotaBytes: null };
}
Nullable both ways, because estimate() is not universal and its numbers are
deliberately imprecise. Twenty thousand rows land under a megabyte, which is a
useful thing to have seen with your own eyes before you reason about limits.
Build it
| File | Action | What goes in it |
|---|---|---|
src/types.ts |
modify | openMs, persistent, existingRows on InitMetrics; new StorageInfo; add resetData and getStorageInfo to DbWorkerApi |
src/db-worker.ts |
modify | OPFS_PATH, the open try/catch with the in-memory fallback, openMs, the existing-rows count, CHECKPOINT after seeding, resetData, getStorageInfo |
src/index.ts |
modify | Report persistence and open time; seed only when existingRows is 0; print storage usage |
Done when: the first load says it seeded 20,000 rows, and a reload says
Restored 20,000 rows — no seeding needed with a non-zero storage figure.
Answer key: engine-03...engine-04.
Challenge: open a second tab
Load the page, then open the same URL in a second tab and read what each one
reports. One will be persistent, the other in-memory, and the fallback you wrote
is the only reason the second one renders at all. Then find the OPFS entry in
DevTools under Application → Storage, note the file size against your row count,
and call resetData() from the console to watch a reload seed from scratch
again.
Next lesson: filters, and the first values that do not come from your own source code.