Zygote
Graphicsintermediate

The life of a frame

From a touch to photons: how one frame moves through the UI thread, the RenderThread, the GPU, BufferQueue, SurfaceFlinger and the display — and why 16.67 ms is the number that governs everything.

13 minAPI 1–37 · 17interactivequizverified 2026-10-27

A display does not draw when your code is ready. It draws when its own clock says so, sixty, ninety or a hundred and twenty times a second, and every frame is either finished by then or shown a beat late. Everything in Android's graphics stack is arranged around that deadline.

TL;DR#

  • A frame is produced against a vsync deadline, not on demand. At 60 Hz the budget is 16.67 ms; at 90 Hz it is 11.11 ms; at 120 Hz it is 8.33 ms.
  • The work is split across two threads by default: the UI thread measures, lays out and records drawing commands; the RenderThread turns those commands into GPU work.
  • The finished image goes into a BufferQueue, which decouples your app from SurfaceFlinger, the compositor, and from the display.
  • SurfaceFlinger combines every visible layer and hands the result to the Hardware Composer, which scans it out.
  • A frame that misses its deadline is jank: the display repeats the previous frame and your input feels late.

Mental model#

Think of a newspaper printing press that runs on a fixed schedule.

The editors are your app's UI thread: they decide what goes on the page, write the headlines and lay out the columns. They have until the press starts to finish the plate.

The platemakers are the RenderThread and the GPU: they take the editors' layout and turn it into something physical that can be printed.

The press is the display. It does not wait. If the plate is not ready when the drum turns, it prints the previous plate again.

The distribution desk is SurfaceFlinger: it collects plates from every department — the app, the status bar, the keyboard — and decides which ones go on the page and in what order.

Where the analogy breaks#

A press prints one plate. A phone composites layers: the app's window, the status bar, the navigation bar, the keyboard, each with its own buffer and its own producer. SurfaceFlinger does not merge them into one image unless it has to — the Hardware Composer can often scan several layers out simultaneously as overlays, which is cheaper and lower-latency than drawing them together. And unlike a newspaper, a frame is not a fixed object: the system may decide mid-flight that this frame will be shown at 120 Hz and the next at 60, depending on what is moving.

The big picture#

Loading diagram…
Text description of this diagram

Eight stages of a frame from input to photons. Input wakes the UI thread; Choreographer runs animation callbacks; ViewRootImpl measures and lays out the view tree; draw records display lists and hands them to the RenderThread; the RenderThread turns them into GPU commands; the GPU renders into a buffer that is queued on a BufferQueue; SurfaceFlinger composes the visible layers with help from the Hardware Composer; and the display scans the result out at the next vsync. At 60 Hz the whole budget is 16.67 ms, at 90 Hz 11.11 ms, at 120 Hz 8.33 ms. The slow-onDraw toggle shows the draw stage overrunning and the frame slipping a vsync.

The diagram's costs are illustrative, but the budgets are exact arithmetic: one second divided by the refresh rate. The important shape is that the stages are serial within a frame and pipelined across frames. While the RenderThread is turning frame N into GPU commands, the UI thread can already be working on frame N+1.

Deep dive#

Vsync is the clock#

A vsync is the display's signal that it has finished scanning out a frame and is about to start the next. SurfaceFlinger receives it, timestamps it and distributes it to whoever needs to draw.1 Apps do not poll for it; they subscribe to it.

The framework's subscription mechanism is . You give it a callback and it runs that callback at the start of the next frame, with the frame's timestamp in nanoseconds:

Choreographer.getInstance().postFrameCallback { frameTimeNanos ->
    // runs once, at the beginning of the next frame
    animate(frameTimeNanos)
}

Choreographer runs its callbacks in a fixed order: input first, then animation, then view traversal (measure, layout, draw), then the sync to the RenderThread. That order is deliberate. Animation must see the freshest input, and traversal must see the values animation just produced, all inside the same frame.2

The UI thread: measure, layout, draw#

When a frame is due, ViewRootImpl.performTraversals() walks the view tree. Measure assigns sizes, layout assigns positions, and draw records the tree into a hierarchy of RenderNodes — display lists, not pixels. A view that did not change can keep its recorded display list, which is why a static screen costs almost nothing to re-render.

Compose uses the same underlying machinery but a different composition model: instead of a retained view tree it re-runs the composables whose state changed, then records the resulting layout nodes. The frame clock is still Choreographer's.

The output of this stage is not an image. It is a description of drawing, handed across a thread boundary.

The RenderThread#

SyncAndDrawFrame passes the recorded display lists to the , a thread that belongs to the app process but runs independently of the UI thread. The RenderThread replays the display lists through and Skia and issues GPU commands.3

This split is the reason a slow onDraw and a slow GPU do not simply add up. They overlap with the next frame's UI-thread work. A frame that takes 20 ms on the UI thread and 20 ms on the RenderThread cannot run at 60 Hz, but the two 20 ms slices overlap, so the steady-state throughput is roughly the slower of the two, not their sum.

GPU, BufferQueue, and the handoff#

The GPU renders into a buffer taken from a . The queue is the contract between the producer — your app — and the consumer — SurfaceFlinger. A slot moves through four states: free, dequeued, queued and acquired. If every slot is queued or held by the compositor, dequeueBuffer blocks, and the producer waits.4

Loading diagram…
Text description of this diagram

One BufferQueue slot moving through its four states: free, dequeued, queued and acquired. The producer dequeues a free slot, renders into it and queues it; the consumer acquires it for composition and releases it back to free. The transition detail for each edge explains when the producer blocks.

That is the whole reason buffer counts matter. With two buffers, the producer can render while the compositor shows the other, but there is no slack: one late frame leaves nowhere to draw. Project Butter added a third buffer so a single late frame costs latency instead of stalling the pipeline. See Project Butter for that story.

SurfaceFlinger and the Hardware Composer#

wakes on vsync, collects the layers that are ready, and composes them. Composition is not always a GPU job. The HAL tells SurfaceFlinger which layers the display hardware can scan out directly; those become overlays and cost no GPU time. The rest are drawn into a client target by the GPU and then scanned out as one layer.5

This is why adding one translucent view can cost more than it looks: it can turn a stack of cheap overlays into a GPU composition pass over the whole screen.

The clock is not always 60 Hz#

Modern phones switch the refresh rate while you use them. A static reading screen can run at 10 Hz or 24 Hz to save power; a game can run at 120 Hz; a video can run at exactly the rate of its content so no frames are duplicated.

For the frame model, that means the budget is a property of the moment, not the app. A workload that fits in 16.67 ms at 60 Hz does not fit in 8.33 ms at 120 Hz, and the system may react by lowering the rate rather than by dropping frames — which looks like a sudden change in smoothness rather than stutter.

The platform's frame pacing picks a rate based on what is drawing and how fast it can draw.6 An app that consistently misses at 120 Hz will find itself running at 60 Hz, and the change is visible: scroll momentum, animation speed in frames, and input latency all shift together.

The display, and the latency you cannot remove#

The display scans out at the next vsync. Between your finger touching the glass and the pixel changing there are at least a few frames of unavoidable pipeline: the touch must be read, the app must react, the frame must be rendered, composited and scanned out. At 60 Hz one frame is 16.67 ms, so "instant" is already tens of milliseconds.

What happens when a frame is late#

A missed deadline does not mean the work is thrown away. The frame that missed is still rendered, still queued and still shown — just one vsync later than intended. Meanwhile the next frame's work has already started, so for a moment two frames are in flight.

The display shows the previous buffer again while it waits. That repeat is what the eye sees: not a slow frame, but a duplicated one. A single duplicate is often invisible in a scrolling list; a run of them reads as stutter.

The pattern also compounds. If frame N is one vsync late and frame N+1 must wait for the same buffer, N+1 is late too, and the pipeline can settle into a rhythm of "every frame late by one", which looks like a smooth 30 fps rather than a janky 60. That is why a fixed halving of frame rate is a common symptom: the system has not slowed down, it has locked to a divisor of the refresh rate.

Where the time actually goes#

A healthy 60 Hz frame splits roughly like this on a mid-range device:

StageTypical share
Input and animation callbacksunder 1 ms
Measure and layout1–3 ms
Record drawing1–3 ms
RenderThread and GPU2–6 ms
Composition1–3 ms

Those numbers are illustrative, not guarantees. The important shape is that no stage has half the budget to itself. A frame that spends 9 ms in layout has already used more than half of a 60 Hz budget before the GPU has been asked to do anything.

Input latency is the frame budget multiplied#

If the display refreshes every 16.67 ms, a touch that arrives just after a frame started waits almost a full frame before its effect can be drawn, then another frame for the render and composition, then the display's own scanout. The result is that the best case touch-to-photon latency at 60 Hz is already several frames.

This is why high refresh rates feel better beyond smoothness: at 120 Hz every term in that sum halves. It is also why interactive gestures work the way they do — the system cannot make the pipeline faster than a frame, so a gesture has to show its progress inside a frame rather than waiting for the action to complete.

How it evolved#

Interface changes16 releases
APIVersionWhat changed
11.0
  • Pull-down notification shade
  • Home screen with widgets
31.5 Cupcake
  • Home screen widgets and folders
92.3 Gingerbread
  • New UI with dark theme and green accents
113.0 Honeycomb
  • The Holo design language arrives
123.1 Honeycomb
  • Resizable widgets
164.1 Jelly Bean
  • Project Butter: vsync-driven, smooth UI
  • Expandable notifications
204.4W KitKat (Wear)
  • Voice-driven card UI
215.0 Lollipop
  • Material Design
  • Notifications on the lock screen
247.0 Nougat
  • Quick Settings tiles
  • Direct Reply in notifications
268.0 Oreo
  • Notification channels
289 Pie
  • Dark theme groundwork
2910
  • System-wide dark theme
3011
  • Conversation notifications and bubbles
  • Media controls in Quick Settings
3112
  • Material You dynamic colour
3313 Tiramisu
  • Themed app icons
  • Runtime notification permission
3616 Baklava
  • Material 3 Expressive
  • Live Updates notifications

Two dates matter more than the rest. API 16Android 4.1 introduced vsync-driven rendering, triple buffering and Choreographer — the frame became a scheduled object rather than a free-running loop. API 21Android 5.0 introduced the RenderThread, which moved GPU work off the UI thread entirely.

2

Under the hood#

Curiosity corner#

  • The frame budget is a lie your phone keeps renegotiating. Modern panels switch between 60, 90 and 120 Hz on the fly, so "the" budget is only fixed for a given refresh rate. A game at 120 Hz has half the time an app at 60 Hz has.
  • Recording drawing is cheaper than drawing. That is why the display-list model survived from Android 3.0 into the Compose era: re-recording a changed view is usually far cheaper than re-rasterising the whole screen.
  • SurfaceFlinger composes even when nothing changed. It still has to present a frame, which is why an idle screen is not free — though it is very cheap.

Try it#

Capture a graphics trace
adb shell perfetto -o /data/misc/perfetto-traces/trace -t 10s -a com.example.app gfx view wm
Example output
Trace saved to /data/misc/perfetto-traces/trace
Frame statistics from the system
adb shell dumpsys gfxinfo com.example.app
Example output
Total frames rendered: 1841
Janky frames: 63 (3.42%)
50th percentile: 6ms
90th percentile: 11ms
95th percentile: 14ms
99th percentile: 31ms

Check yourself#

Check yourself

1 / 12

At 120 Hz, how much time does one frame have before it misses the display's deadline?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsImplement VSYNCsource.android.comaccessed 2026-10-27↩
  2. docsGraphics architecturesource.android.comaccessed 2026-10-27↩
  3. docsRenderersource.android.comaccessed 2026-10-27↩
  4. docsBufferQueue and grallocsource.android.comaccessed 2026-10-27↩
  5. docsSurfaceFlinger and WindowManagersource.android.comaccessed 2026-10-27↩
  6. docsFrame pacingsource.android.comaccessed 2026-10-27↩
  7. docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
  8. docsการแสดงผลช้า | App qualitydeveloper.android.comaccessed 2026-10-27↩
  9. docsAndroid tracing with Perfettoperfetto.devaccessed 2026-10-27↩