Web Workerとは?重い処理を別スレッドへ逃がす実装と使い分けを解説
Web Workerは、JavaScriptのコードをUIとは別のスレッドで動かすためのブラウザ標準APIです。WebWorker、ウェブワーカーとも表記されます。画面が固まる原因の多くは、メインスレッドが長いJavaScript処理に占有され、クリックやスクロールへの応答を返せない状態にあります。Web Workerはその処理をスレッドごと切り離しますが、代わりにDOMに触れなくなり、データはコピーで渡すことになります。この記事では最小の実装コード、postMessageで送れるデータと送れないデータ、専用ワーカー・共有ワーカー・Service Workerの使い分け、そして「Workerに逃がしても速くならない」典型パターンまでを、一次ソースと実測を添えて整理します。
まとめ:Web Workerの要点と判断基準
- Web Workerは別スレッドでJavaScriptを実行する仕組み。ブラウザのJavaScriptでマルチスレッド処理を行う標準手段はこれだけで、DOMには一切アクセスできず、メインスレッドとは
postMessageのメッセージ交換だけでつながる。 - 最小実装は
new Worker()とpostMessage/onmessageの3要素。{ type: 'module' }のモジュールワーカーはChrome 80・Firefox 114・Safari 15以降で使える。 - 送れるのは構造化クローン可能な値だけ。関数やDOMノードは
DataCloneErrorで弾かれる。大きなArrayBufferは転送リストに載せればコピーせず所有権ごと移せる。 - 共有ワーカー(SharedWorker)はSafari 16で復活し、長く無効だったAndroid版ChromeでもChrome 148(Stable 2026年5月5日)で再有効化された。同時に寿命を延ばす
extendedLifetimeも入っている。 - 効くのは1タスクが50msを超えるCPUバウンドな計算。数MBのデータを渡して数ミリ秒の処理をさせる用途では、転送コストが上回って遅くなる。
- メッセージの往復を書きたくない場合はComlink 4.4.2(依存ゼロ、brotli圧縮で約1.1kB)でRPCのように呼べる。
以降は、この判断に必要な実装コードとブラウザ対応の実態を順に見ていきます。
メインスレッド占有とINP悪化:Web Workerが解決する問題
スレッドは処理を順番に実行していく1本の流れで、ブラウザのメインスレッドはJavaScriptの実行・スタイル計算・レイアウト・描画・入力イベントの処理をすべてこの1本で担当します。ここで数百ミリ秒かかる計算を実行すると、その間に届いたクリックやキー入力は処理されず、キューで待たされます。W3CのLong Tasks仕様は、実行時間が50msを超えるタスクを「long task」と定義し、計測対象にしています。
ユーザー体感側の指標がINPです。web.devの基準では、75パーセンタイルで200ms以下なら良好、500msを超えると不良と判定されます。1回の計算に300msかかる処理をメインスレッドで回していれば、その間に来た操作は構造的にこの基準を割ります。Web Workerは計算そのものを速くはしません。計算中も入力を受け付けられる状態を維持する仕組みです。
ただしWorkerに逃がしても、描画とDOM更新は必ずメインスレッドに戻ってきます。処理結果を1万行のテーブルとして描く場合、重いのは計算ではなく描画側という結果になることもあります。切り分けの前提として、レンダリングとは?ブラウザ描画の仕組みとCSR/SSR/SSGの違い・選定基準を解説で描画パイプラインのどの段階に時間が乗っているかを押さえておくと判断を誤りません。Promiseやasync/awaitとの役割の違いは非同期処理とは?同期処理との違いから実装方式まで実装者目線で解説で整理しています。async/awaitは待ち時間を譲るだけでスレッドは増えないため、CPUを使い切る計算には効きません。
最小実装:new Workerの生成とpostMessageの往復
ワーカーは独立したスクリプトファイルとして用意し、メインスレッドから生成します。以下は500万回のループをワーカー側で回し、結果だけを受け取る最小構成です。
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
worker.postMessage({ cmd: 'sum', n: 5_000_000 });
worker.onmessage = (e) => {
console.log(e.data.acc);
};
worker.onerror = (e) => {
console.error(e.message, e.filename, e.lineno);
};
// worker.js
self.onmessage = (e) => {
const { cmd, n } = e.data;
if (cmd !== 'sum') return;
let acc = 0;
for (let i = 0; i < n; i++) acc += i;
self.postMessage({ cmd, acc });
};
バンドラ利用時のURL指定とモジュールワーカーの対応版
new Worker('./worker.js')のように文字列でパスを書くと、Viteやwebpackなどのバンドラがワーカーのファイルをビルド対象として認識できず、本番ビルドで404になります。new URL('./worker.js', import.meta.url)の形で書くと、バンドラが静的解析してワーカー用のチャンクを出力します。import.metaはモジュール専用の構文なので、この書き方をする側のスクリプトは<script type="module">で読み込む必要があります。
{ type: 'module' }を付けたモジュールワーカーでは、ワーカー側でimport文が使えます。MDNのbrowser-compat-dataによる対応版はChrome 80、Firefox 114、Safari 15です。旧来のimportScripts()はモジュールワーカーでは使えないため、ライブラリの読み込み方もimportに統一します。なお入れ子のワーカーからスクリプトを読み込む挙動はSafari 16.4で対応しており、ワーカーの中でさらにワーカーを起こす構成は対応版の下限が上がります。
エラー処理と終了:onerror・terminate・self.close
ワーカー内で送出された例外は、メインスレッド側のonerrorにErrorEventとして届きます。混同しやすいのがmessageerrorで、こちらは受信側がメッセージを復元できなかったときに発火します。送信側がクローンできない値を渡した場合はpostMessageの呼び出しがその場でDataCloneErrorを投げるため、原因の切り分けは「どちら側で何が起きたか」で分かれます。
終了はメインスレッドからのworker.terminate()と、ワーカー内からのself.close()の2系統です。terminate()は実行中の処理を待たずに即座に止めるため、書きかけの状態が中途半端に残る設計は避けます。ユーザー操作のたびにワーカーを生成して破棄する実装は起動コストが積み上がるので、1つ作って使い回す形が基本です。
構造化クローンと転送可能オブジェクト:送れるデータの境界
postMessageの引数は構造化複製アルゴリズムで複製されます。参照が渡るのではなくコピーが渡るため、メインスレッドとワーカーで同じオブジェクトを共有することはできません。Node.js v26.5.0のstructuredClone(ブラウザと同じアルゴリズム)で確認すると、Map・Date・RegExp・TypedArrayはそのまま複製できます。一方、関数を含めると次のように失敗します。
// 実測(Node v26.5.0・postMessageと同じ構造化複製アルゴリズム)
structuredClone({ done: () => {} });
// DataCloneError: () => {} could not be cloned.
// ブラウザでも同じ理由で弾かれる
worker.postMessage({ done: () => {} });
コールバックを渡したい、DOMノードを渡したい、クラスインスタンスのメソッドごと渡したい、という設計はここで破綻します。渡せるのは値だけなので、処理の指示はcmdのような文字列や数値に落とし込む必要があります。クラスインスタンスを送った場合、プロトタイプは失われてプレーンオブジェクトになる点も設計上の制約です。
数MB超データの転送によるコピー回避
ArrayBufferは転送リストに載せると、コピーではなく所有権の移動になります。移動後、送り側のバッファは切り離されて長さ0になります。
const buf = new ArrayBuffer(1024);
worker.postMessage(buf, [buf]); // 第2引数が転送リスト
console.log(buf.byteLength); // 0(送信側は使えなくなる)
この挙動はNode v26.5.0のstructuredClone(buf, { transfer: [buf] })でも同じで、複製先が1024バイト、送信元が0バイトになります。画像のピクセルデータや音声バッファのようにサイズが大きいものほど、コピーを避ける効果が出ます。逆に送信後も元データを使い続ける実装では、転送ではなく通常のコピーを選ぶ必要があります。
読み書きを両スレッドで共有したい場合はSharedArrayBufferになりますが、こちらはsecure contextかつクロスオリジン分離された文書でしか使えません。COOPとCOEPのレスポンスヘッダーを設定したうえで、crossOriginIsolatedがtrueかを確認してから使う分岐が必要です。埋め込み広告や外部iframeを使うページでは分離条件を満たせないことが多く、採用前にヘッダー要件から確認します。
専用ワーカー・共有ワーカー・Service Workerの選び分け
「Worker」と名の付くAPIは3種類あり、寿命とスコープが違います。名前が似ているだけで用途は別物です。
| 種別 | スコープ | 通信 | 寿命 | 主用途 |
|---|---|---|---|---|
| Dedicated Worker | 生成したページ専用 | worker.postMessage | ページを閉じるまで | 重い計算のオフロード |
| SharedWorker | 同一オリジンの複数タブ | port.postMessage | 接続タブが全部閉じるまで | タブ間の接続・状態共有 |
| Service Worker | 登録スコープ配下 | fetch/messageイベント | イベント駆動で起動・終了 | 通信の横取りとキャッシュ |
Service Workerはネットワークリクエストのプロキシとして設計されており、オフライン対応やプッシュ通知が主戦場です。計算を逃がす目的でService Workerを選ぶ理由はありません。アイドル時にブラウザが停止させるため、長い計算の置き場所として不適切です。なおSharedWorkerの寿命は、後述のextendedLifetimeを指定した場合だけ表の記載から外れます。
SharedWorkerは対応状況の変遷が激しいAPIです。Safariは初期に実装した後いったん削除し、2022年のSafari 16で再対応しました。Safari 16.0のリリースノートには「WebKit now supports Shared Workers.」と明記されています。
より大きい変化はAndroidです。Chromeはプロセス寿命が読めないことを理由にAndroidでSharedWorkerを無効化し続けてきましたが、Chrome 148(Stable 2026年5月5日)のリリースノートで「SharedWorker is being re-enabled on Android」として再有効化がアナウンスされました。無効化の理由は「SharedWorkerのインスタンスがユーザーにも開発者にも通知されないまま終了しうる」という懸念で、当初考えていたほど深刻ではないと判断された経緯は whatwg/html の issue #11205 の議論に残っています。Chromium側のトラッキングバグは40290702です。同バージョンの他の変更点はChrome 148の安定版リリース概要と主要アップデート対象環境にまとめています。
Chrome 148ではextendedLifetime: trueというコンストラクタオプションも追加されました。接続していたクライアントが全部アンロードされた後もワーカーを生かし続けるよう要求するもので、タブを閉じた直後の後始末処理を任せられます。対応はChromeのみで、FirefoxとSafariでは未対応です。
この経緯があるため、SharedWorkerを前提にした実装では現在も機能検出とフォールバックを残すのが安全です。古い端末やAndroid版Chrome 147以前ではSharedWorkerが未定義になります。フォールバック先のWorkerとは通信の入口が違うので、port側に寄せて呼び出しコードを共通化します。
// SharedWorkerのportとWorkerはpostMessage/onmessageが共通
const endpoint =
typeof SharedWorker !== 'undefined'
? new SharedWorker(new URL('./shared.js', import.meta.url), { type: 'module' }).port
: new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
endpoint.start?.(); // MessagePortのときだけ必要
endpoint.postMessage({ cmd: 'sum', n: 5_000_000 });
モジュール版のSharedWorker({ type: 'module' })の対応版はChrome 80、Firefox 114、Safari 15で、Android版ChromeはSharedWorker本体と同じくChrome 148からです。
WebSocket接続をワーカー側へ寄せる構成
ワーカー内ではWebSocket・fetch・IndexedDBが使えます。受信メッセージのJSONパースや差分計算をワーカーで済ませ、描画に必要な最小限の結果だけをメインスレッドへ返す構成は、秒間数十件のメッセージが届くダッシュボードで効きます。
ここで種別の選択が効いてきます。Dedicated Workerを使う場合、ワーカーはタブごとに1つなのでWebSocket接続もタブの数だけ張られます。同じユーザーが5タブ開けば5接続です。SharedWorkerに置けば接続は1本に集約でき、各タブはport経由で同じデータを受け取ります。ただし前節のとおりAndroid版Chromeでの対応はChrome 148以降なので、モバイル比率が高いサービスでは接続数の削減効果を織り込む前に利用者の環境を確認します。
// shared.js(共有ワーカー側)
const ports = [];
let socket;
self.onconnect = (e) => {
const port = e.ports[0];
ports.push(port);
port.start();
port.onmessage = (ev) => {
// タブ側のunloadで送る。実運用ではこの切断検知を必ず入れる
if (ev.data?.cmd === 'bye') ports.splice(ports.indexOf(port), 1);
};
socket ??= connect();
};
function connect() {
const ws = new WebSocket('wss://example.com/stream');
ws.onmessage = (ev) => {
const data = JSON.parse(ev.data);
for (const p of ports) p.postMessage(data);
};
return ws;
}
そもそもWebSocketが要件に合っているかは別の判断です。サーバーからの一方向配信だけならSSEのほうが再接続やプロキシ通過で扱いやすく、この比較はSSEとWebSocketの違いと使い分け|WebRTC・WebTransportまで性能比較で整理しています。
Web Workerを使うべきでない場面
Workerは万能のパフォーマンス改善策ではありません。次の条件に当てはまる処理は、Workerへ移すと遅くなるか、複雑になるだけで効果が出ません。
データ転送量に対して計算量が小さい処理が最も典型的です。構造化複製はコピーなので、10MBのオブジェクトを渡して数ミリ秒のフィルタをかける実装では、コピーのコストが計算を上回ります。判断の目安は、その処理を単体でプロファイルして1タスクが50msを超えるかどうか。超えないならメインスレッドに置いたままで構いません。
DOMやレイアウト情報に依存する処理もWorkerに移せません。要素の座標やスタイル計算結果はワーカー側から取得できないため、結局メインスレッドで測って値を送る往復が発生します。
短い処理が大量に連続するケースは、Workerよりも処理を分割してメインスレッドに制御を返すほうが単純です。1件あたり数ミリ秒の処理を1万件回すような実装なら、一定件数ごとに制御を返して入力を処理させる設計で体感は改善します。
ブラウザ内で本格的なデータ処理を行う場合は、Workerに手作りのコードを載せるより、Worker前提で設計されたライブラリを使うほうが速く着地します。ブラウザ上でSQLを実行するDuckDB WASMとは?ブラウザでSQL分析を実行する仕組みと実装・採用判断のように、WebAssemblyとワーカーを組み合わせた実装が既にあるためです。
ワーカー数の上限:hardwareConcurrencyとSafariのクランプ
複数のワーカーで分散処理する場合、生成数の上限はnavigator.hardwareConcurrencyを基準にします。この値はワーカー内からも参照でき、対応版はChrome 37、Firefox 48、Safari 15.4です。ただしSafariはフィンガープリント対策で戻り値を4または8にクランプしており(WebKit bug 233381)、実際の論理コア数とは限りません。コア数を超えてワーカーを作っても並列度は上がらず、メモリ消費とスケジューリングのオーバーヘッドだけが増えます。各ワーカーが独立したJavaScript実行環境とヒープを持つため、生成数がそのままメモリ使用量に効く点も見積もりに入れます。
Comlinkによるメッセージ往復のRPC化
コマンド名で分岐するonmessageは、扱う処理が増えるとswitch文が肥大化します。GoogleChromeLabsのComlinkは、ES ProxyでワーカーのAPIを関数呼び出しの形に見せるライブラリです。npmのlatestは4.4.2(2024年11月7日公開)、依存パッケージはゼロ、ライセンスはApache-2.0で、README記載のサイズはbrotli圧縮で約1.1kBです。
// worker.js
import * as Comlink from 'comlink';
Comlink.expose({
sum(n) {
let acc = 0;
for (let i = 0; i < n; i++) acc += i;
return acc;
},
});
// main.js
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
const total = await api.sum(5_000_000); // Promiseが返る
SharedWorkerと組み合わせる場合は、メインスレッド側でComlink.wrap(worker.port)とportを渡し、ワーカー側はonconnectの中でComlink.exposeを呼びます。コールバックを渡したいときはComlink.proxy()でラップします。素のpostMessageでは関数がDataCloneErrorになるところを、Comlinkが参照の受け渡しに変換する形です。
注意点として、Comlinkを挟んでも構造化複製の制約自体は消えません。引数と戻り値はクローンされ、呼び出しごとにメッセージが1往復します。ループの中で細かく呼ぶとオーバーヘッドが積み上がるため、粒度の大きいAPIとして設計します。リリースは2024年11月が最後で、mainブランチには2025年6月のMessagePortのclose漏れ修正が未リリースのまま入っています。更新頻度は低いものの、依存ゼロで自前実装に置き換えられる規模である点は採用判断の材料になります。
デバッグ:DevToolsのスレッド切り替えとPerformance計測
ワーカー内のコードはメインスレッドとは別コンテキストで動くため、通常のブレークポイントの張り方では止まりません。Chrome DevToolsのSourcesパネルでは、実行中のワーカーがスレッドとして一覧表示され、選択するとそのワーカーのスコープでステップ実行できます。共有ワーカーはchrome://inspect/#workersから専用のDevToolsを開けます。
計測面では、Performanceパネルの記録にワーカーのスレッドが別トラックとして表示されます。メインスレッドのトラックからlong task(50msを超える帯)が消え、ワーカー側のトラックに移っていれば、オフロードが実際に効いていると判断できます。改善したつもりで実はメッセージのシリアライズがメインスレッドに残っているケースも、この記録で見つかります。
よくある質問
Web Workerとはどういう仕組みですか?
ブラウザがJavaScript実行環境をもう1つ立ち上げ、そこで指定したスクリプトを動かす仕組みです。メインスレッドとはメモリを共有せず、postMessageで送ったデータのコピーだけがやり取りされます。そのためワーカー側の処理がどれだけ長くても、メインスレッドはクリックやスクロールへの応答を続けられます。DOM操作ができないのは、複数スレッドから同じDOMツリーを触ると整合性が壊れるためです。
Service WorkerとWeb Workerの違いは何ですか?
目的が違います。Web Worker(専用ワーカー)は計算をページから切り離すためのもので、生成したページと寿命をともにします。Service Workerはページとネットワークの間に入るプロキシで、リクエストの横取りやキャッシュ応答、プッシュ通知を担当し、ページを閉じた後もイベント駆動で起動します。重い計算の置き場所としては設計されておらず、アイドル時にブラウザが停止させるため、計算のオフロードには専用ワーカーを使います。
Web Workerに対応しているブラウザは?
専用ワーカーはChrome 4、Firefox 3.5、Safari 4以降で対応しており、現行のブラウザで対応状況を気にする必要はほぼありません。判断が必要なのは派生機能のほうです。モジュールワーカー({ type: 'module' })はChrome 80・Firefox 114・Safari 15以降、SharedWorkerはChrome 5・Firefox 29・Safari 16以降で、Android版ChromeのSharedWorker対応はChrome 148以降になります。
Web WorkerからDOMを操作できますか?
できません。documentやwindowはワーカーのグローバルスコープに存在しないためです。ワーカーは計算結果だけを返し、DOMへの反映はメインスレッド側で行う分担にします。描画自体をワーカーに寄せたい場合は、Canvasの描画コンテキストをワーカーへ転送するOffscreenCanvasという別のAPIがあり、こちらはワーカー内で描画命令を発行できます。
Web WorkerでWebSocketは使えますか?
使えます。ワーカー内ではWebSocketのほかfetchやIndexedDBも利用でき、接続の維持と受信データの前処理をまとめて任せられます。ただし専用ワーカーではタブごとに接続が張られます。同一オリジンの複数タブで接続を1本にまとめたい場合はSharedWorkerを使いますが、Android版Chromeでの対応はChrome 148以降である点を利用者の環境と照らして判断します。