こんにちは。フロントエンドチームのシニアアーキテクトとして、日々コードの海を泳いでいる私だ。
君たちが書いたプロダクト、最近ユーザーから「なんかボタンを押してから画面がカクつく」「たまにスクロールが引っかかる」なんてクレームを受けていないかい? DevToolsのPerformanceタブを開いてみたら、見事なまでに真っ赤なロングタスク(Long Task)の炎が上がっていたりしてね。
今回は、フロントエンドエンジニアなら避けて通れない、そして避けては通れないがゆえに何度も泣かされてきたテーマ「メインスレッドのブロッキング防止とWeb Workersによるオフロード」について、ブラウザの内部構造の裏側まで深く潜って解説しよう。
公式ドキュメントを読めば「JavaScriptはシングルスレッドで動作するため〜」なんてお決まりの文句が書いてあるが、実務の現場では、そのシングルスレッドの上でどうやって60fps(あるいは120fps)の滑らかなUIを維持するのか、その泥臭い戦い方が求められる。
さあ、ブラウザの頭脳のなかを覗きに行こうか。
—
なぜブラウザは「フリーズ」するのか? メインスレッドの孤独な戦い
私たちが普段書いているJavaScriptのコード、そしてブラウザが画面を描画するための処理。これらは基本的に、「メインスレッド(Main Thread)」と呼ばれるたったひとつのスレッドの上で相乗りして実行されている。
ブラウザのメインスレッドは、いわば「超多忙なワンオペ店員」だ。彼がやっている仕事をリストアップしてみよう。
1. HTMLのパースとDOMツリーの構築
2. CSSのパースとCSSOMツリーの構築、スタイルの計算(Recalculate Style)
3. レイアウト(Layout / Reflow)の計算
4. ペイント(Paint)と合成(Compositing)
5. ユーザーからの入力イベント(click, scroll, keydownなど)の処理
6. そして、私たちの大切なJavaScriptの実行
この中で、JavaScriptの実行と、画面の描画(レイアウトやペイント)は同時に行うことができない。なぜなら、JavaScriptからいつでもDOMを書き換えたりスタイルを変更したりする可能性があるからだ。もし並行処理を許したら、データ競合でブラウザがクラッシュするか、画面がめちゃくちゃになってしまう。
だからブラウザは、「JavaScriptが走っている間は、描画やイベント処理を待たせる(ブロッキングする)」という選択をする。
50ミリ秒の壁:ロングタスクの正体
W3Cやブラウザベンダーは、メインスレッドを50ミリ秒以上占有するタスクを「ロングタスク(Long Task)」と定義している。
人間の脳は、画面の変化から100ミリ秒以内であれば「滑らか(スムーズ)」と感じるが、それ以上遅延すると「ラグがある」と認識する。ブラウザは1秒間に60回(約16.6ミリ秒ごとに)画面を更新しようと必死こいてフレームを生成しているのに、あるJavaScriptの処理が80ミリ秒もかかったとしたらどうなるか?
その間、画面の更新は完全にストップし、ユーザーがスクロールしようがボタンを連打しようが、ブラウザは「ちょっと待て、今計算で忙しいんだ!」とガン無視する。これが、いわゆる「カクつき」や「フリーズ」の正体だ。
実務でよくあるアンチパターンは、数万件の巨大なJSONデータをフロントエンドで受け取り、それを愚直に`Array.prototype.map()`や複雑なループでフィルタリング・加工してから画面にレンダリングするようなケースだ。これ、メインスレッドでやったら即座にロングタスクの出来上がりだ。
—
救世主「Web Workers」:メインスレッドを解放する裏技
「じゃあ、重い処理はどうすりゃいいんだよ? バックエンドに投げろってか?」
いや、必ずしもサーバーサイドの力を借りる必要はない。ブラウザには、HTML5の時代から「Web Workers」という強力なマルチスレッドの仕組みが備わっている。
Web Workersを使えば、メインスレッドとは完全に独立したバックグラウンドスレッド(ワーカー・スレッド)を立ち上げ、その中で重いJavaScriptの処理をゴリゴリ回すことができる。
Web Workersの限界と制約を知る
ただし、シニアとして君たちに釘を刺しておかなければならない。Web Workersは万能の魔法の杖ではない。いくつかの厳しい制約がある。
- DOMに直接アクセスできない:ワーカー内からは `document` や `window`、`DOM要素` に一切触れられない。画面の書き換えは直接できないのだ。
- メモリの共有ができない(原則として):メインスレッドとワーカーの間でデータをやり取りするには、データ構造をコピー(構造化クローンアルゴリズム)するか、転送(Transferable Objects)する必要がある。巨大なデータをそのままコピーすると、逆に通信コストでパフォーマンスが落ちることもある。
- 使えるAPIが限られる:`fetch` や `setTimeout`、`IndexedDB` などは使えるが、DOM関連のAPIや親スレッド固有のグローバルオブジェクトは使えない。
つまり、Web Workersの正しい使い所は、「画面表示とは関係のない、純粋な重いデータ処理(計算、パース、暗号化、画像処理など)を裏でこっそり終わらせて、結果だけをメインスレッドに返すこと」だ。
—
実践:Web Workersを使ったブロッキング防止の実装コード
百聞は一見にしかず。現場ですぐに使える、実用的な実装パターンを見ていこう。
今回は、フロントエンド側で「数百万桁の素数計算や、巨大な配列のソート・集計」といった、いかにもメインスレッドを殺しそうな重い処理をWeb Workerにオフロードする例だ。
1. ワーカー専用のスクリプトファイル (`heavy-calc.worker.js`)
まずは、バックグラウンドで実行する処理を独立したファイルとして用意する。
// heavy-calc.worker.js
// このファイルはメインスレッドとは別の独立したスレッドで実行されます
// メインスレッドからのメッセージを受け取るイベントリスナー
self.addEventListener(‘message’, (event) => {
const { type, payload } = event.data;
if (type === ‘START_HEAVY_CALCULATION’) {
console.log(‘[Worker] 重い処理を開始します…’, payload);
const startTime = performance.now();
// 例:CPUに負荷がかかる重い処理(ダミーの巨大ループとデータ集計)
let result = 0;
const limit = payload.limit || 100000000;
for (let i = 0; i < limit; i++) { result += Math.sqrt(i); } const endTime = performance.now(); const duration = endTime - startTime; // 処理結果をメインスレッドに送り返す // ※ Transferable Objectsを使う場合は第二引数に転送するバッファを指定できます self.postMessage({ status: 'SUCCESS', result: result, duration: duration }); } });
2. メインスレッド側の実装 (`main.js` または React等のコンポーネント内)
次に、UIを制御するメインスレッド側からワーカーを生成し、メッセージをやり取りするコードだ。
// main.js
import React, { useState, useEffect } from ‘react’;
export function DataProcessorComponent() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [duration, setDuration] = useState(0);
const [worker, setWorker] = useState(null);
// コンポーネントのマウント時にWeb Workerを初期化
useEffect(() => {
// ViteやWebpackなどのモダンなバンドラなら、このようにワーカーファイルをインポートできます
const myWorker = new Worker(
new URL(‘./heavy-calc.worker.js’, import.meta.url),
{ type: ‘module’ }
);
// ワーカーからのメッセージを受け取る
myWorker.onmessage = (event) => {
const { status, result, duration } = event.data;
if (status === ‘SUCCESS’) {
setResult(result);
setDuration(duration);
setLoading(false);
console.log(`[Main] 重い処理が完了しました (${duration.toFixed(2)}ms)`);
}
};
// エラーハンドリングもお忘れなく
myWorker.onerror = (error) => {
console.error(‘[Main] Web Worker内でエラーが発生しました:’, error);
setLoading(false);
};
setWorker(myWorker);
// クリーンアップ:コンポーネント破棄時にワーカーを終了させる
return () => {
myWorker.terminate();
};
}, []);
const handleRunHeavyTask = () => {
if (!worker) return;
setLoading(true);
setResult(null);
// メインスレッドをブロックせずに、ワーカーへ処理を指示
// この瞬間も、ボタンのアニメーションやスピナーは滑らかに動き続けます!
worker.postMessage({
type: ‘START_HEAVY_CALCULATION’,
payload: { limit: 200000000 } // 2億回の計算
});
};
return (
Web Workers ブロッキング防止デモ
{/ メインスレッドがブロッキングされていない証拠に、このスピナーは滑らかに回り続けます /}
{loading &&
⏳ 裏で一生懸命計算中… (UIは滑らかです)
}
{result !== null && (
計算結果: {result}
処理時間: {duration.toFixed(2)} ms
)}
);
}
このコードの美しいところは、「重い計算(2億回のループ)が走っている最中であっても、ローディングのスピナーが一切カクつかず、ユーザーがテキストを選択したりボタンのホバーエフェクトを確認したりできる」という点だ。メインスレッドの仕事は「ワーカーに命令を投げて、結果を待つだけ」だからだ。
—
現場で使える実践的Tips:Workersを使うべきか、タスク分割すべきか?
さて、ここまで読んで「なんでもかんでもWeb Workersに投げれば解決だ!」と思ったそこの君、少し待ってほしい。シニアとしてもう一歩踏込んだ現実的な判断基準を授けよう。
1. ワーカー生成のコストに注意する
Web Workerのインスタンス生成(`new Worker()`)は、実はそれなりにメモリと時間を喰う重い処理だ。ボタンを押すたびに毎回ワーカーを生成・破棄していると、かえってオーバーヘッドが大きくなる。重い処理が頻繁に発生する場合は、ワーカーをシングルトン(常駐型)として使い回すアーキテクチャを検討すべきだ。
2. 「Comlink」などのライブラリの活用を検討する
素のWeb Workersの `postMessage` や `onmessage` は、イベント駆動型なのでコードが少し冗長になりがちだ。「まるで非同期関数(async/await)を呼ぶ感覚で、別スレッドの関数を呼び出したい」という場合は、GoogleがメンテしているComlinkなどのライブラリを導入すると、開発体験が劇的に向上する。
3. ワーカーが使えない場合の最終手段:タスクの分割(Time Slicing)
もし環境の制約や、どうしてもDOM操作が絡む理由でWeb Workersが使えない場合は、`setTimeout` や `requestIdleCallback`、あるいはモダンな `scheduler.postTask()` を使って、巨大なループを小さなチャンクに分割し、ブラウザの描画フレームの合間に細切れで処理を実行する「タイムスライシング(Time Slicing)」という手法を使おう。これも実務では非常に重要なテクニックだ。
—
まとめ
ブラウザのメインスレッドは、私たちが作り出すリッチなWebアプリケーションの「命綱」だ。ここを重いJavaScriptの処理で詰まらせてしまうことは、レストランのメインシェフに皿洗いから接客まで全部一人でやらせて、料理が全く客席に出なくなっているようなものだ。
- メインスレッドの50msルールを意識し、ロングタスクを排除する。
- 重いデータ処理や計算は、Web Workersにオフロードしてメインスレッドを常にフリーにしておく。
- UIの滑らかさ(60fps)は、ユーザー体験(UX)の命である。
この思想をチーム全体に浸透させ、カクつきのない、最高に心地よいフロントエンド体験をユーザーに届けよう。君たちの健闘を祈る!

コメント