Skip to content

Pre-mount Android views in the time each frame leaves free, behind a flag - #59057

Open
YevheniiKotyrlo wants to merge 1 commit into
react:mainfrom
YevheniiKotyrlo:fix/android-premount-frame-budget
Open

YevheniiKotyrlo wants to merge 1 commit into
react:mainfrom
YevheniiKotyrlo:fix/android-premount-frame-budget

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

MountItemDispatcher.dispatchPreMountItems pre-mounts views at the start of each DISPATCH_UI frame callback, before the frame's traversal, until frameTimeNanos + FRAME_TIME_NS / 2: 8.33 ms on every display. While JavaScript creates views faster than the UI thread pre-allocates them, that is all of a 120 Hz frame, so frames run past the next vsync, and half of a 60 Hz frame, so the rest of the frame sits idle while the views wait for the mount.

Behind a new flag, enablePremountItemsBetweenFramesAndroid (off by default), PreMountItemsBetweenFrames pre-mounts in the time each frame leaves free instead:

  • After the frame's traversal, in slices posted to the UI thread's looper that each start items for about 1 ms, until the next vsync: the frame's time plus its frame interval. From Android 13 that's the spacing of the frame timelines the Choreographer hands each vsync (VsyncFrameInterval), which follows the rate the frames run at where the display can report its mode's rate; otherwise it's the display's refresh rate (DisplayFrameInterval, read from DisplayManager), or a 60 Hz frame while neither is known. A frame, an input event or any other message queued meanwhile waits one slice at most. The slices are asynchronous messages, like the Choreographer's vsync, since a view that invalidates while it draws, such as a ScrollView during a fling, leaves the next frame's traversal barrier in the queue between frames.
  • Before the traversal, after a frame that left no time before the next vsync, for as long as that frame's draw slept waiting on the render thread, less one slice, at most half a frame and never past half of a 30 Hz frame. While the render thread is behind, frames leave no time after them, and that sleep is the free time. It's measured as the UI thread's wall time less its CPU time (Debug.threadCpuTimeNanos()), and a frame that left time gets none, whatever kept its thread off the CPU.
  • Before the traversal, for half a frame and never past half of a 30 Hz frame, once two frames in a row have each taken a whole frame interval of their own work, as while the UI thread is busy past every vsync. Those frames leave no time after them and needn't sleep, so without this the commit's mount would run every view still waiting, where today each of them pre-mounts up to 8.33 ms; it's the progress bound requestIdleCallback's timeout gives idle work. A frame's own work is timed from its vsync, or from the end of the frame before it when that came later, without what it pre-mounted, so the first frame that fits its interval ends it, and one busy frame costs the next nothing.
  • A commit whose mount items arrive while the slices run is mounted by the next slice, as it is today once the frame's pre-mount budget ends, and the slices go on with whatever its mount queued. With enableMountingCoordinatorPullModelAndroid, a commit queued as a surface to pull waits for the frame, which pulls it once, as today, and mount items queued meanwhile wait with it, since a dispatch pulls before it mounts.

A slice runs only while the frame callback would mount, so none runs once mounting has failed or the UI manager is destroyed. Pausing removes the queued slices on the UI thread, whichever thread invalidate() runs on, a slice that throws stops mounting like a throwing frame callback, and a frame whose mount fails queues none. With the flag off nothing changes. The C++ drain of the pre-allocation queue (drainPreallocateViewsQueue) still runs at the start of each frame, outside any budget, and on main it can wait there for the JS thread's executeMount (#59244).

I tried idle handlers first: a vsync and an input event reach the main queue only when the looper polls, so an idle handler can't see the next frame coming. In a probe, MessageQueue.isIdle() stayed true across frames while a handler ran, on every device and system image I tried (API 26 to 37), and a handler that yielded only to queued messages drained a 150 ms backlog in one freeze.

In release RNTester on a Galaxy S23 (Android 14) at 120 Hz, a RedMagic NX809J (Android 16) at 144 Hz and a Mi MIX 3 (Android 10) at 60 Hz, with 2400 views mounted beside a native spring, the flag pre-mounts 1.3 to 2.3 times as many views before the commit, loses at most 0.1 vsyncs per burst before it where main loses 1.6 on the S23 and 5.5 on the RedMagic, and loses 2.9 to 6.0 fewer vsyncs per burst in all. When native-driven animations keep the UI thread busy past every vsync, it pre-mounts for half a frame before each traversal and draws the mount from 34 ms sooner than main (Mi MIX 3) to 10 ms later (S23, 2400 animations). Over 160 translucent layers it pre-mounts at least as many views as main, and on the Mi MIX 3 it draws the mount 56 ms sooner. The traces, every arm and the scene are in the comment below.

Changelog:

[Internal] - Add enablePremountItemsBetweenFramesAndroid, which pre-mounts Android views in the time each frame leaves free instead of for half of a 60 Hz frame before its traversal

Test Plan:

PreMountItemsBetweenFramesTest (new) runs the scheduler under Robolectric with the main looper paused, React Native's SystemClock mocked and a fake thread clock: nothing pre-mounts inside the frame; pre-mounting stops at the next vsync at 60, 120 and 144 Hz after 0, 2 and 5 ms of frame work; a message queued during a backlog runs between two slices; a traversal barrier left in the queue doesn't hold the slices back; a commit that lands during the slices, or during the slice that runs the last item, mounts before the next frame, and what its mount queues still runs before the vsync, while a commit the pull model queues waits for the frame, and mount items with it; a stale or missing refresh rate still yields every 1 ms; a frame that ran past the next vsync pre-mounts nothing after it; frames whose draw sleeps until the next vsync get that time before their traversal, at most half a frame and half of a 30 Hz frame, for as long as they keep sleeping and items are left, while a frame that left time, one frame busy on the CPU, a thread clock that can't tell, or the first frame after vsyncs that ran no frame callback gets none; frames busy past every vsync pre-mount for half a frame before each traversal from the third, at 60, 120 and 144 Hz, with a thread clock that can't tell too, one busy frame between frames that leave time costs nothing, and the time ends once frames fit their interval though they start late, at a frame with nothing to pre-mount, at vsyncs with no frame callback, and at cancelling; a frame with items left asks for the next frame's interval; a slice runs nothing once mounting is no longer allowed; cancelling removes every queued slice and the measured time; a failing item reaches the exception handler.

FabricUIManagerPreMountTest (new) drives the real DISPATCH_UI frame callback: with the flag off, nine 1 ms items run inside the frame, as today; with it on, none inside and 17 before the next 60 Hz vsync, and nine inside the frame after one whose draw slept; after initialize() on a 120 Hz display, on Android 13 and on Android 10, the slices follow its frame; pausing cancels the queued slice; a frame whose mount fails pre-mounts nothing after it, and neither does a slice queued before it; invalidating from another thread while a slice runs stops the slices. MountItemDispatcherTest (new) pins today's 8.33 ms from the frame's start, the deadline-bounded step, and a mount batch running every pre-mount item left before itself. DisplayFrameIntervalTest (new) covers reading the default display's frame from 1 to 240 Hz, following a change the display reports, reading one that lands while its listener registers, reading on the UI thread when started from another, and reporting none while the display reports no rate. VsyncFrameIntervalTest (new) covers the frame timelines' spacing, none from a single timeline or from timelines that don't advance, and the spacing answering instead of the display's interval until it's more than two frames old or cancelled.

With pre-mounting left where it is today, the scheduler's cases fail for the placement, nine items running inside the frame and none after it; with slices posted as ordinary messages, the barrier case fails with expected:<[17]> but was:<[2]>; with the slice queued before the frame's mount items, the failure case fails with expected:<[0]> but was:<[3]>; with a commit left for the next frame, both commit cases fail, and the pulled commit's case fails the same way when a surface to pull doesn't count as a commit, while counting a pull that waits for its root view stops the slices at once; without pre-mounting before the traversal, four frames whose draw slept pre-mount [0, 0, 0, 0] items before their traversals against [0, 9, 9, 9], and five back-to-back frames busy past a 120 Hz vsync pre-mount [0, 0, 0, 0, 0] against [0, 0, 5, 5, 5].

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 841 tests, 0 failures, 139 of them in com.facebook.react.fabric
  • ./gradlew :packages:react-native:ReactAndroid:compileReleaseKotlin :packages:react-native:ReactAndroid:compileReleaseJavaWithJavac -PenableWarningsAsErrors=true: clean
  • ./gradlew :packages:rn-tester:android:app:assembleRelease -PreactNativeArchitectures=arm64-v8a, which compiles the generated C++, for the traces above: clean
  • node ./scripts/format-kotlin.js --check, node ./scripts/format-java.js --check, yarn featureflags --verify-unchanged, yarn flow-check, yarn test-typescript-legacy, ESLint and Prettier over the flag sources, and the feature flags' Jest suite: clean

I didn't run the full Android build, the template and HelloWorld app builds, the iOS builds or the e2e jobs locally.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 14, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 14, 2026 11:36
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 14, 2026
@javache

javache commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

The refresh rate can change dynamically, so forever locking to the smallest time may be suboptimal - can we try to use the screen's current refresh rate?

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Updated in 35f8355: pre-mounting now takes half of the default display's current frame, read from DisplayManager when the display reports a change rather than on every frame, so a drop from 120 Hz back to 60 Hz restores the 60 Hz budget. MountItemDispatcherTest covers that switch and DisplayFrameIntervalTest the listener.

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Follow-up in 3d780fb: below 60 Hz the budget stays at today's 8.33 ms instead of half of the longer frame, since an adaptive panel idling at 1 Hz would otherwise pre-mount for 500 ms, and a display that reports no refresh rate gets 8.33 ms as well.

@javache

javache commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

an adaptive panel idling at 1 Hz would otherwise pre-mount for 500 ms

While 500ms is extreme, I do think it's reasonable for us to use more than 8ms here when the system has reduced frame rate (eg in low power mode?). Maybe make the max the equivalent of 30hz / 16ms?

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

9e2fa9c caps the budget at half of a 30 Hz frame (16.67 ms) instead of 8.33 ms; a display that reports no refresh rate keeps 8.33 ms.

@meta-codesync

meta-codesync Bot commented Oct 16, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D125663479.

@rubennorte

Copy link
Copy Markdown
Member

Sorry for chiming in late. I think this goes in the right direction but I did similar explorations in the past and we could end up regressing current apps in production by under-preallocating views for common 120fps devices, which now would preallocate views for half the time they were doing later. This could be good for scroll performance and animations, but could also be bad for initial load and navigations.

Given how sensitive this code path is, if we're going to update it I think we should try to get the best solution possible. In this case, I'd suggest trying idle handlers in the main thread message queue. Instead of checking if there's enough time in an arbitrary deadline, we could just check if there's anything in the queue pending execution (like choreographer callbacks). If there are, we would yield as soon as possible (improving the performance vs. now as we wouldn't go over more items than necessary). If there aren't, we would continue preallocating views, making this more efficient than it is now.

Would you be open to trying this approach? In any case, we would need to validate this via a feature flag because it's a very sensitive codepath for performance.

Thanks!

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 19, 2026 16:02
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/android-premount-frame-budget branch from 9e2fa9c to 6700008 Compare October 20, 2026 17:12
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Release RNTester traces: main against enablePremountItemsBetweenFramesAndroid

Release RNTester (arm64-v8a, Hermes) built from main at 79a5629 with the flag off, and from this PR at 50115fd with the flag on, both with the scenes below added to the View examples. Each scene ran for 24 s after a 9 s warm-up, in rounds main, flag, flag, main (the native-animation arms ran six rounds, main first and last and the flag third and fourth), with the CPUs back near idle before each one. Traced with Perfetto and the frame timeline on Android 14 and 16, and with atrace on Android 10, which has no frame timeline. The RedMagic ran its frames at 144 Hz.

"Vsyncs lost" counts the vsyncs the UI thread drew no frame for; where the frame timeline exists, the vsyncs SurfaceFlinger repeated on screen follow in brackets. "Run past the next vsync" is how far a frame's UI-thread work, its pre-mounting included, ended after the following vsync. "Drawn" counts from the burst's first new shadow node on the JS thread to the end of the frame that draws its mount. Medians, with ranges in brackets.

2400 views beside a native spring, main → flag on:

Galaxy S23, Android 14, 120 Hz RedMagic NX809J, Android 16, 144 Hz Mi MIX 3, Android 10, 60 Hz
views pre-mounted before the commit 482 → 708 1819 → 2400 731 → 1670
vsyncs lost before the commit, per burst 1.6 (0.4) → 0.1 (0.0) 5.5 (4.3) → 0.0 (0.0) 0.0 → 0.0
drawn (ms) 154.6 → 149.3 153.1 → 156.8 386.8 → 344.5
vsyncs lost per burst, commit and mount included 10.7 (10.6) → 7.8 (7.8) 10.9 (11.5) → 4.9 (5.7) 11.1 → 7.9

At 144 Hz main's 8.33 ms budget is longer than the frame, so its UI thread loses 5.5 vsyncs per burst before the commit.

The same beside native-driven animations that keep the UI thread busy past every vsync — 800 on the Mi MIX 3, 1600 and 2400 on the S23 and the RedMagic — where the flag pre-mounts for half a frame before each traversal once two frames in a row have been busy:

Mi MIX 3, 800 S23, 1600 S23, 2400 RedMagic, 1600 RedMagic, 2400
median frame (ms) 23.8 8.9 13.0 9.4 15.3
views pre-mounted before the commit 21 → 374 131 → 122 60 → 80 817 → 557 432 → 590
vsyncs lost before the commit, per burst 3.5 → 3.6 3.8 (3.1) → 1.9 (2.6) 5.0 (5.0) → 1.7 (3.4) 8.8 (8.7) → 6.4 (7.3) 9.3 (1.1) → 5.6 (1.3)
drawn (ms) 469.9 → 436.2 171.7 → 160.2 179.4 → 189.6 170.6 → 166.2 171.1 → 172.3
vsyncs lost per burst, commit and mount included 22.7 → 22.0 17.1 (17.1) → 14.8 (15.2) 21.4 (21.6) → 19.9 (19.9) 16.4 (16.8) → 18.1 (18.8) 20.9 (4.8) → 25.6 (7.2)

With 2400 animations on the S23, few views reach the queue before the commit in either build. On the RedMagic, the frame that draws a commit mounted between frames records its display lists at 1.5–1.8 GHz, where main's one long mount frame reaches 2.4–3.4 GHz before its traversal: the device caps the UI thread's CPU cluster at each frame's start and lifts the cap after about 25 ms of load, which makes that recording 6–9 ms longer. The S23 shows no such cap.

Over 160 translucent full-window layers, which keep the render thread busy past each vsync so the UI thread waits in each draw:

Galaxy S23 RedMagic NX809J Mi MIX 3
views pre-mounted before the commit 194 → 290 720 → 772 0 → 497
drawn (ms) 170.3 → 168.4 169.2 → 174.6 462.5 → 406.8
vsyncs lost per burst, commit and mount included 16.9 (17.2) → 15.6 (16.0) 19.5 (3.4) → 19.9 (3.6) 9.2 → 7.5

Drawn times move from round to round by about as much as the builds differ wherever they are within 10 ms of each other.

Every arm on each handset

Galaxy S23, Android 14, 120 Hz

2400 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 482 (273–734) 708 (506–845)
longest run past the next vsync before the commit (ms) 0.59 (0.35–2.54) 0.07 (0.04–1.07)
vsyncs lost before the commit, per burst 1.6 (0.4) 0.1 (0.0)
from the render's start to the drawn mount (ms) 154.6 (139.4–171.7) 149.3 (140.0–160.6)
vsyncs lost per burst, commit and mount included 10.7 (10.6) 7.8 (7.8)

600 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 198 (96–275) 247 (180–284)
longest run past the next vsync before the commit (ms) 0.49 (0.38–1.35) 0.05 (0.00–0.17)
vsyncs lost before the commit, per burst 0.6 (0.1) 0.0 (0.0)
from the render's start to the drawn mount (ms) 60.2 (42.9–79.3) 55.5 (42.9–70.1)
vsyncs lost per burst, commit and mount included 2.9 (2.9) 1.6 (1.3)

2400 views on a ScrollView fling; 8 bursts on main, 8 with the flag on:

main flag on
views pre-mounted before the commit 693 (448–1015) 669 (636–737)
longest run past the next vsync before the commit (ms) 0.92 (0.64–1.32) 0.06 (0.04–0.07)
vsyncs lost before the commit, per burst 0.4 (0.4) 0.0 (0.0)
from the render's start to the drawn mount (ms) 149.3 (135.1–157.1) 153.2 (146.3–155.6)
vsyncs lost per burst, commit and mount included 8.6 (9.9) 8.0 (8.9)

2400 views over 160 translucent full-window layers; 15 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 194 (108–478) 290 (3–377)
longest run past the next vsync before the commit (ms) 11.64 (10.24–14.36) 9.70 (5.00–13.68)
vsyncs lost before the commit, per burst 2.3 (2.1) 2.3 (1.9)
from the render's start to the drawn mount (ms) 170.3 (148.7–191.5) 168.4 (153.6–180.0)
vsyncs lost per burst, commit and mount included 16.9 (17.2) 15.6 (16.0)

2400 views beside 800 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 272 (84–402) 159 (66–258)
longest run past the next vsync before the commit (ms) 6.56 (4.63–9.46) 3.16 (0.08–10.76)
vsyncs lost before the commit, per burst 1.9 (0.9) 0.6 (0.5)
from the render's start to the drawn mount (ms) 164.9 (141.7–176.9) 164.0 (152.6–175.9)
vsyncs lost per burst, commit and mount included 12.4 (12.1) 10.9 (11.0)

2400 views beside 1600 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 131 (31–402) 122 (1–334)
longest run past the next vsync before the commit (ms) 23.22 (9.20–27.48) 19.33 (10.71–32.37)
vsyncs lost before the commit, per burst 3.8 (3.1) 1.9 (2.6)
from the render's start to the drawn mount (ms) 171.7 (143.5–194.3) 160.2 (139.8–179.1)
vsyncs lost per burst, commit and mount included 17.1 (17.1) 14.8 (15.2)

2400 views beside 2400 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 60 (0–239) 80 (0–364)
longest run past the next vsync before the commit (ms) 30.23 (20.26–45.01) 25.61 (16.64–40.88)
vsyncs lost before the commit, per burst 5.0 (5.0) 1.7 (3.4)
from the render's start to the drawn mount (ms) 179.4 (160.6–211.8) 189.6 (172.3–226.2)
vsyncs lost per burst, commit and mount included 21.4 (21.6) 19.9 (19.9)

RedMagic NX809J, Android 16, 144 Hz

2400 views beside a native spring; 14 bursts on main, 15 with the flag on:

main flag on
views pre-mounted before the commit 1819 (1507–2400) 2400 (2400–2400)
longest run past the next vsync before the commit (ms) 1.80 (0.00–2.49) 0.05 (0.00–0.21)
vsyncs lost before the commit, per burst 5.5 (4.3) 0.0 (0.0)
from the render's start to the drawn mount (ms) 153.1 (149.2–180.2) 156.8 (147.4–172.1)
vsyncs lost per burst, commit and mount included 10.9 (11.5) 4.9 (5.7)

600 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 298 (208–494) 564 (489–596)
longest run past the next vsync before the commit (ms) 1.71 (0.00–3.97) 0.14 (0.03–0.46)
vsyncs lost before the commit, per burst 1.8 (0.3) 0.0 (0.0)
from the render's start to the drawn mount (ms) 61.6 (49.6–70.4) 53.9 (44.3–67.5)
vsyncs lost per burst, commit and mount included 3.6 (3.6) 0.6 (0.6)

2400 views on a ScrollView fling; 8 bursts on main, 8 with the flag on:

main flag on
views pre-mounted before the commit 2354 (1064–2400) 2400 (2400–2400)
longest run past the next vsync before the commit (ms) 0.29 (0.00–0.96) 0.02 (0.01–0.60)
vsyncs lost before the commit, per burst 0.9 (0.5) 0.0 (0.0)
from the render's start to the drawn mount (ms) 106.5 (99.4–112.9) 105.8 (95.5–120.0)
vsyncs lost per burst, commit and mount included 3.1 (4.3) 1.8 (3.0)

2400 views over 160 translucent full-window layers; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 720 (482–1630) 772 (654–866)
longest run past the next vsync before the commit (ms) 13.03 (4.76–14.88) 10.81 (9.06–13.14)
vsyncs lost before the commit, per burst 8.3 (0.3) 6.1 (0.5)
from the render's start to the drawn mount (ms) 169.2 (152.0–178.3) 174.6 (151.9–193.3)
vsyncs lost per burst, commit and mount included 19.5 (3.4) 19.9 (3.6)

2400 views beside 200 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 1996 (1507–2400) 2400 (2400–2400)
longest run past the next vsync before the commit (ms) 3.10 (0.59–4.98) 0.13 (0.07–11.98)
vsyncs lost before the commit, per burst 5.1 (4.2) 0.1 (0.1)
from the render's start to the drawn mount (ms) 159.8 (145.7–173.2) 156.2 (148.4–167.3)
vsyncs lost per burst, commit and mount included 10.7 (11.2) 5.9 (6.7)

2400 views beside 400 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 1761 (1535–1942) 2202 (1307–2400)
longest run past the next vsync before the commit (ms) 4.75 (4.48–10.82) 0.14 (0.02–1.76)
vsyncs lost before the commit, per burst 6.6 (5.7) 0.0 (0.0)
from the render's start to the drawn mount (ms) 157.2 (147.9–168.4) 161.9 (156.0–177.7)
vsyncs lost per burst, commit and mount included 12.6 (12.9) 7.6 (8.5)

2400 views beside 800 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 1671 (975–1909) 755 (520–1022)
longest run past the next vsync before the commit (ms) 7.98 (7.40–18.63) 9.26 (0.39–12.11)
vsyncs lost before the commit, per burst 7.1 (6.9) 1.4 (1.4)
from the render's start to the drawn mount (ms) 160.3 (152.1–178.7) 179.0 (163.6–192.1)
vsyncs lost per burst, commit and mount included 13.2 (13.6) 10.4 (10.9)

2400 views beside 1600 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 817 (354–1394) 557 (236–1285)
longest run past the next vsync before the commit (ms) 21.18 (11.51–29.52) 24.00 (12.89–30.40)
vsyncs lost before the commit, per burst 8.8 (8.7) 6.4 (7.3)
from the render's start to the drawn mount (ms) 170.6 (137.2–190.7) 166.2 (141.2–183.6)
vsyncs lost per burst, commit and mount included 16.4 (16.8) 18.1 (18.8)

2400 views beside 2400 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 432 (51–1092) 590 (218–950)
longest run past the next vsync before the commit (ms) 22.91 (16.33–29.85) 21.48 (16.96–32.08)
vsyncs lost before the commit, per burst 9.3 (1.1) 5.6 (1.3)
from the render's start to the drawn mount (ms) 171.1 (130.4–201.6) 172.3 (142.4–225.9)
vsyncs lost per burst, commit and mount included 20.9 (4.8) 25.6 (7.2)

Mi MIX 3, Android 10, 60 Hz

2400 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 731 (550–835) 1670 (1229–1836)
longest run past the next vsync before the commit (ms) 0.00 (0.00–4.34) 0.00 (0.00–0.03)
vsyncs lost before the commit, per burst 0.0 0.0
from the render's start to the drawn mount (ms) 386.8 (366.4–419.5) 344.5 (318.3–380.7)
vsyncs lost per burst, commit and mount included 11.1 7.9

600 views beside a native spring; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 143 (0–254) 345 (86–453)
longest run past the next vsync before the commit (ms) 0.00 (0.00–6.50) 0.00 (0.00–1.02)
vsyncs lost before the commit, per burst 0.0 0.0
from the render's start to the drawn mount (ms) 118.5 (108.1–138.1) 111.1 (98.1–167.4)
vsyncs lost per burst, commit and mount included 2.2 1.1

2400 views on a ScrollView fling; 6 bursts on main, 6 with the flag on:

main flag on
views pre-mounted before the commit 703 (624–804) 1544 (1431–1664)
longest run past the next vsync before the commit (ms) 0.00 (0.00–0.00) 0.00 (0.00–0.00)
vsyncs lost before the commit, per burst 0.0 0.0
from the render's start to the drawn mount (ms) 374.2 (369.1–378.9) 330.6 (320.6–338.2)
vsyncs lost per burst, commit and mount included 10.8 7.3

2400 views over 160 translucent full-window layers; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 0 (0–61) 497 (326–586)
longest run past the next vsync before the commit (ms) 23.52 (13.62–43.46) 22.18 (11.56–28.01)
vsyncs lost before the commit, per burst 0.1 0.6
from the render's start to the drawn mount (ms) 462.5 (428.8–493.6) 406.8 (394.9–461.5)
vsyncs lost per burst, commit and mount included 9.2 7.5

2400 views beside 200 native-driven animations; 7 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 686 (557–771) 952 (602–1125)
longest run past the next vsync before the commit (ms) 1.45 (0.00–7.22) 0.00 (0.00–43.94)
vsyncs lost before the commit, per burst 0.0 0.6
from the render's start to the drawn mount (ms) 400.7 (377.0–415.4) 396.9 (362.7–428.6)
vsyncs lost per burst, commit and mount included 12.0 11.8

2400 views beside 400 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 383 (132–587) 457 (326–766)
longest run past the next vsync before the commit (ms) 10.72 (2.51–31.04) 5.05 (0.00–38.95)
vsyncs lost before the commit, per burst 0.6 0.6
from the render's start to the drawn mount (ms) 433.8 (408.1–479.7) 420.8 (404.4–455.1)
vsyncs lost per burst, commit and mount included 14.6 14.3

2400 views beside 800 native-driven animations; 14 bursts on main, 14 with the flag on:

main flag on
views pre-mounted before the commit 21 (0–124) 374 (342–516)
longest run past the next vsync before the commit (ms) 29.40 (24.02–52.49) 40.50 (30.67–47.43)
vsyncs lost before the commit, per burst 3.5 3.6
from the render's start to the drawn mount (ms) 469.9 (446.8–504.3) 436.2 (419.9–446.4)
vsyncs lost per burst, commit and mount included 22.7 22.0
The scene: the arms added to RNTester's View examples

Each arm mounts and unmounts count items of two Views. premount-burst-* toggles them every 1.5 s beside a native-driven spring that redraws every vsync; premount-fling-* toggles them on each fling of a horizontal ScrollView, started from an idle screen; premount-gpu160-* is the burst arm over 160 translucent full-window Views, which keep the render thread busy past each vsync; premount-cpu<n>-* is the burst arm beside n small Animated.Views rotated by one native-driven loop, so the UI thread updates every one of them every frame.

function PremountBurstSpinner(): React.Node {
  const rotation = React.useRef(new Animated.Value(0)).current;
  const spin = React.useRef(
    rotation.interpolate({inputRange: [0, 2], outputRange: ['0deg', '360deg']}),
  ).current;
  React.useEffect(() => {
    const spring = Animated.spring(rotation, {
      toValue: 1,
      stiffness: 10,
      damping: 0.01,
      mass: 1,
      useNativeDriver: true,
    });
    spring.start();
    return () => spring.stop();
  }, [rotation]);
  return (
    <Animated.View
      style={{
        width: 48,
        height: 48,
        backgroundColor: '#527FE4',
        transform: [{rotate: spin}],
      }}
    />
  );
}

function PremountBurstItems({
  count,
  mounted,
}: {
  count: number,
  mounted: boolean,
}): React.Node {
  return (
    <View style={{flexDirection: 'row', flexWrap: 'wrap', marginTop: 8}}>
      {mounted
        ? Array.from({length: count}, (_, index) => (
            <View
              key={index}
              style={{
                width: 8,
                height: 8,
                margin: 1,
                backgroundColor: '#e5e7eb',
              }}>
              <View style={{width: 4, height: 4, backgroundColor: '#7c3aed'}} />
            </View>
          ))
        : null}
    </View>
  );
}

function PremountBurstBlock({count}: {count: number}): React.Node {
  const [mounted, setMounted] = useState(false);
  React.useEffect(() => {
    const interval = setInterval(() => setMounted(previous => !previous), 1500);
    return () => clearInterval(interval);
  }, []);
  return <PremountBurstItems count={count} mounted={mounted} />;
}

function PremountBurstExample({count}: {count: number}): React.Node {
  return (
    <View testID="premount-burst">
      <PremountBurstSpinner />
      <PremountBurstBlock count={count} />
    </View>
  );
}

const PREMOUNT_FLING_COLUMNS = Array.from({length: 400}, (_, index) => (
  <View
    key={index}
    style={{
      width: 48,
      height: 96,
      backgroundColor: index % 2 === 0 ? '#527FE4' : '#7c3aed',
    }}
  />
));

function PremountFlingExample({count}: {count: number}): React.Node {
  const [mounted, setMounted] = useState(false);
  return (
    <View testID="premount-fling">
      <ScrollView
        horizontal={true}
        testID="premount-fling-scroller"
        style={{height: 96}}
        onMomentumScrollBegin={() => setMounted(previous => !previous)}>
        {PREMOUNT_FLING_COLUMNS}
      </ScrollView>
      <PremountBurstItems count={count} mounted={mounted} />
    </View>
  );
}

function PremountGpuExample({
  count,
  layers,
}: {
  count: number,
  layers: number,
}): React.Node {
  const {height} = useWindowDimensions();
  return (
    <View testID="premount-gpu" style={{height}}>
      {Array.from({length: layers}, (_, index) => (
        <View
          key={index}
          pointerEvents="none"
          style={[
            StyleSheet.absoluteFill,
            {backgroundColor: 'rgba(124, 58, 237, 0.02)'},
          ]}
        />
      ))}
      <PremountBurstSpinner />
      <PremountBurstBlock count={count} />
    </View>
  );
}

function PremountBusyAnimations({count}: {count: number}): React.Node {
  const progress = React.useRef(new Animated.Value(0)).current;
  const rotate = React.useRef(
    progress.interpolate({inputRange: [0, 1], outputRange: ['0deg', '360deg']}),
  ).current;
  React.useEffect(() => {
    const loop = Animated.loop(
      Animated.timing(progress, {
        toValue: 1,
        duration: 2000,
        easing: Easing.linear,
        useNativeDriver: true,
      }),
    );
    loop.start();
    return () => loop.stop();
  }, [progress]);
  return (
    <View style={{flexDirection: 'row', flexWrap: 'wrap'}}>
      {Array.from({length: count}, (_, index) => (
        <Animated.View
          key={index}
          style={{
            width: 6,
            height: 6,
            margin: 1,
            backgroundColor: '#f59e0b',
            transform: [{rotate}],
          }}
        />
      ))}
    </View>
  );
}

function PremountCpuExample({
  count,
  animated,
}: {
  count: number,
  animated: number,
}): React.Node {
  return (
    <View testID="premount-cpu">
      <PremountBusyAnimations count={animated} />
      <PremountBurstSpinner />
      <PremountBurstBlock count={count} />
    </View>
  );
}

@YevheniiKotyrlo YevheniiKotyrlo changed the title Budget Android pre-mounting by the display's frame, not a 60 Hz one Pre-mount Android views in the time each frame leaves free, behind a flag Oct 20, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Thanks, I tried idle handlers first. A vsync and an input event only reach the main queue when the looper polls, so a running idle handler can't see the next frame coming: MessageQueue.isIdle() stayed true across frames on every device and system image I tried, and draining until something was queued froze the UI thread for the whole backlog.

So I've reworked this PR behind enablePremountItemsBetweenFramesAndroid, off by default, to pre-mount in the time each frame leaves free: after the frame in 1 ms slices until the next vsync, and before the traversal for as long as the previous frame's draw waited on the render thread. On a Galaxy S23 and a RedMagic at 120 Hz and a Mi MIX 3 at 60 Hz it pre-mounts more views before each commit than today and loses fewer vsyncs per burst; the traces are in the comment above.

…flag

With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI frame
callback no longer pre-mounts for half of a 60 Hz frame before the frame's
traversal. PreMountItemsBetweenFrames pre-mounts after the frame, in slices
posted to the UI thread's looper as asynchronous messages, each starting
items for about 1 ms, until the next vsync: from Android 13 the spacing of
the frame timelines the Choreographer hands a vsync callback, otherwise the
display's refresh rate. After a frame that left no time, it also pre-mounts
before the next traversal: for as long as that frame's draw slept, or for
half a frame once two frames in a row each took a frame interval of their
own work, at most half a frame and half of a 30 Hz frame either way. A
commit whose mount items arrive while the slices run is mounted by the
next slice, which first runs every pre-mount item left; one the pull model
queues as a surface to pull waits for the frame, and mount items wait with
it. Slices run only while the frame callback would mount.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/android-premount-frame-budget branch from e71fb78 to 50115fd Compare October 22, 2026 09:17
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 22, 2026 11:57
@javache
javache requested a review from rubennorte October 22, 2026 12:56

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

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants