DuckDB-Wasm でブラウザ内 SQL を動かす
DuckDB-Wasm をブラウザに埋め込み、SQL の DDL / INSERT / SELECT をクライアント完結で実行するパターン。getJsDelivrBundles の中身、SharedArrayBuffer + COOP/COEP ヘッダ、4GB の memory 上限、Parquet を URL 直読みする時の CORS 罠まで実装メモ。
検証日: 2026-05-26
使用バージョン:
@duckdb/duckdb-wasm@1.33.x(2026-05 時点でmain)対象: ブラウザだけで分析クエリを動かしたい(サーバー不要)、Parquet を直接読みたい、ローカル CSV 解析
DuckDB(列指向の高速分析 SQL DB)を WASM(WebAssembly) ビルドでブラウザに埋め込み、サーバーなしで SQL を実行するパターン。DDL / INSERT / SELECT / Parquet 読み込み、WASM ロードの落とし穴を動く demo で確認します。
触って試す
初回は WASM(~3MB)を fetch するため数秒待ちます。
セットアップ(npm + 3 種類の WASM bundle)
npm i @duckdb/duckdb-wasm
| 項目 | 値 |
|---|---|
| 公開バージョン | 1.33.x(DuckDB native ~1.3 系に対応) |
| Node 必要バージョン | 18+(主用途はブラウザ) |
| TypeScript 型 | 本体同梱 |
| ブラウザ要件 | WebAssembly + Web Worker + SharedArrayBuffer(eh bundle 使用時) |
| WASM サイズ | 3MB 前後(mvp < eh < coi)、main bundle に同梱厳禁 |
| ライセンス | MIT |
DuckDB-Wasm は環境別に 3 種類の WASM bundle(mvp / eh / coi)を持っていて、getJsDelivrBundles() → selectBundle() が ブラウザの capability から自動選択する:
| bundle | 速度 | 必要なブラウザ機能 |
|---|---|---|
mvp | 最も遅い | WebAssembly のみ(古いブラウザの fallback) |
eh | 速い | WebAssembly Exception Handling |
coi | 最速 | SharedArrayBuffer + Cross-Origin Isolation(COOP/COEP ヘッダ要) |
coi を使うには HTTP レスポンスに 2 つのヘッダ が必要:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cloudflare Pages / Workers / Vercel いずれもヘッダは _headers ファイル(Pages)や Response.headers で設定可能。iframe 内に埋め込む場合は 親ページも同じヘッダ必須(さもないと eh にフォールバック)。
なぜブラウザで 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);
const worker = new Worker(bundle.mainWorker!);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
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 は ~3MB あるので、main bundle に同梱しない。動的 import で初回利用時にだけロード:
const init = async () => {
const duckdb = await import("@duckdb/duckdb-wasm");
// ...
};
getJsDelivrBundles() は CDN(jsdelivr)からブラウザ環境ごとに最適な bundle を選んでくれる。自前 host する場合は bundles を手動で用意。
主な 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'");
7 つの落とし穴と直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
coi bundle に切り替わらず体感遅い | COOP/COEP ヘッダ未設定 で SharedArrayBuffer 不使用 | 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 ~3MB、動的 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によるはじめてのアルゴリズム入門 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
はじめてのWebデザイン&プログラミング : HTML、CSS、JavaScript、PHPの基本 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
フロントエンドの知識地図ーー 一冊でHTML/CSS/JavaScriptの開発技術が学べる本 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。