Web Worker 実践:ブラウザで重い計算を安全にオフロードする
なぜメインスレッドがカクつくのか?
ブラウザのメインスレッドは、JavaScript の実行、DOM レンダリング、イベント処理、レイアウト計算を一手に担います。JS タスクが 50ms を超えると、スクロールの遅れ、クリック無反応、アニメーションのドロップなど、ユーザーが「重い」と感じます。
典型的な高負荷タスク:
| シナリオ | 所要時間 | メインスレッドへの影響 |
|---|---|---|
| PDF をページ単位で画像化 | 2–30s | ページが完全にフリーズ |
| FFmpeg による動画変換 | 10–120s | キャンセル不可、UI フィードバックなし |
| PNG の一括圧縮 | 1–10s | 入力が反応しない |
| 大きな JSON の整形 | 100–500ms | 短いカクつき |
Web Worker の本質的な価値は、計算集約タスクをメインスレッドから切り離し、UI を常に操作可能に保つことです。
Web Worker の基本アーキテクチャ
スレッドモデル
┌─────────────────────────────────┐
│ Main Thread │
│ DOM · Event Loop · Rendering │
│ ↕ postMessage │
├─────────────────────────────────┤
│ Web Worker │
│ 独立した JS コンテキスト・DOM 不可 │
│ 独立した Event Loop │
└─────────────────────────────────┘
Worker は独立した JavaScript 実行環境を持ちますが、DOM・window・document にはアクセスできません。データは postMessage でやり取りし、大きなオブジェクトは Transferable Objects でゼロコピー転送します。
Worker の作り方(2 通り)
// 方法1:別ファイル(推奨・コード分割に有利)
const worker = new Worker(new URL('./pdf-worker.ts', import.meta.url));
// 方法2:Blob URL(動的生成向け)
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
Next.js / Vite では new URL(..., import.meta.url) が推奨され、ビルドツールが Worker ファイルのバンドルとハッシュを処理します。
通信:postMessage と Transferable
構造化クローン vs 所有権の転送
// ❌ コピー:大きな ArrayBuffer が丸ごと複製され、メモリが倍になる
worker.postMessage({ buffer: largeArrayBuffer });
// ✅ 転送:ゼロコピー。メインスレッドは直ちにアクセス権を失う
worker.postMessage({ buffer: largeArrayBuffer }, [largeArrayBuffer]);
PDF のバイナリ(多くは 1–50MB)では Transferable が必須です。ToolsKu の PDF→画像では、100 ページ PDF で Transferable により 200MB 超のコピーを回避しています。
リクエスト・レスポンスパターン
// main.ts
const id = crypto.randomUUID();
worker.postMessage({ type: 'render', id, pageIndex: 0, pdfBytes }, [pdfBytes]);
worker.onmessage = (e) => {
if (e.data.id === id) {
const imageBlob = e.data.result;
displayPreview(imageBlob);
}
};
UUID でリクエストとレスポンスを対応づけ、複数ページを並列処理できます。
ToolsKu における Worker アーキテクチャ
PDF→画像:複数 Worker の並列化
ユーザーが PDF をアップロード
↓
メインスレッド:ページ数を解析
↓
Worker プールを作成(CPU コア数 − 1)
↓
ページごとにレンダリングを分配 → Worker 1, 2, 3, 4...
↓
メインスレッド:結果を集約 → 進捗バーを更新
↓
ZIP でダウンロード
Worker 数は通常 navigator.hardwareConcurrency - 1 とし、並列度とメモリ使用量のバランスを取ります。
FFmpeg.wasm:単一 Worker + SharedArrayBuffer
動画変換は FFmpeg.wasm(約 25MB の大型 WASM)を使用します。WASM インスタンスは Worker 間で共有できないため、動画ツールは 単一 Worker 専有 です:
- メインスレッド:UI 状態、進捗表示、キャンセル
- Worker:FFmpeg 変換、進捗コールバック
SharedArrayBufferで進捗を共有(COOP/COEP ヘッダーが必要)
エラー処理と Worker のライフサイクル
worker.onerror = (e) => {
console.error('Worker error:', e.message);
worker.terminate();
fallbackToMainThread(); // メインスレッドへフォールバック
};
// 完了後は terminate してメモリを解放
worker.terminate();
Worker は自動 GC されません。長時間稼働する Worker はメモリを占有し続けます。タスク完了後は terminate() するか、Worker 内で大きなオブジェクトを明示的に解放してください。
制限と代替手段
| 制限 | 影響 | 代替 |
|---|---|---|
| DOM 不可 | UI を直接操作できない | postMessage で結果を返す |
| localStorage 不可 | 永続化できない | IndexedDB(Worker から利用可) |
| 起動コスト ~50ms | 短いタスクには不向き | 50ms 未満はメインスレッドに残す |
| デバッグが難しい | DevTools サポートが限定的 | console.log + Worker パネル |
極めて短い処理(10KB 未満の JSON 整形など)では、起動コストが計算時間を上回ることがあります。ToolsKu では ファイル > 1MB、または見積もり > 100ms のときだけ Worker を有効化 します。
性能測定
Chrome 130、M2 MacBook Pro、50 ページ PDF → PNG:
| 方式 | 合計時間 | メインスレッドブロック | 操作可能 |
|---|---|---|---|
| メインスレッド同期 | 18.2s | 18.2s | ❌ |
| 単一 Worker | 17.8s | 0s | ✅ |
| 4 Worker 並列 | 5.1s | 0s | ✅ |
並列 Worker で 18 秒が 5 秒に短縮され、UI は常に応答します。キャンセル、ページ遷移、完了ページのプレビューが可能です。
ベストプラクティス
- 必要なときだけ:短いタスクは Worker 化しない
- Transferable 優先:大きなバイナリは所有権を転送
- Worker プール:CPU 集約はプール並列、I/O 集約は単一 Worker
- すぐ terminate:完了・キャンセル後に
terminate() - フォールバック:Worker 不可時はメインスレッド +
requestIdleCallback - 進捗通知:長時間タスクは postMessage で進捗を返し、フリーズと誤解させない
Web Worker は高性能ブラウザツールの基盤です。WASM と Transferable と組み合わせれば、ブラウザ上で GB 級ファイルの処理も現実的になります。ToolsKu の 200 以上のツールがすべてローカルで動く技術的支柱です。
ブラウザローカルツールを無料で試す →