Repository navigation
Run requestIdleCallback polyfill after paint in 5ms slices of a 10ms per-frame budget - #129
Draft
mattcosta7 wants to merge 6 commits into
Draft
mattcosta7 wants to merge 6 commits into
mattcosta7 wants to merge 6 commits into
Conversation
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
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>
Contributor
There was a problem hiding this comment.
🟡 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 on lines
+679
to
+681
| it('leaves a native requestIdleCallback alone', () => { | ||
| // Test runs in Chrome, which ships native requestIdleCallback. | ||
| expect(isSupported()).to.equal(true) |
Comment on lines
+41
to
+50
| if (unpainted.has(id)) continue | ||
| remove(id) | ||
| clearTimeout(entry.timeout) | ||
| const {callback} = entry | ||
| callback({didTimeout: false, timeRemaining: () => Math.max(0, sliceEnd - performance.now())}) | ||
| break | ||
| } | ||
| } | ||
| } finally { | ||
| if (pending.size > unpainted.size && performance.now() < periodDeadline) { |
Comment on lines
+120
to
+121
| const timeout = options?.timeout | ||
| const delay = timeout && timeout > 0 ? timeout : 0 |
| runFrame() | ||
| await sleep(100) | ||
| // 4ms each: roughly 2-3 fit in 10ms, and the rest wait for the next frame. | ||
| expect(ran.length).to.be.greaterThanOrEqual(2) |
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>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
Safari has no native
requestIdleCallback, soapply()installs this polyfill there. Today each callback runs as asetTimeout(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
MessageChanneltask, within a shared 10 ms budget per frame.Native implementations, the exports and
apply()are unchanged.What changes for consumers
requestAnimationFrame, then a task)timeRemaining()options.timeoutdidTimeoutalwaystruewhen no timeout was setdidTimeout: trueandtimeRemaining() === 0if the timeout fires first, otherwisefalsedisplay:nonecross-origin iframe)nulloptionsOne 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.yieldorisInputPending(MDN browser-compat-data), so rAF,MessageChannelandsetTimeoutare all that's available. If Safari shipspostTask, 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: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 testpasses (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 andSymboltimeout fixes, each covered by a test.Tradeoffs and risks
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).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 whenevertimeRemaining() <= 5always defers.timeoutexpires 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.Not in this PR