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.