In the browser
duckweb_client_shell(config) returns a skeleton that boots a SharedWorker, loads the wasm tier of duckweb and renders the same routes in the browser. The config says which extensions to load, which database to attach, and any setup statements; %ORIGIN% in a setup statement is replaced with the origin the page was served from, since an app cannot know it. Pass an empty string for the default. Mount it on whichever routes should render client-side:
UNION ALL SELECT duckweb_client_shell(
'{"start":"/","extensions":["/_duckweb/httpfs.duckdb_extension.wasm","/_duckweb/duckweb.duckdb_extension.wasm"],'
|| '"app":"/_duckweb/app.duckdb"}')
FROM duckweb_match(route, '/client') WHERE method = 'GET'The SharedWorker is a classic worker, not a module one: Firefox does not support module shared workers, and the browser suite runs under both engines so that cannot regress unnoticed.
One DuckDB per browser, not per tab: a second tab connects to the same worker and pays nothing for it. The asset directory must hold duckdb-blocking.mjs, duckdb-base.wasm, httpfs.duckdb_extension.wasm, duckweb.duckdb_extension.wasm and app.duckdb; nix build .#duckweb-assets assembles exactly that.
The client identifies itself with X-Duckweb-Client: 1. An app should emit a whole document when that header is absent and a fragment when it is present — the client renders into a shell it already has, and a document inside a <div> is not one.
Service-worker rendering, and remote data
The SharedWorker above cannot answer a navigation: the page fetches its own content. A service worker can, because it intercepts the navigation itself — GET / goes into the worker, the render macro answers it, and the browser gets a normal HTML response. The URL bar, back button and view-source all behave.
The obstacle is data. ServiceWorkerGlobalScope has no XMLHttpRequest, and httpfs reaches for a synchronous one, so remote reads fail there on a stock duckdb-wasm build. The way through is ducklings, which builds DuckDB with Emscripten’s Asyncify: DuckDB’s synchronous internals unwind out to JS, fetch runs, and the stack rewinds when it resolves. That build reads parquet, ducklake and S3 from a service worker.
Asyncify’s price is that dynamic extension loading is compiled out, so duckweb is statically linked instead of loaded. Its C-API entrypoint is called from the generated main.cpp with an access struct synthesised from DatabaseInstance::GetExtensionAPIV1() — the same function table the dynamic loader would have passed. Any extension following the <name>_init_c_api convention can come along the same way; svg_charts does.
Linking more than one of them needs --allow-multiple-definition: every Rust staticlib carries its own copy of the Rust runtime, so a second one redefines rust_eh_personality and friends. They are the same code from the same compiler, so taking the first is correct; a single crate re-exporting both entrypoints would avoid the flag entirely. Archive order matters too — an archive only yields members for symbols undefined at the point it is reached, so CRoaring has to come after ducklake, which is what references it.
Rules for driving that build from JS
Call exports through ccall(..., {async: true}). Asyncify unwinds the stack out of wasm, so a bare await Module._duckdb_query(...) awaits a plain integer and returns before the query has run. The symptom is a coherent, misleading error — IO Error: HEAD request failed - no response packet — with the fetch resolving seconds later into nothing. Only ccall’s async mode hands back the Asyncify.whenDone() promise.
Split 64-bit arguments into low/high pairs. The build sets WASM_BIGINT=0, so each idx_t is legalized into two i32 parameters. duckdb_wasm_result_value_to_string(res, col, row) is called as (res, col, 0, row, 0). Getting this wrong puts the row index into the column’s high word, and every row after the first silently reads back NULL.
Build with DUCKLINGS_NO_ASYNCIFY_REMOVE=1. The default strategy instruments everything except a hand-written list, and any function on that list sitting on the stack when a fetch suspends traps as RuntimeError: unreachable, killing the worker with no error anywhere. WriteAheadLog is on the list, which is enough to break every ATTACH of a remote database.
Building it
nix build .#duckweb-sw-wasm. Everything comes from nixpkgs’ emscripten — no container, and none is possible anyway, since emsdk’s prebuilt clang and node will not run on NixOS. Two things the sandbox exposes that a dev machine hides: httpfs’ crypto.hpp includes openssl/rand.h, so the openssl headers have to be staged even though no openssl symbol survives the link, and the build post-processes its own JS with perl. A cold build is slow — fetchSubmodules pulls avro, iceberg, and a separate full DuckDB checkout for every extension.
What a host has to serve
A remote ATTACH — including a ducklake catalog — probes for a <db>.wal sidecar. DuckDB opens it with “null if it does not exist”, and httpfs implements that by catching the 404 inside OpenFileExtended. In this build that catch does not take effect, because the request it spans suspends and rewinds through Asyncify; exceptions that do not span a suspend propagate normally, as a missing parquet file still reports a clean error. So the host must serve <db>.wal as an empty 200 with content-length: 0, not a 404 and not a 204. Reading parquet directly needs none of this.