The page looks identical when this lesson ends. Same three lines, same version
number. What changes is that your page no longer knows what a wasm bundle is:
it calls db.init(), gets a plain object back, and every mention of DuckDB has
moved behind a boundary. By the end there are two workers on the page, and you
will be able to say exactly what each one is for. Checkpoint:
engine-02.
The reason that isn’t the reason
The standard justification for putting a database in a web worker is that the
engine would otherwise block the main thread. For DuckDB that is already
handled — lesson 1 constructed AsyncDuckDB around a worker the library
supplies, and the engine has been off your main thread since the first query.
So what is left on it? Everything that is not the engine:
- assembling SQL strings
- decoding Arrow record batches into JavaScript values
- mapping thousands of rows into arrays and objects
- and, from the next lesson, generating a twenty-thousand-row
INSERT
That work is yours, it grows with your data, and right now it runs between your user and their next frame. That is what moves. The engine was never the problem; the marshalling is.
The second reason is architectural and matters more over nine lessons. Once every database call goes through one interface, the page cannot reach around it. There is exactly one place where SQL exists, one place to add a query, and one file to read when a result looks wrong.
One interface, both sides
Create src/types.ts and put the contract in it — this file is the only thing
the two sides agree on:
/** Timings and environment facts captured while bringing DuckDB up. */
export interface InitMetrics {
bundle: 'eh' | 'mvp';
duckdbVersion: string;
instantiateMs: number;
}
/** Contract for everything db-worker.ts exposes over Comlink. */
export interface DbWorkerApi {
init(): Promise<InitMetrics>;
}
Neither side imports the other. The worker implements DbWorkerApi; the page
consumes it. That makes the interface the single place a mismatch can appear,
and TypeScript catches it at compile time rather than as undefined is not a function in a browser.
The worker asserts its half with satisfies, at the bottom of
src/db-worker.ts:
import { expose } from "comlink";
expose({
init,
} satisfies DbWorkerApi);
satisfies is doing real work. It checks the object against the interface
without widening its type, so forgetting to expose a method you declared is a
build error, and exposing one with the wrong signature is too.
The page’s half is nine lines, in src/db.ts, and it never changes again for
the rest of the course:
import { wrap } from "comlink";
import type { DbWorkerApi } from './types.js';
const worker = new Worker(
new URL("./db-worker.js", import.meta.url),
{ type: "module" }
);
export const db = wrap<DbWorkerApi>(worker);
wrap<DbWorkerApi> returns a proxy typed as Remote<DbWorkerApi>: every method
keeps its name and arguments, and every return type becomes a promise. Calling
db.init() posts a message, waits for the reply, and resolves. You get
autocomplete across a thread boundary.
What survives the crossing
Comlink hides the message passing; it does not repeal its rules. Arguments and
return values go through the structured clone algorithm, which handles
primitives, plain objects, arrays, Date, Map, Set, and typed arrays — and
refuses functions, DOM nodes, class instances with methods, and anything
holding a closure.
This is why init() returns InitMetrics — a flat bag of numbers and strings —
rather than the AsyncDuckDB instance. The connection object cannot cross and
should not: keeping it inside the worker is what makes the boundary meaningful.
Every method you add for the rest of this course obeys the same discipline, and
it is the reason the API ends up shaped the way it does.
Two workers, and which is which
Move the bundle table and the boot sequence into db-worker.ts unchanged, wrap
them in a function that returns metrics, and the page shrinks to this:
import { db } from './db.js';
async function main(): Promise<void> {
const metrics = await db.init();
log(`Selected the ${metrics.bundle} bundle.`);
log(`Instantiated in ${metrics.instantiateMs.toFixed(0)} ms.`);
log(`DuckDB ${metrics.duckdbVersion} is answering queries.`);
}
Open DevTools and look at the Sources or Threads panel. There are now two workers: yours, and the one DuckDB created inside it. Nested workers look redundant until you name what runs where — the engine runs in DuckDB’s, your marshalling runs in yours, and the main thread runs neither.
Note also what did not need changing: the bundle table, selectBundle, the
instantiate call. Worker code is ordinary module code. It has no DOM, but it
has fetch, performance, Worker, and everything else you used.
Build it
| File | Action | What goes in it |
|---|---|---|
package.json |
modify | Add comlink to dependencies |
src/types.ts |
write | InitMetrics and DbWorkerApi |
src/db-worker.ts |
write | The bundle table and boot sequence from lesson 1, an init() returning InitMetrics, and the expose({ … } satisfies DbWorkerApi) call |
src/db.ts |
write | The wrapped worker client, exported as db |
src/index.ts |
modify | Delete every DuckDB import; call db.init() and log the metrics |
Done when: the page still reports the bundle, instantiate time, and version,
src/index.ts contains no reference to @duckdb/duckdb-wasm, and DevTools
shows two workers for the page.
Answer key: engine-01...engine-02.
Challenge: try to return the connection
Add getConnection(): Promise<unknown> to DbWorkerApi, implement it by
returning the live conn object, and call it from the page. Read the error
carefully — it names the exact structured-clone failure, and it is the most
useful error in this whole architecture. Then delete it. Knowing which things
cannot cross a worker boundary is what stops you designing an API that cannot
work.
Next lesson: a table, twenty thousand rows, and one INSERT statement that
would be a serious mistake under slightly different circumstances.