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.