Skip to content

Run requestIdleCallback polyfill after paint in 5ms slices of a 10ms per-frame budget - #129

Open
mattcosta7 wants to merge 6 commits into
mainfrom
mattcosta7-miniature-broccoli
Open

mattcosta7 wants to merge 6 commits into
mainfrom
mattcosta7-miniature-broccoli

Conversation

@mattcosta7

@mattcosta7 mattcosta7 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Safari has no native requestIdleCallback, so apply() installs this polyfill there. Today each callback runs as a setTimeout(0) with a 50 ms budget counted from when it was scheduled, so "idle" work can run between an event handler and the next paint. Event Timing reports that time as INP presentation delay.

GitHub RUM (macOS desktop, 14 days): Safari INP p90 is 159 ms vs Chrome 88 ms. This polyfill explains part of that tail, not all of it.

After this PR: callbacks run after the next paint, each in its own MessageChannel task, within a shared 10 ms budget per frame.

Native implementations, the exports and apply() are unchanged.

What changes for consumers

Before After
When callbacks run 0 ms timer, can land before paint After the next paint (requestAnimationFrame, then a task)
Idle time per frame Unbounded 10 ms of wall-clock time, shared by all pending callbacks
Yielding n/a One callback per task, so input, rendering, timers and promise continuations run between callbacks
timeRemaining() 50 ms from scheduling Time left in a 5 ms slice (or the 10 ms period if shorter). Never more than 5 ms
options.timeout didTimeout always true when no timeout was set didTimeout: true and timeRemaining() === 0 if the timeout fires first, otherwise false
Hidden tabs n/a No rAF, so periods are posted as timer tasks at least 100 ms apart
rAF never arrives (for example a display:none cross-origin iframe) n/a After 250 ms the period is posted as a task
null options threw Accepted, like native

One spec deviation: callbacks queued during a period run in a later task of the same 10 ms budget, not the next period. Otherwise a self-rescheduling queue gets one callback per frame (measured 0.4-0.5x throughput, no INP gain).

Safari has no scheduler.postTask, scheduler.yield or isInputPending (MDN browser-compat-data), so rAF, MessageChannel and setTimeout are all that's available. If Safari ships postTask, a background-priority backend would be better.

Evidence

Real Safari 27.2 (safaridriver, in-page synthetic clicks, 3 x 25 per cell). Percentage of frames over 20 ms:

Shape No idle queue Base This PR
100 ms click handler 9.8% 58.8% 9.4%
Heavy layout 6.7% 76.9% 2.1%
Many short-timeout callbacks 0.2% 18.4% 17.4%

Also in real Safari: microtask order matches native (A A-micro B), and idle time per frame is 9 ms (base: up to 98 ms). A backgrounded tab running a self-requeuing queue was busy for 1.9 s of a ~3.5 s window before the 100 ms hidden gap and 171 ms after (base: 21 ms).

Playwright WebKit (click INP with a background idle queue, measured on 1106ad1, not re-run on this head): no queue 16 ms, old polyfill 64 ms, this design 16 ms. With competing foreground work, a 40 x 5 ms task chain finished in about 300 ms vs 1400-2100 ms with the old polyfill.

Tests: npm test passes (lint, tsc, 126 tests, about 50 for this polyfill), stable over repeated runs. Tests drive animation frames by hand and use microtask probes to prove callbacks run in separate tasks. A second-model review plus browser probes of an earlier revision drove the one-callback-per-task, 250 ms fallback, late-request and Symbol timeout fixes, each covered by a test.

Tradeoffs and risks

  • Throughput: on Safari, idle work completes at about 52-69% of the old polyfill's rate (measured on 1106ad1). The old one got its throughput by not being idle. Consumers with a lot of idle work will finish later, and idle work waits for the next frame (about 16 ms at 60 Hz).
  • Smaller budget: timeRemaining() is at most 5 ms. Loops that check it stay correct. Code that does fixed-size work regardless can overrun, as with native. A callback that defers whenever timeRemaining() <= 5 always defers.
  • Timeouts are not budgeted: a callback whose timeout expires runs in its own timer task, as native does, so queues of short-timeout callbacks still delay frames. This PR is no better than base there. Don't use short timeouts for non-urgent work.
  • Cheap self-requeuing callbacks run hundreds of times per frame (about 400 in Chromium, about 26 in WebKit), and a throwing self-requeuer fires far more error events than before.
  • Stalled-frame fallback: 250 ms is a guess at "rAF is not coming". It is exercised in Chromium only. WebKit and real Safari ran these cases within 1 ms either way.
  • Not tested: iOS Safari, real pointer input through WebDriver (too unreliable to use), and production traffic. Watch Safari INP p75/p90, presentation delay and request-to-run delay after release. Rollback is a revert and re-release.

Not in this PR

  • Version bump and release.
  • Consumer-side changes such as moving telemetry flushes off idle callbacks.

mattcosta7 and others added 3 commits November 3, 2026 17:34
The polyfill ran idle callbacks as a 0ms timer with a 50ms budget measured
from scheduling, so in Safari idle work landed between an event handler and
the next paint and showed up as INP presentation delay.

Callbacks now run in a task posted from requestAnimationFrame, get a 10ms
budget measured from when they start, honour options.timeout with
didTimeout: true, and can be cancelled at any stage. Hidden documents skip
the animation frame, since rAF does not fire there. didTimeout is now false
when no timeout was requested.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7
Giving every callback its own 10ms budget let a burst of pending callbacks
run back to back in one frame. In a WebKit lab with 8 independent idle
queues, click INP rose to 92ms, worse than the original polyfill.

Follow the spec's idle period model instead: schedule at most one period
per frame (requestAnimationFrame, then setTimeout(0)), run the callbacks
queued before it in FIFO order against a shared deadline, and leave the
rest, including callbacks queued during the period, for the next frame.
A throwing callback no longer strands the callbacks behind it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7
Running a whole 10ms idle period as one task made foreground work queue
behind it. In a WebKit lab, a React-scheduler-style MessageChannel chain
took 540ms under the shared period vs 223ms with no idle work.

Keep the 10ms wall-clock budget per frame, but spend it in 5ms slices,
each posted as its own MessageChannel task, so input, rendering, timers
and messages interleave with idle work. The same chain now takes about
280-315ms, with no change to INP, frame pacing, or idle throughput when
nothing else is competing.

Callbacks queued during a period may run in a later slice of the same
period, bounded by the wall deadline, so self-rescheduling queues are not
limited to one slice per frame.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7
@mattcosta7 mattcosta7 changed the title Run requestIdleCallback polyfill after paint with a shared 10ms idle period Run requestIdleCallback polyfill after paint in 5ms slices of a 10ms per-frame budget Nov 4, 2026
mattcosta7 and others added 2 commits November 3, 2026 20:04
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e817e803-845b-4e88-b4fe-713c9673fbe7
… cases

- Run each idle callback in its own task, capped at 5ms of the shared 10ms
  period, so promise continuations settle between callbacks like native.
- Fall back to a task when requestAnimationFrame doesn't fire within 250ms
  while the document is still visible (display:none/offscreen iframes).
- Make requests made between the frame gate and the period opening wait for
  the next frame, so they still run after a paint.
- Call callbacks without a receiver, matching the timeout path.
- Convert options.timeout before registering the callback.
- Deflake the slice-yield test, and cover the above.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A native-only assertion fails in targeted WebKit environments without requestIdleCallback, while timeout handling can strand callbacks and scheduling can violate FIFO ordering.

4 open issues
What changed in this PR

Reworks the requestIdleCallback polyfill to reduce pre-paint idle work in browsers without native support, particularly Safari. Native implementations remain unchanged.

Changes:

  • Schedules callbacks in separate tasks after a frame, sharing a 10 ms wall-clock budget with 5 ms callback deadlines.
  • Adds timeout management, cancellation across scheduling stages, and fallbacks for hidden documents or stalled frames.
  • Expands tests for scheduling, deadlines, callback ordering, cancellation, and fallback behavior.
File Description
test/​requestidlecallback.js Adds manual-frame controls and extensive scheduling, timeout, cancellation, and fallback tests.
src/​requestidlecallback.ts Implements shared idle periods, task-based callback execution, and scheduling fallbacks.

Review effort: Max

Comment thread test/requestidlecallback.js Resolved
Comment thread src/requestidlecallback.ts Resolved
Comment thread src/requestidlecallback.ts Resolved
Comment thread test/requestidlecallback.js Resolved
In real Safari 27.2, a hidden tab with a self-rescheduling idle queue ran
10ms periods back to back (about 55% of a core). Hidden periods now start
at least 100ms apart.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattcosta7
mattcosta7 marked this pull request as ready for review November 4, 2026 12:38
@mattcosta7
mattcosta7 requested a review from a team as a code owner November 4, 2026 12:38
@mattcosta7
mattcosta7 requested a review from jibrang November 4, 2026 12:38

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants