跪拜 Guibai
← Back to the summary

Flutter's 7-Year-Old Touch Smoother Was Adding a Frame of Latency on Modern iPhones

Recently, PR #191368 fixed a long-standing issue in Flutter. This issue was discovered by chance during a discussion in another issue, #190815. Knopp recently found that iOS tends to schedule Flutter on E-cores. Specifically:

Even when QoS is set correctly, the main thread and raster thread are scheduled on E-cores by iOS for a long time. The system seems to think that each frame of Flutter requires very little work and is not worth occupying a P-core. However, E-cores are easily preempted by other background activities. A slight delay can cause a missed frame deadline. Interestingly, after Knopp artificially increased the computational load on the UI and raster threads, iOS migrated them to P-cores, and the jank disappeared.

So the problem changed: Sometimes, optimizing too well causes the system to deliberately hold you back. This issue is similar to the one in PR #191368, where a very small scheduling delay causes an entire frame to be missed.

For example, the old Flutter touch flow looked something like this:

touchesMoved
     ↓
PostTask to UI
     ↓
Dart / Framework processing
     ↓
RequestFrame
     ↓
Another PostTask
     ↓
AwaitVSync
     ↓
CADisplayLink

From a CPU perspective, a PostTask might only add tens or hundreds of microseconds, but from a frame scheduling perspective, it's different. Suppose:

touchesMoved                   0.0 ms
Current CADisplayLink cycle    0.7 ms

Flutter originally had only a 0.7 ms window to complete the 'pointer handling - RequestFrame - AwaitVSync' operation. If the thread runs stably on a P-core, the 'PostTask - wake up quickly - 0.2 ms' flow is generally fine. But if it's on a crowded E-core, this might happen:

PostTask
     ↓
Thread temporarily doesn't get CPU time
     ↓
Runs after 0.8 ms
     ↓
CADisplayLink has already passed

The actual loss here is not 0.6 ms. In a real scenario, Flutter has to wait for the next VSync:

This means iOS's touch-to-present latency is reduced by a whole frame.

Of course, the problem in #191368 actually dates back a long time, to the iPhone X era. It was originally designed for touch event jitter on iPhone X / XS models.

On early iOS 11-12, the hardware sampling itself was relatively stable, but the timing of UIKit delivering touch events to the application was very uneven. Two events might arrive in one frame, none in the next, and two more in the frame after. If Flutter accepted all of them, the visual scrolling step size would fluctuate.

So Flutter added a SmoothPointerDataDispatcher. Its core goal was: when a previous pointer packet is still in a processing cycle, new packets are cached in pending_packet_ and dispatched on the next VSync.

This mechanism was designed to smooth out irregular input delivery on the iPhone X/XS, at the cost of potentially adding one input cycle of latency. This optimization has persisted for nearly 7 years.

In reality, the Flutter iOS team recently tested a batch of devices from the iPhone 6S and XS to the iPhone 17 Pro in issue #191199. The touch delivery time spread on the historical iPhone XS / iOS 12 was about 12.08 ms, equivalent to 72% of a 60 Hz frame. On current devices and systems, this number has converged to a very small range:

Test Environment Touch delivery spread Rate of SmoothPointerDataDispatcher forced stash
120 Hz, correction enabled 0.18 ms 99.2%
120 Hz, correction disabled 0.21 ms 99.3%
60 Hz, iPhone 17 Pro Low Power Mode 0.15 ms 99.5%
60 Hz, iPhone XS / iOS 18.7.10 1.47 ms 99.4%
60 Hz, iPhone 6S / iOS 15.8.8 0.83 ms 0%
iPhone XS / iOS 12, 2019 data 12.08 ms

In other words, on many current devices, UIKit's touch delivery is already very stable, but Flutter's 'smoother' layer is still actively pushing about 99% of pointer packets to the next VSync.

For example, if a packet arrives just after a VSync boundary and gets stashed, is_pointer_data_in_progress_ can easily remain true, and subsequent drag events will continuously be in a 'one beat late' mode until the gesture ends.

At 60 Hz, one frame is 16.67 ms, and at 120 Hz, it's 8.33 ms. For interactions like scrolling and dragging, where visuals are directly tied to the finger, this difference is completely perceptible.

However, in subsequent tests, after removing Smooth, some iPhones dropped directly from 60 fps to 30 fps, so the problem wasn't that simple.

Knopp did the most direct experiment: just replaced Smooth with Default, leaving everything else unchanged:

That is, 120 Hz devices dropped to 60 fps, and 60 Hz devices could even drop to 30 fps. This led to another mysterious discovery:

SmoothPointerDataDispatcher had also been inadvertently masking a scheduling defect in the iOS VSync waiter for years.

Classic and unexpected, right? One bug was a feature for another problem for many years. The old flow was roughly like this:

UIKit touchesMoved
        ↓
Platform → UI PostTask
        ↓
pointer processing
        ↓
Framework RequestFrame
        ↓
Animator PostTask again
        ↓
AwaitVSync
        ↓
Start / Wake up CADisplayLink
        ↓
Wait for next CADisplayLink
        ↓
BeginFrame

There are two classic 'execute later' points here:

In ordinary asynchronous work, these two PostTasks might only take a tiny amount of CPU time. But within a single iOS frame, what they change is whether you can catch the CADisplayLink callback of the current UIKit update cycle.

If AwaitVSync misses this round of display-link dispatch, the loss is not tens of microseconds, but the next 8.33 / 16.67 ms VSync. So this optimization is really about phase.

Therefore, this PR couldn't simply remove Smooth. The entire touch flow needed to be re-engineered to compress the touch-to-VSync request back into the same run-loop turn, like this:

touchesMoved
     ↓
Framework processes pointer and requests a new frame
     ↓
VSync request
     ↓
CADisplayLink callback

The first three steps here must be completed synchronously within the same runloop turn. So #191368 actually dismantled several waiting points in succession:

The third one is a key modification. After the Framework calls scheduleFrame due to a scroll position change, the Engine can immediately send the 'I want the next frame' intent into the VSyncWaiter. This eliminates the need to wait for the current run-loop task to end and return. The final timing has the opportunity to become:

Same UIKit UI Update

Event Dispatch
    │
    ├─ touchesMoved
    │    └─ Flutter pointer processing
    │          └─ scheduleFrame
    │                └─ AwaitVSync
    │
    └─ CADisplayLink callback
             └─ BeginFrame

This structure actually aligns with Apple's now-public UIKit update phase order. A standard UI update goes through:

beforeEventDispatch → afterEventDispatch → beforeCADisplayLinkDispatch → afterCADisplayLinkDispatch → beforeCATransactionCommit → afterCATransactionCommit. These phases run continuously without exiting to the next run loop in between.

Knopp himself verified timestamps on iOS 18 and iOS 26, measuring that CADisplayLink arrives about half a millisecond after touchesMoved, and multiple samples maintained this sequence.

In other words, as long as Flutter doesn't insert extra event-loop turns itself, it does have a chance to register a new frame request before the display link arrives.

Moreover, the VSyncWaiter modification solved another pitfall: the first frame no longer foolishly waits for a just-started CADisplayLink.

In fact, just making AwaitVSync synchronous wasn't enough, because the previous '60 fps to 30 fps' experiment had already proven that iOS's CADisplayLink, when just awakened from a paused state, does not give Flutter a callback for the current frame. So #191368 added another layer of special handling for VsyncWaiterIOS:

In the past, FlutterVSyncClient would pause the CADisplayLink by default every time it received a tick. The next time Flutter requested a frame, it would call await() to unpause it. The allowPauseAfterVsync in the current VSyncClient.swift is indeed true by default, and await() itself just sets isPaused to false.

The new solution sets allowPauseAfterVsync to false, keeping the DisplayLink running during continuous interaction, and introduces waiting_for_vsync_:

DisplayLink is already running
     ↓
AwaitVSync only sets waiting_for_vsync_ = true
     ↓
The immediately upcoming real CADisplayLink tick consumes this request

Then, the first frame from an idle state takes a different path:

DisplayLink is currently paused
     ↓
Unpause DisplayLink
     ↓
Immediately FireCallback()
     ↓
Flutter immediately BeginFrame

This solves the problem of dropping to 30 fps after removing Smooth. Previously, the DisplayLink was turned off every frame, and turned back on when a touch arrived, then waited for its next tick, which made it very easy to miss the current display opportunity.

However, during this modification process, Knopp was continuously tormented by Flutter's AI Review, to the point of speechless frustration:

After seeing the newly added waiting_for_vsync_, Gemini Code Assist reported several critical issues in a row:

It believed that AwaitVSync() modifies this variable on the UI thread, while the CADisplayLink callback reads it from the Platform thread, thus creating a data race and suggesting the addition of std::mutex.

The author's first reply was already very clear:

“UI thread and platform thread are the same thing.”

Then Gemini continued to deduce race/deadlock based on the multi-threading assumption. The author replied again, "No mutex needed." Then Gemini continued its nonsense, and Knopp was directly helpless:

In Flutter's Settings, merged_platform_ui_thread is already kEnabled by default, meaning the Platform TaskRunner and UI TaskRunner share the Platform thread. The iOS configuration layer doesn't even allow disabling it via FLTEnableMergedPlatformUIThread=false, but Gemini seems to be living in the last century.

And that's not all. The AI always makes edge-case protections in strange places. But if you don't explain and get approval, the PR can hardly move to the next step. So AI Review can sometimes be very annoying:

So the entire bug and problem has many historical factors, a long time span, and also strange system scheduling strategies. If you optimize too well, the system thinks you don't need that much overhead and throws you onto the E-core. True performance optimization is sometimes indeed very bizarre.