Repository navigation
Description
Overview
SmoothPointerDataDispatcher was written to smooth out irregular touch delivery on the iPhone X/XS running iOS 11~12 for devices that are now on Apple's vintage (iPhone XS) and obsolete (iPhone X) lists. We were reportedly seeing delivery spreads of up to 12.08ms (72% of a frame) at the time.
Measurement on all devices on my desk (iPhone 13, iPhone 14, iPhone 16 Pro, iPhone 17 Pro) running iOS 18, 26, and 27 show that touch delivery is now very consistent (delivery spread of 0.15~0.21ms) at both 60Hz and 120Hz, and that SmoothPointerDataDispatcher is actually a net negative that's adding an extra frame of latency to 99% of frames.
In the 8 years since we wrote this code, iOS has changed a ton and we've significantly changed Flutter's threading -- in particular merged UI/Platform thread, which has a significant effect on how vsync is coordinated across threads.
Background
Touches are delivered from UIKit at roughly one per display refresh, and the engine then rebuilds a frame. Ideally those two things line up so we get one touch triggering one frame, which then shows one small step of scroll movement or similar.
A long time ago, around when the iPhone X/XS shipped, we were getting reports that delivery of these events within the frame were irregular, i.e. there was a phase shift so we'd see delivery like:
|----1----|----2----|----3----|----4----|
| * | * *| | * * |
| 1 event | 2 events| 0 events| 2 events|
|----1----|----2----|----3----|----4----|
Frames that got 1 event were fine. Frames with 0 events show no movement. Frames with 2 events rendered only the newest position. So you ended up with jank in animation.
SmoothPointerDataDispatcher was written to rate-limit the engine's pointer event handoff to the framework. When a touch arrives and the engine is already working through one for the current period, we don't hand the new one over. Instead, we park it and hand it over on the next vsync. So in the example above, we'd deliver the first event from frame 2 to the framework, park the second, and deliver that in frame 3, which would have been empty otherwise. The tradeoff this makes is that we improve the evenness that we deliver events at in exchange for increased average latency of those events.
There's a lot of gross gotchas with this including the fact that not all touches produce frames (e.g. a touch in an empty non-scrolling bit of the screen) so we need some mechanism to time release of events so they don't get stuck, which is how we eneded up with a second CADisplayLink (in addition to the one VSyncWaiterIOS already owns, because that might be paused) to time release of these things on vsync.
What's wrong with the current design
I tested on hardware from the past 11 years (iPhone 6S, iPhone XS, iPhone 17 Pro) and found delivery was consistent to within 1% of a frame interval on all devices.
The good news: there's no inconsistency to absorb.
The bad news: SmoothPointerDataDispatcher delays roughly 99% of touch events by a full vsync interval. (but they're consistent 😂)
In a sense these are both good news since both of these mean that deleting this code is a win.
Method
All measurements were taken on ios_profile builds using flutter_scroll_overlay as the test app.
Patch
I applied this patch at 875be1c: touch-delivery-instrumentation.patch
Measuring delivery consistency
The whole premise was that sampling was accurate but delivery was jittery, so we want to measure (with SmoothPointerDataDispatcher enabled and disabled):
- How consistent is sampling?
- How consistent is delivery?
- What's the latency of delivery?
For sampling and delivery, I collected records from dispatchTouches::
CACurrentMediaTime()at touch event deliveryUITouch.timestampthe hardware sample timeUITouchPhase[event coalescedTouchesForTouch:].count
I recorded these in a buffer throughout gestures and dumped to log on gesture end and measured with SmoothPointerDataDispatcher enabled and disabled.
Measuring delivery latency
For latency, I added counters in SmoothPointerDataDispatcher::DispatchPacket:
- total: how many packets through the dispatcher
- stashed: how many were held back to the next vsync
- displaced: how many were superseded by a newer one. i.e. how many stashed packets got immediately released upon a second packet arriving. (
SmoothPointerDataDispatcheronly stashes one and dispatches it immediately the second another comes in)
Measuring test behaviour
I dumped my own measured delivery times through input_events_unittests with smoothing on and off using our existing TestSimulatedInputEvents harness. I also dumped through the iPhone XS delivery times added in ShellTest in 2019 as a control.
Configurations tested
- 120Hz with smoothing (how we ship today, ProMotion phone)
- 60Hz with smoothing (how we ship today, non-ProMotion phone)
- 120Hz without smoothing and correction (disabled
SmoothPointerDataDispatcher) - 120Hz without smoothing or correction (disabled
SmoothPointerDataDispatcher, disabled_touchRateCorrectionVsyncClient) - 60Hz without smoothing (disabled
SmoothPointerDataDispatcher)
What is "correction" here? UIKit throttles touch delivery to 60Hz unless it sees a main-thread animation running. In flutter-team-archive/engine#35592 we landed a second CADisplayLink with an empty callback to trick CoreAnimation into believing that an animation is running. This was required at the time because the iOS embedder's real CADisplayLink was running on the UI thread, not the main thread. But we merged those, so we shouldn't need it anymore.
Observations
Delivery spread / phase jitter
Results of my test runs vs the original iPhone XS data from iOS 11~12 in 2019. I deleted data where over 2.5 frame intervals passed with no touch event to account for times where my finger paused on the screen.
| configuration | measurements | 1x frames | 2x frames | frame cadence | delivery spread |
|---|---|---|---|---|---|
| 120Hz, correction on | 880 | 872 (99.1%) | 8 (0.9%) | 8.33 ms, 99.1% | 0.18 ms (2.1% of a frame) |
| 120Hz, correction off | 924 | 864 (93.5%) | 60 (6.5%) | 8.33 ms, 93.5% | 0.21 ms (2.5%) |
| 60Hz (ProMotion device in low-power mode) | 951 | 948 (99.7%) | 2 (0.2%) | 16.67 ms, 99.7% | 0.15 ms (0.9%) |
| 60Hz (iPhone Xs, i0S 18.7.10) | 500 | 500 (100.0%) | 0 (0.0%) | 16.67 ms, 100.0% | 1.47ms (8.8%) |
| 60Hz (iPhone 6s, iOS 15.8.8) | 423 | 422 (99.76%) | 1 (0.24%) | 16.67 ms, 99.76% | 0.83 (5.6%) |
| 60Hz (iPhone Xs, iOS 12.x, reported 2019) | ? | ? | ? | 60Hz | 12.08 ms (72%) |
In the above table "1x frames" is the count of frames at the configured refresh rate that had a touch event, "2x frames" is the number of frames where a touch event happened followed by a frame interval without one.
TL;DR the delivery spread is two orders of magnitude tighter than the world SmoothPointerDataDispatcher was written for. Delivery was pretty phase-locked the entire time.
UITouch coalescing
Data consistently showed 2 events per delivery at 120 Hz on an iPhone 17 Pro (240 Hz touch input) and 4 per delivery when restricted to 60 Hz (tested both via Battery > low power mode and Accessibility > Restrict frame rate).
We don't call coalescedTouchesForTouch: or predictedTouchesForTouch: anywhere in the embedder; we're using the coalesced touches already. This was expected but I wanted to be 100% sure.
Touch correction client
Now that we've moved the main CADisplayLink owned by FlutterDisplayLinkManager (which is held by VsyncWaiterIOS among others) we've already got a CADisplayLink on the main thread. We no longer need the correction client. Turning it off had zero effect on phase jitter.
Dispatcher latency
Looking at the data captured in SmoothPointerDataDispatcher on how many frames we stashed (delayed by a vsync) and how many were displaced (stashed but then another event came in and we immediately fired the stashed one):
| configuration | stashed | displaced |
|---|---|---|
| 120Hz, correction on (shipping config) | 99.2% | 1 / 360 |
| 120Hz, correction off | 99.3% | 0 / 960 |
| 60Hz (iPhone 17, low power mode) | 99.5% | 1 / 840 |
| 60Hz (iPhone Xs, iOS 18.7.10) | 99.4% | 0 / 480 |
| 60Hz (iPhone 6s, iOS 15.8.8) | 0.0% | 0 / 840 |
Whether a pointer event ends up stashed or not comes down to a race against the vsync boundary. A packet arriving just before the vsync gets passed through unstashed (assuming no event prior to that has been stashed. A packet arriving just after it is stashed. The end result is that once a packet has been stashed, every later callback finds one pending and the flag never clears until the gesture ends. The table above shows that for all devices other than the the oldest one (the iPhone 6s), over 99% of packets ended up delayed a vsync.
All it takes is one packet arriving after the vsync boundary and we end up in this mode. We only clear is_pointer_data_in_progress_ when the secondary vsync callback finds pending_packet == nullptr:
| dispatcher->is_pointer_data_in_progress_ = false; |
Using @knopp's demo app and the extra instrumentation in the engine, the offsets from native UIKit touch handling was smaller, but not entirely gone (because #110431 is still an issue).
What should we do about it?
We should delete SmoothPointerDataDispatcher. Across a broad pile of hardware on iOS 18, 26, and 27 it delayed consistently delayed ~99% of touches in my teting. Fuchsia ripped it out in flutter-team-archive/engine#25495 in 2021. We should do the same.
Video evidence
You can still see clear evidence of #110431 in both, but ripping it out has a positive impact on latency. Effectively we're going from two frame delay to a one frame delay.
| SmoothPointerDataDispatcher enabled | SmoothPointerDataDispatcher disabled |
|---|---|
SmoothPointerDataDispatcherEnabled.mov |
SmoothPointerDataDispatcherDisabled.mov |
Activity
knopp commentedon Aug 17, 2026
Without other changes I tried simply replacing
SmoothPointerDataDispatcherwithDefaultPointerDataDispatcherand it will halve the framerate during dragging. It's not really obvious on 120hz phones, but is very obvious on an iPhone XS. I added very simple timing logging to framework showing the timing ofhandleBeginFrame.This is iPhone 13 Pro with
SmoothPointerDataDispatcherwhile touch dragging (not flicking!)Same device with
DefaultPointerDataDispatcher:You can see that the frames are 8ms apart with
smoothand 16ms apart withdefault. On iPhone XS the times are 16ms and 32ms respectively.Why does this happen?
It would seem that
SmoothPointerDataDispatcheractually fixes some things, but it merely hides the real problem. Which is that our iOS vsync waiter does not fire immediately. What happens is that when we receive touch event, the framework asks for a vsync to start rendering the frame. The vsync waiter in turn stars aCADisplayLinkand waits for it to fire. This is too slow. The touch event come at the beginning of frame cycle and we waste entire frame waiting forCADisplayLinkto tick. MacOS embedder has logic to short circuit this and the iOS waiter will need to do something similar. When a touch event is received we need to start rendering the frame immediately and not wait for an actual vsync. The frame delay + immediately pausing CADisplayLink after is what causes the halved frame rate.SmoothPointerDataDispatcherhides this by scheduling asecondary vsyncwhich is a rather confusing feature originally added to address some fuchsia peculiarities, but the bottom line is that it requests another vsync tick where it dispatches a delayed touch event. This somewhat fixes the framerate issue at the expense of increased latency.I can confirm that on both devices (XS and 13PRO) the touch events are coming at expected rate and I'd assume that now that CADisplayLink is on main thread it should fire at the right place in frame cycle. When armed on touch, it does fire too late and we likely also shut it down too fast and this needs to be addressed.
knopp commentedon Aug 17, 2026
There is one additional thing we need to consider. Touch events come early in the frame cycle, but I am not completely certain that display link callback might not come even earlier. If that happens we need to make sure that our vsync waiter will not trigger frame before the framework has a chance to handle touch event.
cbracken commentedon Aug 17, 2026
Updated the Method section to include the patch. What's interesting is that the response to removing this is bimodal depending on touch/vsync timing. For some devices at some framerates,
SmoothPointerDataDispatcherwas effectively passthrough, for others it adds an extra frame of delay (on top of what flutter has through it's design) but has a side-effect of synchronising how we deliver touch relative to the vsync.We can't just rip it out but I've experimented a bit with how we could get the side-effects without the dispatcher, which I think I've proven above is not doing the job it was built for. It's doing a another job, and we need part of that.
knopp commentedon Aug 17, 2026
Good news is that
CADisplayLinkon main thread is very well behaved and consistently fires after touch events are dispatched:This means that even with
CADisplayLinkrunning during dragging the framework can always process touch events before rendering frame. Tested with both iOS 18 and 26.cbracken commentedon Aug 18, 2026
Alright I picked up an iPhone 6S (bottom-tier iOS 15 device we support along with the iPhone SE) and an iPhone XS. The iPhone 6S delivered smooth framerates and input handling with smoothing disabled. The iPhone XS was jittery with smoothing disabled.
Videos for the iPhone 6S:
iPhone.6S.SmoothingEnabled.mov
iPhone.6S.SmoothingDisabled.mov
Videos for the iPhone XS:
iPhone.XS.SmoothingEnabled.mov
iPhone.XS.SmoothingDisabled.mov
knopp commentedon Aug 18, 2026
The XS going at glorious 30 fps is perfectly expected given how broken our vsync waiter is. The unexpected part is the iPhone 6S being smooth :) I am very curious why.
[-]iOS: Delete `SmoothPointerDataDispatcher`[/-][+]iOS: Delete `SmoothPointerDataDispatcher` and `_touchRateCorrectionVSyncClient`[/+]cbracken commentedon Aug 19, 2026
I'm still collecting data but the data I have so far points to timing relative to vsync.
knopp commentedon Aug 19, 2026
Possible approach to discuss #191368
iOS: Add null-check for touch rate correction task runner
iOS: Add null check for touch rate correction task runner (flutter#19…
Cleanup VSync infrastructure and improve iOS touch to present latency (…
cbracken commentedon Oct 21, 2026
Before and after videos for the iPhone 6S:
iPhone.6S.SmoothingEnabled.mov
iPhone.6S.with.191444.mov
Apologies for the small change in the sample app. I'll see if I can re-do the before video with the updated app.
Cleanup VSync infrastructure and improve iOS touch to present latency (…
github-actions commentedon Nov 4, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.