A setTimeout(0) nem enged előre semmit – a scheduler.yield() igen

A setTimeout(0) trükkel feldarabolt taskok folytatása a sor végére kerül. A natív scheduler.postTask és scheduler.yield API ugyanezt a problémát oldja meg prioritásokkal – és a yield folytatása nem esik hátrébb az újonnan érkező taskokhoz képest.

Ha valaha feldarabolt egy hosszú, main threadet blokkoló ciklust setTimeout(fn, 0)-val, hogy „engedjen levegőhöz” a böngészőt, akkor tudod azt az érzést, amikor működik ugyan, de fogalmad sincs, miért pont annyira gyorsult, amennyire. React is pontosan ezt csinálja évek óta a saját Scheduler csomagjában, csak MessageChannel-lel és a navigator.scheduling.isInputPending() API-val kombinálva, mert a natív böngésző soha nem adott ehhez rendes eszközt. Most már ad – csak épp majdnem senki nem tudja, hogy a trükk, amit eddig használtál, egy konkrét, mérhető hátránnyal jár, amit a natív megoldás pont emiatt épített be alapból.

Diagram: két időzített sáv. A felsőn a zöld feladat folytatása szaggatott, elhalványuló dobozként a sor végére kerül két narancssárga feladat mögé; az alsón a zöld folytatás azonnal, kiemelve követi az első blokkot, a narancssárga feladatok csak utána futnak.

A probléma, amit mindenki ismer

Egy 50ms-nél hosszabb JavaScript task blokkolja a main threadet: nem futhat le közben input-kezelés, nem frissülhet a UI, a Interaction to Next Paint romlik. A megoldás mindig ugyanaz volt: darabold fel a munkát, és add vissza időnként az irányítást a böngészőnek. Csak épp a rendelkezésre álló eszközök – setTimeout(0), requestAnimationFrame, requestIdleCallback – mind ötletes újrahasznosítások, nem kifejezetten erre tervezve.

scheduler.postTask: prioritás, nem csak halasztás

A Prioritized Task Scheduling API (Chrome 94, 2021 óta) ad egy globális scheduler objektumot három prioritási szinttel: user-blocking, user-visible (alapértelmezett) és background.

if ("scheduler" in globalThis) {
  scheduler.postTask(() => renderVisibleList(), { priority: "user-visible" });
  scheduler.postTask(() => sendAnalyticsBatch(), { priority: "background" });
}

A TaskController ráadásul futás közben is átprioritizálhatja a még el nem indult taskot – ezt eredetileg olyan use case-ekre szánták, mint amikor egy widget scrollozás közben látótérbe kerül, és onnantól user-blocking-ra kellene váltani a renderelését:

const controller = new TaskController({ priority: "user-visible" });
scheduler.postTask(() => renderWidget(), { signal: controller.signal });

viewportObserver.addEventListener("intersect", () => {
  controller.setPriority("user-blocking");
});

Érdekes adalék: a Chrome saját telemetriája szerint a TaskController.setPriority() használata töredéke a sima postTask()-énak – ez a WebKit-es standards-positions vitában is felmerült konkrét számként. A legtöbb fejlesztő tehát nem bajlódik a dinamikus átprioritizálással, csak a statikus „ez most fontos, ez ráér” döntéssel – ami valljuk be, a use case-ek 90%-ában elég is.

A meglepő rész: scheduler.yield()

Ez a rész az, amitől érdemes végigolvasni a cikket. scheduler.yield() (Chrome 129, 2024 szeptember) első ránézésre csak egy Promise-visszaadó await-elhető pihenő pont egy hosszú ciklusban:

async function processRows(rows) {
  for (const row of rows) {
    renderRow(row);
    if ("scheduler" in globalThis && "yield" in scheduler) {
      await scheduler.yield();
    } else {
      await new Promise((resolve) => setTimeout(resolve, 0));
    }
  }
}

Na ezt nem tudtam: a setTimeout(0) és a scheduler.yield() folytatása nem ugyanoda kerül a sorban. A Chrome fejlesztői blog cikke és a WICG explainer szerint setTimeout(0) után a folytatásod a sor végére kerül – minden már beütemezett task lefut előtte, akár egy harmadik féltől jövő script is. A scheduler.yield() folytatása viszont a saját task-od prioritási szintjén, az azonos vagy alacsonyabb prioritású új taskok elé kerül – vagyis a ciklusod folytatása nem versenyez hátrányból mindennel, ami közben becsatlakozott a sorba. Ez pont az a diagram, amit a cikk elején látsz: a felső sávban a setTimeout(0)-os folytatás a két közbeékelődött task mögé csúszik, az alsóban a yield()-es folytatás rögtön utánuk – tehát előttük – fut.

Ez azt jelenti, hogy egy hosszú listát renderelő ciklus scheduler.yield()-del úgy adja vissza a vezérlést a böngészőnek (hogy futhasson egy input esemény vagy egy repaint), hogy közben nem esik hátrébb a saját munkájával egy lassabb harmadik féltől jövő scripthez képest. Ezt egyébként Airbnb is publikálta saját mérésként még a postTask origin trial idején: a Total Blocking Time számottevő csökkenését mérték egy tipikus mobilos munkameneten, miután kép-karusszeleket, térképet és analitikai scripteket priorizáltak vele – ez az ő saját, kontrollálatlan méréseik, nem egy laborérték, úgyhogy inkább irányadó, mint bizonyíték.

Amiért még nem dobnám ki a régi trükköt

Ez most szép és okos API, de van pár dolog, ami miatt nem raknám be gondolkodás nélkül minden projektbe.

A Safari egyáltalán nem támogatja, sem a postTask-ot, sem a yield()-et. A WebKit standards-positions #361 ticket 2024 júniusa óta nyitva áll, formális pozíció nélkül – és a beszélgetésből kiderül, hogy még a metódusnevek konzisztenciáján is vitáznak (yield() vs. egy tervezett render()/wait() pár), tehát a specifikáció maga sincs lezárva. A Firefox is csak 2025 augusztusában, a 142-es verzióban kapta meg mindkettőt – három évvel a Chrome után. A webstatus.dev adatai szerint a Baseline-státusz jelenleg is „limited”, vagyis hivatalosan sem számít még széles körben elérhetőnek.

Van hivatalos polifill is, de a saját README-je vallja be, hogy setTimeout/MessageChannel/requestIdleCallback kombinációval nem tudja visszaadni a natív user-blocking prioritás valódi event loop-beli elsőbbségét – ott kötsz ki, ahol egyébként is voltál a setTimeout(0)-trükkel.

Amit ténylegesen csinálnék: feature-detection mögé rakva, progresszív enhancement-ként bevezetném ott, ahol tényleg van hosszú, feldarabolható munka – nagy táblázat vagy canvas renderelés, tömeges DOM-frissítés, kliens oldali keresés/szűrés nagy adathalmazon. A statikus három prioritási szint (postTask) simán megéri, a dinamikus TaskController.setPriority() átprioritizálást viszont csak akkor vezetném be, ha van rá konkrét, mért indok – a fent említett usage-adatok szerint a legtöbb helyen ez felesleges komplexitás. Sima CRUD-alkalmazásnál, ahol a leghosszabb task is 10ms alatt van, ez most még megoldás egy problémára, ami nálad nincs is.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük