The end of this lesson is four lines of text on a white page: which build of DuckDB your browser chose, how long it took to instantiate, and the version number of a database engine that is now running inside your tab. It is not much to look at. It is a real analytical database, fetched as a static file, answering SQL with no server anywhere in the picture. Checkpoint: engine-01.

Start with an empty directory and a manifest. Three runtime dependencies, two dev dependencies, nothing else:

{
  "name": "duckdb-wasm-data-explorer",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@duckdb/duckdb-wasm": "^1.30.0",
    "vite-plugin-top-level-await": "^1.6.0",
    "vite-plugin-wasm": "^3.5.0"
  },
  "devDependencies": {
    "typescript": "^7.0.2",
    "vite": "^7.0.5"
  }
}

Two of those five exist only to teach the bundler about WebAssembly, which is the first thing worth understanding.

Two builds, and the one your browser gets

DuckDB does not ship one wasm binary. It ships several, and they differ in which WebAssembly features they assume the browser supports. The one that matters here is exception handling: the eh build uses it and is smaller and faster, and the mvp build is the conservative fallback for engines without it.

You do not pick. You describe both and let the library decide:

import * as duckdb from '@duckdb/duckdb-wasm';

const DUCKDB_BUNDLES: duckdb.DuckDBBundles = {
    mvp: {
        mainModule: new URL('@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm', import.meta.url).href,
        mainWorker: new URL('@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js', import.meta.url).href,
    },
    eh: {
        mainModule: new URL('@duckdb/duckdb-wasm/dist/duckdb-eh.wasm', import.meta.url).href,
        mainWorker: new URL('@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js', import.meta.url).href,
    },
};

const bundle = await duckdb.selectBundle(DUCKDB_BUNDLES);

selectBundle feature-detects and returns one entry. Each entry names two files: the wasm module itself, and a worker script — hold on to that second one, it matters in a moment.

The new URL(specifier, import.meta.url) pattern is not decoration. It is the form bundlers recognize as “this is an asset reference, rewrite it to wherever you put the file.” Write these as plain strings and they will resolve in dev and 404 in production.

Why Vite needs to be told about wasm

Both plugins in that manifest are here because a bundler’s default assumptions about JavaScript are wrong for this dependency:

import { defineConfig } from 'vite';
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";

export default defineConfig({
    plugins: [wasm(), topLevelAwait()],
    build: { target: 'esnext' },
    worker: {
        format: 'es',
        plugins: () => [wasm(), topLevelAwait()],
    },
    optimizeDeps: {
        exclude: ['@duckdb/duckdb-wasm'],
        esbuildOptions: { target: 'esnext', supported: { 'top-level-await': true } },
    },
});

Four things are being fixed. vite-plugin-wasm handles the .wasm import mechanics. vite-plugin-top-level-await exists because instantiating a wasm module is asynchronous and the resulting code awaits at module scope, which older output targets cannot express — hence target: 'esnext' in three places.

The worker block is the one people miss. Worker bundles are built through a separate pipeline with its own plugin list, so plugins registered for the main build do not apply there. Omit it and the app works in dev and breaks on npm run build, which is the worst available failure mode.

optimizeDeps.exclude keeps esbuild’s dependency pre-bundling away from the package entirely, so it cannot inline the glue code and break those asset URLs.

The engine is already off your main thread

Here is the whole program:

const bundle = await duckdb.selectBundle(DUCKDB_BUNDLES);
const kind = bundle.mainModule.includes('duckdb-eh') ? 'eh' : 'mvp';
log(`Selected the ${kind} bundle.`);

const t0 = performance.now();
const worker = new Worker(bundle.mainWorker!, { type: 'module' });
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(bundle.mainModule);
log(`Instantiated in ${(performance.now() - t0).toFixed(0)} ms.`);

const conn = await db.connect();
const rows = (await conn.query('PRAGMA version;')).toArray();
log(`DuckDB ${rows[0].library_version} is answering queries.`);
await conn.close();

Read the third and fourth lines again. You construct a Worker from bundle.mainWorker, and AsyncDuckDB wraps it. The engine never runs on your main thread — not after some later optimization, but from this first line. The Async in the class name is the tell: every method call is a message to that worker and a promise back.

This matters because the usual next step in a tutorial is “now move the database into a web worker so it doesn’t block the UI,” and that reason is already false. There is a real reason to add a worker of your own, it is a different reason, and it is the whole of the next lesson.

instantiate() is where the 34 MB binary is fetched and compiled — around 7.7 MB over the wire once gzipped, and cached by the browser afterwards. That number is not small and this course will not pretend otherwise. It buys a complete analytical engine; whether that trade is right depends on what you do with it, and you cannot judge that until lesson 7.

Build it

File Action What goes in it
package.json write The five dependencies and the four scripts above
tsconfig.json write strict: true, target/module ES2022+, moduleResolution: "Bundler", noEmit, include: ["src", "vite.config.ts"]
vite.config.ts write Both plugins, in both the main and worker pipelines, with the esnext targets
.gitignore write node_modules/, dist/, .env
index.html write An hgroup heading and a <pre id="out" aria-live="polite"> for output
src/index.ts write The bundle table, the boot sequence, and a log() that appends to the <pre>
src/style.css write Plain page styles — this is not the design lesson
README.md, LICENSE write Project description and MIT

Done when: npm run dev shows three lines naming the selected bundle, an instantiate time in milliseconds, and a DuckDB version, and both npm run typecheck and npm run build complete without errors.

Answer key: the whole tree at engine-01.

Challenge: make selectBundle lie

Swap the two entries in DUCKDB_BUNDLES so eh points at the mvp files and mvp points at the eh ones, then reload and read what the page reports. You have just learned that the label is yours and the detection is not — the library picks a key, and trusts you about what is behind it. Put it back, then check the network panel and note which .wasm was actually fetched and how long it took on a cold cache.

Next lesson: a second worker, one typed contract, and the real reason to move code off the main thread.