スケジューリングAPI(scheduler.yieldとタスク優先度)
長い処理でも画面が固まらない書き方が分かる。scheduler.postTaskの3段優先度とyieldによるタスク分割を、メインスレッドとイベントループの原理から正確に解説します。
- 1.scheduler.postTaskはタスクをuser-blocking/user-visible(既定)/backgroundの3優先度でキューに積み、ブラウザのイベントループが優先度順に取り出す。
- 2.scheduler.yieldは現在のタスクを一旦止めてメインスレッドを明け渡し、再開を予約するawait可能な関数。setTimeout(0)と違い元タスクの継続優先度を保ち、列の後ろに回されにくい。
- 3.長タスク(50ミリ秒超)を境界で分割しyieldを挟むと、入力イベントが間に処理されるためINPの入力遅延が縮む。
なぜスケジューリングAPIが要るのか
ブラウザのメインスレッドは、JavaScriptの実行・スタイル計算・レイアウト・ペイント・入力処理をすべて1本で回します。あるタスクが長く走り続けると、その間ユーザーの操作イベントは処理されず、画面更新も止まります。これが応答性の劣化、すなわち INP(Interaction to Next Paint)悪化の根本原因です。指標としての INP の内部定義は Core Web Vitalsの計測アルゴリズム を、メインスレッドが1タスクを最後まで実行する性質は イベントループ詳説 を参照してください。
長タスクの定義は明確で、メインスレッドを50ミリ秒以上連続占有したタスクです。50ミリ秒を超えると、ちょうど操作が来た場合の入力遅延が体感できる水準に達するため、これを境界に「分割して譲る」のが基本戦略になります。scheduler API(window.scheduler)は、この分割と優先度付けを標準化したものです。
scheduler.postTask:3段階の優先度キュー
scheduler.postTask(callback, options) はコールバックを優先度付きでキューに登録し、Promiseを返します。優先度は3種類で、ブラウザのイベントループはこの優先度順にタスクを取り出します。
| 優先度 | 用途 | 扱い |
|---|---|---|
| user-blocking | 操作への即時応答(最優先) | 他の保留タスクより先に実行される |
| user-visible(既定) | 画面に出るが緊急でない更新 | postTaskで優先度省略時の値 |
| background | ユーザーが待っていない処理 | より優先度の高いタスクが尽きてから実行 |
// 既定は user-visible。明示すると意図が伝わる
scheduler.postTask(() => renderList(), { priority: "user-visible" });
// ログ送信や事前計算など、待たれていない処理は background へ
scheduler.postTask(() => sendAnalytics(), { priority: "background" });
重要なのは、これが setTimeout(fn, 0) とは別系統のキューだという点です。setTimeout の0ミリ秒タスクはタイマータスク源に積まれ、優先度の概念を持ちません。postTask は優先度ごとに別の列を持ち、イベントループが各回で最も高い優先度の列から1件取り出します。優先度が同じなら登録順(FIFO)です。
TaskController による中断と優先度変更
postTask には TaskController を渡せます。これは AbortController を継承し、中断(abort)に加えて優先度の動的変更ができます。たとえば最初 background で積んだ処理を、ユーザーがその領域までスクロールした瞬間に user-blocking へ引き上げる、といった制御が可能です。
const controller = new TaskController({ priority: "background" });
scheduler.postTask(work, { signal: controller.signal });
// 後から優先度を上げる/取りやめる
controller.setPriority("user-blocking");
controller.abort(); // まだ実行されていなければキューから除去
delay オプションを併用すると、指定ミリ秒の遅延後に初めてキューへ入る点も setTimeout と同様に使えます。
scheduler.yield:タスクを分割して譲る
長い処理の途中でメインスレッドを明け渡すのが scheduler.yield() です。これは await可能な関数で、呼んだ時点で現在のタスクをいったん中断し、ブラウザに制御を返します。ブラウザは保留中の入力処理や描画を挟み、その後で yield() 以降の続きを再開します。
async function processChunks(items) {
for (let i = 0; i < items.length; i++) {
handle(items[i]);
// 一定間隔ごとにメインスレッドを譲る
if (i % 100 === 0) {
await scheduler.yield();
}
}
}
yield() の肝は、継続(再開分)の優先度の扱いです。setTimeout(0) や await new Promise(r => setTimeout(r)) で譲ると、続きはタイマー/マイクロタスクの末尾に回り、その間に積まれた他のタスクに追い越されがちです。これに対し yield() の継続は、元タスクの優先度に応じて優先的に再開されるよう設計されています。具体的には、user-visible 相当のタスク内で yield() した継続は、同優先度の新規タスクより前に戻ってくる傾向があり、「譲ったのに永遠に順番が来ない」状態を避けられます。
ひと続きの処理を途中で区切って譲るなら yield()、独立した処理単位を別タスクとして優先度付きで投入するなら postTask() です。前者は処理の連続性(変数スコープ)を保ったまま分割でき、後者はキャンセルや優先度変更を伴う粒度の粗い制御に向きます。
なぜ分割が INP を縮めるのか
INP の遅延は input delay + processing time + presentation delay の3区間に分かれます(詳細は Core Web Vitalsの計測アルゴリズム)。このうち input delay は、操作が来た瞬間にメインスレッドが他タスクで埋まっていると延びます。
長タスクを yield() で区切ると、各区切りでイベントループが回り、保留中の入力イベントがその隙間で処理されます。仮に200ミリ秒の処理を50ミリ秒×4に割ると、操作が来たときの最悪待ち時間はおおむね1区間ぶん(約50ミリ秒)に縮みます。総計算時間は変わりませんが、応答までの遅延が短くなるのが本質です。
分割なし: [====== 200ms 連続 ======] 入力は最大200ms待たされる
yield挟む: [50ms]↩[50ms]↩[50ms]↩[50ms] ↩で入力を処理 → 待ちは約50ms
譲るたびにイベントループ1周ぶんのオーバーヘッド(スタイル再計算やレイアウトの再評価機会)が入ります。1件ごとに yield() すると総処理時間が大きく伸び、かえって完了が遅れます。50ミリ秒に近づいたら譲る(時間ベース)か、一定件数ごとに譲るなど、区切り粒度を調整してください。
時間ベースで譲りたい場合は、performance.now() で経過を測り、しきい値超過時のみ yield() します。あわせて navigator.scheduling.isInputPending()(入力が保留中かを返す)で、入力が来ているときだけ譲る最適化も可能です。
async function timeSliced(items) {
let start = performance.now();
for (const item of items) {
handle(item);
if (performance.now() - start > 50) {
await scheduler.yield();
start = performance.now(); // 計測をリセット
}
}
}
requestIdleCallback との違い
似た目的の API に requestIdleCallback があります。これはフレームの空き時間(アイドル)にだけコールバックを呼ぶもので、優先度でいえば background に近い「暇なら実行」の意味合いです。期限(deadline)を受け取り、残り時間内で処理を進める設計のため、ユーザーに見える更新には向きません(暇がないと延々と後回しになるため)。
| API | 選び方の基準 | 向き不向き |
|---|---|---|
| scheduler.yield | ひと続きの長タスクを分割し応答性を保つ | ループ処理・逐次変換に最適 |
| scheduler.postTask | 独立タスクを優先度付きで投入・制御する | キャンセル/優先度変更が要るとき |
| requestIdleCallback | 見えない後回し可能な処理を暇な時間に流す | プリフェッチ・遅延ログ向き |
| setTimeout(0) | 互換目的の単純な遅延・分割 | 優先度がなく追い越されやすい |
要点は、(1) 長タスク=メインスレッド50ミリ秒以上連続占有、(2) postTaskの3優先度(user-blocking/user-visible既定/background)と TaskController による中断・優先度変更、(3) yieldは継続の優先度を保ってメインスレッドを譲る await 可能関数で、setTimeout(0)より追い越されにくい、(4) 分割が縮めるのは主に INP の input delay で総処理時間は不変、の4点です。
まとめ
スケジューリングAPIは、メインスレッド1本という制約のもとで応答性と処理量を両立させる標準手段です。scheduler.postTask は user-blocking/user-visible(既定)/background の3優先度キューにタスクを積み、イベントループが優先度順に取り出します。TaskController で中断や優先度変更も可能です。scheduler.yield は長タスクを途中で区切ってメインスレッドを譲りつつ、継続を優先的に再開するため、setTimeout(0) のように順番待ちで追い越されません。分割で縮むのは主に INP の input delay であり総処理時間は変わらない、という原理を押さえれば過剰分割も避けられます。非同期の継続がどう実装されるかは async/await の脱糖 を、実務の改善手順は Web パフォーマンス を合わせて参照してください。
Web/フロントエンド Article
スケジューリングAPI(scheduler.yieldとタスク優先度)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
スケジューリング
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 6
導入後に効く点
scheduler.yieldは現在のタスクを一旦止めてメインスレッドを明け渡し、再開を予約するawait可能な関数。setTimeout(0)と違い元タスクの継続優先度を保ち、列の後ろに回されにくい。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 6
判断チェックリスト
- 自社の用途が「スケジューリング / INP」に近いか確認する。
- 強みである「scheduler.postTaskはタスクをuser-blocking/user-visible(既定)/backgroundの3優先度でキューに積み、ブラウザのイベントループが優先度順に取り出す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。