DuckDB-Wasm でブラウザ内 SQL を動かす
DuckDB-Wasm をブラウザに埋め込み、SQL をクライアント完結で実行するパターン。jsDelivr から読み込まれるファイルの実測(WASM は転送時 6.8 MB・展開後 34 MB、coi は含まれない)、CDN の worker を直接起動すると SecurityError になる理由と回避、COOP/COEP、4GB の memory 上限、Parquet を URL 直読みする時の CORS まで。
検証日: 2026-09-22(初版 2026-05-26)
使用バージョン:
@duckdb/duckdb-wasm@1.33.1-dev45.0(DuckDBv1.5.1)対象: ブラウザだけで分析クエリを動かしたい(サーバー不要)、Parquet を直接読みたい、ローカル CSV 解析
DuckDB(列指向の高速分析 SQL DB)を WASM(WebAssembly) ビルドでブラウザに埋め込み、サーバーなしで SQL を実行するパターン。DDL / INSERT / SELECT / Parquet 読み込み、WASM ロードの落とし穴を動く demo で確認します。
触って試す
初回は WASM(転送時 約 6.8 MB)を fetch するため、回線によって 1〜数秒待ちます。
セットアップ(npm + 3 種類の WASM bundle)
npm i @duckdb/duckdb-wasm
| 項目 | 値 |
|---|---|
| 公開バージョン | 1.33.1-dev45.0(SELECT version() は v1.5.1 を返す。2026-09-22 実測) |
| Node 必要バージョン | 18+(主用途はブラウザ) |
| TypeScript 型 | 本体同梱 |
| ブラウザ要件 | WebAssembly + Web Worker(eh は WebAssembly Exception Handling。SharedArrayBuffer が要るのは coi だけ) |
| WASM サイズ | eh = 展開後 34.1 MB / 転送時(brotli)6.8 MB、mvp = 39.2 MB / 7.7 MB(下の実測)。main bundle に同梱厳禁 |
| ライセンス | MIT |
DuckDB-Wasm は環境別に 3 種類の WASM bundle(mvp / eh / coi)を持っています。ただし getJsDelivrBundles() が返すのは mvp と eh の 2 つだけで、selectBundle() はこの 2 つからブラウザの機能に合わせて選びます(Chromium では eh)。coi を使うには bundle を手動で定義します:
| bundle | 位置づけ | 必要なブラウザ機能 | getJsDelivrBundles() に含まれるか |
|---|---|---|---|
mvp | fallback | WebAssembly のみ | 含まれる |
eh | 標準(現行ブラウザで選ばれる) | WebAssembly Exception Handling | 含まれる |
coi | マルチスレッド | SharedArrayBuffer + Cross-Origin Isolation(COOP/COEP ヘッダ要) | 含まれない(duckdb-coi.wasm / duckdb-browser-coi.worker.js / duckdb-browser-coi.pthread.worker.js は dist にあるので手動で定義する) |
3 つの速度差はこの記事では測っていません(公式の説明では coi がスレッドを使える分だけ速いとされています)。
coi を使うには HTTP レスポンスに 2 つのヘッダ が必要:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cloudflare Pages / Workers / Vercel いずれもヘッダは _headers ファイル(Pages)や Response.headers で設定可能。iframe 内に埋め込む場合は 親ページも同じヘッダ必須。jsDelivr のファイルは Cross-Origin-Resource-Policy: cross-origin を返すので、COEP を付けたページからでも読み込めます。
なぜブラウザで SQL?
- サーバー / DB 不要:プライベートデータをアップロードせず、ローカルで分析できる
- Parquet を直接読める:CSV や JSON より高速、列志向で集約に強い
- OLAP 系クエリ最適化:GROUP BY / WINDOW / JOIN が Postgres 互換 + 高速
- AI agent との相性:LLM に SQL を書かせて即実行する UX
代替: SQL.js(SQLite を WASM 化)。OLAP / Parquet なら DuckDB のほうが速い。
最小サンプル(DuckDB-Wasm 1.33、5 ステップ)
AsyncDuckDB を bundle に応じた worker と一緒に初期化、connect() でコネクションを取って query() を実行するだけ:
import * as duckdb from "@duckdb/duckdb-wasm";
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
// CDN の URL を new Worker() に直接渡すと SecurityError になる(Worker は同一オリジンのスクリプトしか起動できない)。
// 同一オリジンの Blob から importScripts で読み込ませる
const workerUrl = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker!}");`], { type: "text/javascript" }),
);
const worker = new Worker(workerUrl);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(workerUrl);
const conn = await db.connect();
await conn.query(`
CREATE TABLE sales (region TEXT, revenue DOUBLE);
INSERT INTO sales VALUES ('east', 1500), ('west', 1100);
`);
const result = await conn.query("SELECT region, SUM(revenue) FROM sales GROUP BY region");
console.log(result.toArray());
Parquet / CSV を直接読む
registerFileURL で URL を登録 → SQL から read_csv / read_parquet で直接読める:
// HTTP 経由で Parquet を直接 SQL 対象にできる
const r = await conn.query(`
SELECT category, COUNT(*)
FROM 'https://example.com/data.parquet'
GROUP BY category
`);
// ローカル CSV(ユーザがアップロードした File を Blob URL 化)
const file = inputElement.files![0];
await db.registerFileHandle("upload.csv", file, duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, true);
await conn.query("SELECT * FROM 'upload.csv' LIMIT 10");
バンドル戦略
WASM は転送時でも約 6.8 MB あるので、main bundle に同梱しない。動的 import で初回利用時にだけロード:
const init = async () => {
const duckdb = await import("@duckdb/duckdb-wasm");
// ...
};
jsDelivr から読み込むときに起きること(実測)
getJsDelivrBundles() は、インストールした版に対応する jsDelivr の URL を組み立てて返すだけの関数です。何が読み込まれるかを、Node から jsDelivr に問い合わせて確かめました(1.33.1-dev45.0)。
ブラウザ側(Chromium、crossOriginIsolated = false)で確かめた挙動:
| やったこと | 結果 |
|---|---|
selectBundle(getJsDelivrBundles()) | duckdb-eh.wasm が選ばれる(pthreadWorker は無し) |
new Worker(bundle.mainWorker)(CDN の URL を直接) | SecurityError: Failed to construct 'Worker'(別オリジンのスクリプトは Worker にできない) |
Blob + importScripts で包んで instantiate | 1,594 ms(WASM のダウンロード込み・キャッシュなし)。その後の最初の SELECT は 60 ms |
初期化の 1.6 秒は回線とキャッシュで大きく変わります(2 回目以降はブラウザのキャッシュから読まれる)。自前でホストする場合は bundles を手動で定義し、.wasm を application/wasm で、brotli を効かせて配信します。圧縮が効いていないと 34 MB をそのまま送ることになります。再現: scripts/measure/duckdb-jsdelivr-worker.mjs
主な SQL 機能
| 機能 | サポート |
|---|---|
| GROUP BY / 集約 | ✅ |
| JOIN(全種類) | ✅ |
| WINDOW 関数 | ✅(LEAD / LAG / RANK / ROW_NUMBER) |
| CTE / 再帰 CTE | ✅ |
| Date / Timestamp 関数 | ✅(date_trunc / interval) |
| JSON 関数 | ✅(json_extract) |
| Geospatial | △(spatial extension で対応) |
PostgreSQL 互換が高く、Postgres で書いた analytic クエリはほぼそのまま動く。
Parquet を URL から直読みする 3 つの前提
SELECT * FROM 'https://example.com/data.parquet' の 1 行で外部 Parquet を読めるのは強力だが、現実は CORS と Range request が前提:
Access-Control-Allow-Origin: 別ドメインの Parquet を読むなら必須。S3 / GCS / R2 は bucket の CORS 設定で許可Accept-Ranges: bytes: DuckDB は Parquet の footer から先に読むため、サーバが HTTP Range request に応える必要がある。Cloudflare R2 / S3 はデフォルトで対応、自前 nginx はchunked_transfer_encoding off等の調整が必要- HTTPS:
crossOriginIsolated環境では mixed content 不可
ローカル CSV を読ませる場合は registerFileHandle で File を渡すだけで OK(CORS の話は出てこない):
await db.registerFileHandle("upload.csv", file, duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, true);
await conn.query("CREATE TABLE t AS SELECT * FROM 'upload.csv'");
8 つの落とし穴と直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
new Worker(bundle.mainWorker) で SecurityError | jsDelivr の URL は別オリジン。Worker は同一オリジンのスクリプトしか起動できない | Blob に importScripts("<CDN の URL>") を書いて、その Blob URL から Worker を作る(上の最小サンプル) |
coi bundle が使われない | getJsDelivrBundles() に coi は含まれない。さらに COOP/COEP ヘッダ未設定 だと SharedArrayBuffer が使えない | coi の bundle(duckdb-coi.wasm と pthread worker)を手動で定義し、host の HTTP ヘッダに COOP/COEP 設定。iframe で読むなら親も同じヘッダ |
| Parquet を読むと CORS エラー | CORS / Range request 未設定 | S3 / R2 の CORS allow origin と Accept-Ranges: bytes を確認 |
Out of Memory で死ぬ | WASM の 4GB 上限(32-bit) | Parquet を read_parquet('url', file_row_group_size=100000) で chunk 読み、または server 側で集約 |
| UI がガクッと止まる | sync DB を使っている / worker を使っていない | AsyncDuckDB + worker を必ず使う(new duckdb.AsyncDuckDB(logger, worker)) |
列が全部 VARCHAR で推論される | CSV 型推論が ambiguous | read_csv_auto('upload.csv', columns={'age': 'INT'}) で明示、もしくは CAST |
| 初回 fetch が 3 秒以上かかる | WASM が main bundle で同梱されない / preload なし | <link rel="preload" as="fetch" type="application/wasm" crossorigin> で予読、または dynamic import で route 単位 |
| クエリ結果が JS 側で BigInt で来る | DuckDB の BIGINT は JS 上 BigInt | result.toArray() で受けた後 Number(row.x) で変換、もしくは SQL で CAST AS INT |
評価
| 観点 | 評価 | コメント |
|---|---|---|
| クエリ性能 | ◎ | OLAP に最適化、数百万行も実用速度 |
| Parquet サポート | ◎ | 直接 URL から読める |
| バンドル | △ | WASM は転送時 6.8 MB(展開後 34 MB)、動的 import 必須 |
| 学習コスト | ○ | SQL 既知者なら API は薄いラッパー |
| エコシステム | ○ | Observable Notebook / MotherDuck で連携進行中 |
向く / 向かないケース
- 向く: ブラウザ内 BI ダッシュボード、ユーザがアップロードした CSV/Parquet の分析、エディタ内 query playground
- 向かない: 数 GB のデータをクライアントで処理(memory limit)、リアルタイム書き込みの DB 用途
- 向かない: 単純な「SQLite で十分」(SQL.js のほうがバンドル軽い)
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
JavaScriptによるはじめてのアルゴリズム入門
JavaScriptでアルゴリズムの基礎から応用まで体系的に実装する
Python と JavaScriptではじめるデータビジュアライゼーション
PythonとJavaScriptで実装するデータ収集から可視化までのフルパイプライン
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
ExpoでiOS/Android両対応アプリをJavaScriptで構築する
はじめてのWebデザイン&プログラミング : HTML、CSS、JavaScript、PHPの基本
HTML・CSS・JavaScript・PHP を横断してWebの全体像を最短で把握する
フロントエンドの知識地図ーー 一冊でHTML/CSS/JavaScriptの開発技術が…
フロントエンド技術の全体像を俯瞰し、学習の優先順位を自分で判断できるようにする