9

Flutter Shim

One script tag that makes Flutter web run at 120 Hz on Android Chrome instead of 60.

120 Hz Flutter web on Android Chrome, with one script tag.

Open the live demo in Chrome on an Android phone with a 120 Hz screen. Pick an app, then tap the badge in the corner to switch between Before and After.

The problem

On a 120 Hz Android phone, ordinary web pages scroll at 120 frames per second, but Flutter web apps scroll and animate at 60. Chrome on Android limits how often a page's main thread may start a frame. Normal pages scroll on Chrome's compositor thread, which isn't limited. Flutter draws everything itself on the main thread, which is. Chrome lifts the limit while your finger is moving, so dragging runs at 120, but the fling after you let go drops back to 60. That drop is what you notice.

The fix

A web worker that owns a canvas gets its own frame clock straight from the compositor, and Chrome doesn't limit it. flutter_shim.js moves Flutter onto that clock in three steps:

  1. Display. Flutter's on-screen canvas is handed to the worker. Flutter keeps drawing as usual, and each finished frame is forwarded to the worker, which shows one per refresh.
  2. Clock. requestAnimationFrame runs on the worker's ticks. Frame times are kept strictly increasing and snapped to the refresh grid, because Flutter's scroll physics jump if time stalls or goes backwards.
  3. Touch. Chrome delivers touch moves in step with its own frames, not the worker's, so slow drags would stutter. The shim holds them back and gives Flutter exactly one move per finger on each tick, interpolated the way Android does for native apps.

The app's code doesn't change, and users don't change any settings. It only switches on in Chrome on Android and does nothing anywhere else.

Results

Measured on a Pixel 10 Pro XL with Chrome 154 and Flutter 3.47.2. Each app is the same build, with and without the shim, measured after lifting the finger:

  • Plain list: 57 → 113–120 fps
  • Material 3 demo: 56 → 111 fps
  • Flutter Gallery (CanvasKit): 57 → 114 fps
  • Wonderous: 59 → 113–120 fps

With the shim, 97–100% of frames reached the screen one refresh (8.3 ms) after the previous one. Slow drags got much smoother too: how much the per-frame movement varies dropped from 16–30% to 1–5% in three of the four apps.

Trade-offs

  • It relies on undocumented Chrome behaviour. If Chrome starts limiting worker clocks too, the shim simply runs at 60, as Flutter does today.
  • Battery. While something animates, Flutter does twice the work per second. An idle page costs nothing, because the worker clock stops a few refreshes after the last frame.
  • Renderer. Use single-threaded skwasm or CanvasKit for now. Multi-threaded skwasm on Flutter stable has a race that 120 Hz makes more likely. The fixes are on Flutter master but not in stable yet.
  • No iPhone version. Safari gives workers a fixed 15 ms timer, so there's no faster clock to borrow. The demo includes a clock probe page that will show it if a future Safari changes that.

How I tested it

The demo serves four apps twice from the same build. Before is the app as it ships, and After adds the one script tag. I also wrote a tool that records the phone's screen over adb and reports frames per second with the finger down and after release, how many frames were on time, and how even slow drags are.

Stack

JavaScript (web workers, OffscreenCanvas), Flutter web (skwasm and CanvasKit), Python and Node tooling, Docker.