ロングタスクの呪縛:メインスレッドを解放し、極限のレンダリングパフォーマンスを手に入れる方法
こんにちは。日夜ブラウザのメインスレッドと格闘しているフロントエンド・アーキテクチャの住人の皆さん。
「なぜリッチなUIを作ると、こんなにもカクつくのか?」
「ReactのConcurrent Modeや非同期処理を導入したのに、なぜあのボタンのクリック反応がもたつくのか?」
ローカルのハイスペックマシンで開発している時には気づかない。だが、ユーザーが手にしている少し枯れたスマートフォンや、バックグラウンドで重い処理が走っているラップトップでアプリを開いた瞬間、その脆弱性は牙を剥く。
原因の多くは、単一のロングタスク(Long Task)がメインスレッドを占有し、ブラウザのライフラインであるレンダリングパイプラインを窒息させていることにある。今回は、このロングタスクの正体をブラウザエンジンの内部挙動から徹底的に解剖し、実務で使える脱出コードまで一気に解説しよう。
—
1. なぜ「50ms」なのか? ブラウザのメインスレッドの残酷な真実
W3CのPerformance TimelineやCore Web Vitals(INP: Interaction to Next Paintなど)において、50msという数字が閾値として厳格に定められているのには、明確な生理学的・工学的理由がある。
人間が画面上のインタラクション(タップやクリック)を知覚し、それが「滑らかである」と感じるための許容遅延時間は一般に100ms以内とされている。もしブラウザがユーザーの入力に対して100ms以内に視覚的なフィードバック(色の変化やモーダルの開閉など)を返さない場合、ユーザーは「遅い」「フリーズした」とストレスを覚える。
ここで、ブラウザのメインスレッドの挙動を思い出してほしい。
メインスレッドは、以下のタスクをたった1つのレーン(シングルスレッド)で、時系列に直列処理している。
1. JavaScriptの実行(イベントハンドラ、フレームワークの差分計算、ビジネスロジック)
2. スタイルの再計算(Recalc Style)
3. レイアウト / リフロー(Layout / Reflow)
4. ペイント / レイヤー合成(Paint / Composite)
5. ガベージコレクション(GC)の実行
もし、あるJavaScriptの実行に 120ms かかったとしよう。この間、メインスレッドは完全にその計算にロックされる。その最中にユーザーがボタンをクリックしたり、スクロール操作を行ったりしても、イベントはタスクキューの末尾に積まれたまま一切処理されない。
これが「ロングタスク」の正体だ。「50msを超える単一のタスクが存在する瞬間、ブラウザはユーザーからのインタラクションに応答できなくなり、レンダリングフレームをドロップする」。これが、私たちのWebアプリを重くしている犯人なのだ。
—
2. メモリ効率とV8エンジンの裏側:なぜ巨大な同期処理は悪なのか?
JavaScriptエンジン(V8など)の視点からロングタスクを見てみよう。
例えば、数万件の巨大なJSONデータを取得し、それをパースしてDOMツリーを構築するためのメモリ上の仮想ノード群(VNodeなど)に変換する処理を考えてほしい。
// 【アンチパターン】メインスレッドを完全にハングアップさせる巨大な同期処理
function processHeavyData(rawItems) {
// 数万件のループを同期的に回す
return rawItems.map(item => {
// 複雑なデータ正規化とオブジェクト生成
const computedValue = heavyCalculate(item.value);
return {
id: item.id,
val: computedValue,
timestamp: performance.now()
};
});
}
このコードが実行されると、V8のヒープメモリ内では短時間に膨大な数のオブジェクトが生成される。
ここで問題になるのが、メモリの割り当て(Allocation)とガベージコレクション(GC)の圧力だ。
ヒープが肥大化すると、V8は効率的なメモリ回収のために「Stop-the-World」に近いパニック状態に陥り、メインスレッドの実行を強制中断してGC走査を行う。ただでさえ重いJavaScriptの演算の途中にGCが割り込むことで、タスクの実行時間はさらに跳ね上がり、レンダリングフレームの更新期限(通常60fpsなら16.6ms、120fpsなら8.3ms)を確実にオーバーする。
結果として、画面の描画は完全にブロックされ、アニメーションはカクつき、スクロールはピタッと止まる。
—
3. レンダリングとの競合:requestAnimationFrame との切ない関係
「よし、非同期にしよう」と考えて、安易に `setTimeout(…, 0)` や `Promise.resolve().then(…)` を使うエンジニアが多い。だが、これだけでは本質的な解決にはならないケースが多い。
ブラウザのレンダリングパイプラインは、おおむね次のようなサイクルで回っている。
[ VSync (リフレッシュレートのタイミング) ]
↓
1. 入力イベントの処理 (Input Events)
2. タイマーコールバックの実行 (Timers: setTimeout etc.)
3. ネットワーク・I/Oの処理
4. requestAnimationFrame (rAF) の実行
5. レイアウト (Layout / Reflow)
6. ペイント (Paint / Composite)
もし、ひとつのタスクやマイクロタスクのチェーンが長すぎると、ステップ4の `requestAnimationFrame` やステップ5のレイアウト計算が永遠にスケジュールされなくなる。
よくある誤解として、「`requestAnimationFrame` の中で重い処理をすれば、次の描画に間に合うはずだ」というものがある。しかし、それは大間違いだ。`rAF` のコールバック内で50msを超える処理を書いた場合、そのフレームのレンダリング(レイアウトとペイント)は完全にその処理の終了まで待たされる。結果としてフレームレートは急落し、Jank(カクつき)が発生する。
—
4. 解決策:タスクの細分化(Yielding to the Main Thread)と実用コード
では、このロングタスクの呪縛から逃れ、堅牢なレンダリングパフォーマンスを手に入れるにはどうすればいいのか?
答えはシンプルだ。「仕事を細切れにし、定期的にメインスレッドの制御権をブラウザに返せ(Yielding)」。
ブラウザが「あ、今メインスレッドが空いたな」と気づいた瞬間に、溜まっていたレンダリングやユーザー入力を挟み込む隙を与えるのだ。
現代のブラウザでは、試験的あるいは標準的なAPIとして `scheduler.postTask()` や `scheduler.yield()` が登場しているが、これらはまだすべての環境で完全にフォールバックなしで使えるわけではない。そのため、実務では `setTimeout` や `MessageChannel`、あるいは `scheduler.yield()` をラップしたヘルパー関数を駆使するのが最も確実だ。
以下に、実務でそのまま使える「メインスレッドをブロックしないチャンク処理(バッチ処理)のマスターピース」を提示しよう。
実装例:非同期ジェネレータを用いたタスク分割エンジン
/
- メインスレッドをブロックせずに、重い配列処理をチャンクに分割して実行する
- @param {Array} items 処理対象の巨大な配列
- @param {Function} processor 各アイテムを処理するコールバック
- @param {number} [chunkSize=50] 1回あたりの処理件数
/
async function processInChunks(items, processor, chunkSize = 50) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
// チャンク単位で同期処理を実行
const chunkResults = chunk.map(item => processor(item));
results.push(…chunkResults);
// 【重要】ここでメインスレッドの制御権をブラウザに返却する (Yield)
// これにより、ブラウザはこの合間にレンダリングや入力イベントの処理を挟み込める
await yieldToMainThread();
}
return results;
}
/
- メインスレッドに制御を戻すためのヘルパー
- scheduler.yield が使えればそれを使い、なければ MessageChannel や setTimeout でフォールバック
/
function yieldToMainThread() {
return new Promise(resolve => {
// モダンブラウザの先進的なScheduler APIのサポートがあれば利用
if (globalThis.scheduler && typeof globalThis.scheduler.yield === ‘function’) {
return globalThis.scheduler.yield().then(resolve);
}
// MessageChannel を使った高優先度の非同期タスク化(setTimeout(…, 0) よりもネストの遅延が少ない)
if (typeof MessageChannel !== ‘undefined’) {
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => resolve();
port2.postMessage(null);
return;
}
// 最終フォールバック
setTimeout(resolve, 0);
});
}
// — 実際の利用例 —
async function handleHeavyUserAction(hugeDataset) {
console.time(“重い処理の完了時間”);
const optimizedResults = await processInChunks(hugeDataset, (item) => {
// ここで1件あたりの重い計算やDOM構築前データの整形を行う
return {
…item,
processed: true,
hash: expensiveHashFunction(item.payload)
};
}, 100); // 100件ごとにメインスレッドを解放
console.timeEnd(“重い処理の完了時間”);
updateUI(optimizedResults);
}
function expensiveHashFunction(str) {
// 擬似的な重い演算
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
function updateUI(data) {
// 描画更新
console.log("UIをスムーズに更新しました", data.length);
}
このコードの肝は、`await yieldToMainThread()` の部分だ。
配列を100件処理するたびに一度JavaScriptの実行コンテキストを抜けてブラウザのタスクキューへ戻るため、その隙間にユーザーのスクロール操作やアニメーションのフレーム更新が割り込むことができる。ユーザーからは「アプリがサクサク動いている」ように体感できるというわけだ。
---
5. チーフアーキテクトからの提言:計測なき最適化は罪
ここまでロングタスクのメカニズムと対策を語ってきたが、最後にプロとして一つ釘を刺しておきたい。
「なんとなくコードを分割するな。必ず測れ」。
Chrome DevToolsの Performanceパネル を開き、Mainスレッドのタイムラインを眺めてほしい。赤いバー(Long Taskインジケータ)がそびえ立っている箇所こそが、君のアプリケーションのボトルネックだ。
また、実務のプロダクション環境では、`PerformanceObserver` APIを使い、実際のユーザー環境(Field Data)でロングタスクがどの頻度で発生しているかをモニタリングする仕組みを構築すべきだ。
// 実環境でのロングタスク検知の例
if (typeof PerformanceObserver !== ‘undefined’) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`[Long Task Detected]: 実行時間 ${entry.duration.toFixed(2)}ms`, entry);
// ここでSentryやGoogle Analyticsなどにメトリクスを送信する
}
});
// longtask エントリを監視
observer.observe({ entryTypes: [‘longtask’] });
}
Webブラウザのアーキテクチャは慈悲深いが、愚かではない。メインスレッドを不当に占有するコードは、容赦なくユーザーエクスペリエンスを低下させるペナルティとして跳ね返ってくる。
タスクを適切に細分化し、ブラウザの呼吸(レンダリングサイクル)に合わせた非同期設計を取り入れること。それこそが、真に堅牢で洗練されたWebアプリケーションを支える、私たちフロントエンドエンジニアの矜持だ。さあ、エディタを開いて、君のコードベースのロングタスクを狩りに行こう。

コメント