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.