Mobile Computing · Mobile Performance
Buffer Queue Depth and the Latency It Adds Between Touch and Photon
How many buffers a surface holds decides whether the pipeline can overlap frames or must serialize, and why every extra buffer buys throughput with input latency.
A frame is not drawn into the display’s memory. It is drawn into a buffer, handed to a compositor, and the compositor’s output is scanned out. The number of buffers in that handoff is a tuning parameter with an exact effect: each buffer in flight adds one refresh period of latency between the input that caused a frame and the photons that show it, and removes one constraint on how much work can overlap.
The queue as a state machine
A surface owns a queue with a fixed number of slots. At any moment each slot is in one of a few states: free, being filled by the producer, queued for the consumer, or being displayed by the consumer. The producer is the app’s rendering path; the consumer is the compositor.
The producer’s first step for a frame is to acquire a free slot. If none is free, it blocks. It then renders into the acquired slot, and on completion releases it to the queue. The consumer, at each composition opportunity, takes the most recently queued slot, composites it, and holds it until the display has finished scanning it, then returns it to the free pool.
Two numbers describe the queue: the total slot count, and the maximum number of slots the consumer may hold queued at once. The second matters because it decides whether the producer can run ahead. With a maximum of one, the producer cannot start frame N+1 until the consumer has taken frame N, so the producer and consumer alternate and no overlap occurs. With a maximum of two, the producer can fill the next frame while the consumer holds the previous one, which is what makes the pipeline overlap.
What each extra buffer buys and costs
The overlap is the buy. A pipeline with a single queued buffer can sustain a frame rate only if the producer’s work plus the consumer’s work fits in one period, because the two are serialized. Two queued buffers let them overlap across frames, so the constraint becomes the larger of the two stages rather than their sum. That is the difference between a frame that takes 10 ms on the CPU and 10 ms on the GPU being impossible at 60 Hz and being comfortable.
The cost is latency. An input arrives at time t. The app processes it, and the frame that reflects it is queued. If one buffer is already queued ahead of it, that frame is composited at the next opportunity and the input’s frame at the one after. The input’s visible effect is therefore one period later than it would be with no queue. Each additional queued buffer adds another period.
There is a second, subtler cost: the queue can hide the app’s own slowness from its measurements. If the app consistently takes 20 ms per frame with two buffers available, it will not drop frames in the sense of a gap in presentation; instead it will present every second vsync while the queue stays full, and the frame intervals will be a steady 33 ms rather than a mix of 16 and 33. The average looks better than the distribution implies, and the latency is worse than either.
Depth and the vsync clock interact
The compositor takes a snapshot at a fixed point in each vsync interval, not at the moment a buffer is released. A buffer released just after the snapshot waits a full period even though the producer finished early. The effective deadline for the producer is therefore the snapshot time minus the pipeline’s depth, and the wait for the snapshot is a quantization that no amount of producer speed removes.
This quantization is why frame timing has a bimodal character. A producer that finishes at 15.9 ms and one that finishes at 16.1 ms produce frame intervals that differ by a full period, not by 0.2 ms. The discontinuity is inherent to a clocked consumer and is the reason averages over frames are a poor summary: the interesting quantity is the fraction of frames on each side of the boundary.
The snapshot also explains an apparent paradox: making a frame slightly faster can increase jank if the change moves work from one stage to another in a way that crosses the snapshot boundary. A GPU stage that finishes at 16.5 ms but starts at 8 ms is fine; one that finishes at 16.5 ms but starts at 15 ms misses. Depth determines how much the start can move.
Modes that change the arithmetic
Different presentation modes decouple the queue from the refresh clock.
A mode where the producer’s release triggers immediate composition rather than waiting for the next snapshot reduces latency at the cost of tearing if the release lands mid-scan. It is used where tearing is acceptable, such as some full-screen video paths, and it is not the default for interactive UI.
A mode where the consumer waits for a specific frame rather than the latest one adds determinism and can add latency, because the consumer will hold an older buffer to present at the requested time instead of presenting the newest one immediately. This is used for video with a fixed cadence.
A mode where frames are discarded if a newer one is available removes the queue’s latency contribution entirely, at the cost of dropping work that was already done. For a UI that changes rapidly, dropping a stale frame is usually better than showing it, because the stale frame’s content is already wrong.
The choice is per-surface, not per-app, and the app usually does not choose it directly. What the app can choose is how many buffers it asks for when it creates its rendering surface, and how many frames it allows to be in flight in its own renderer. Both are depth decisions in the same arithmetic.
Estimating end-to-end latency
End-to-end latency from touch to photon decomposes into stages that are mostly fixed plus a term proportional to depth.
The input path contributes the time from the digitizer interrupt to the app receiving the event, which includes the kernel’s input dispatch, the system process’s routing, and the app’s queue latency. The app’s processing contributes the frame’s own work. The queue contributes one period per buffer ahead of the frame being displayed. Composition and scanout contribute the compositor’s work plus the panel’s own response time.
The queue term is the one under the app’s indirect control, and it is the one that scales with the frame rate. At 60 Hz each buffer is 16.7 ms; at 120 Hz each is 8.3 ms. Doubling the refresh rate therefore halves both the frame budget and the latency contribution of each buffer, which is why high-refresh displays feel more responsive even when the content is the same.
A useful sanity check is that a three-buffer pipeline at 60 Hz has a floor of about two periods of queue latency, roughly 33 ms, before any app work. Adding a typical input path and composition puts the floor near 50 ms. Any measurement below that floor is measuring something other than touch-to-photon.
When the queue itself misbehaves
A stalled consumer. If the compositor stops consuming, for example because the app’s surface became invisible or the system is in a state where composition is suspended, the queue fills and the producer blocks on acquire. The app’s rendering thread appears hung with no work in progress, and the fix is not in the app. The symptom is a render thread blocked in acquire with the queue at its maximum.
Buffer dimensions changing. Resizing a surface reallocates its buffers, and the old buffers cannot be reused until the consumer has released them. A resize during an animation therefore allocates new buffers while the old ones are still held, doubling the memory for the duration. A frequent resize, such as a window being dragged, can allocate faster than the consumer releases.
Producer running ahead of the clock. A producer that renders as fast as it can, without waiting for vsync, fills the queue and then blocks, which wastes CPU and, on a thermally constrained device, wastes power. Pacing the producer to the display’s cadence is the correct default; the exceptions are cases where the producer must run ahead to hide a variable cost.
Depth mismatch between the app and the platform. An app that keeps its own three frames in flight on top of a platform queue of three has six frames of latency, not three. The app’s in-flight count and the platform’s queue depth add, and neither is visible from the other. The total is what the user feels.
The frame that is never released. A buffer that is acquired and never released, because an exception skipped the release, leaks a slot. The queue shrinks by one permanently, and once enough slots leak the producer blocks forever. The pattern to enforce is acquire and release in a construct that cannot be skipped, or a release in a finalizer-equivalent that is guaranteed to run.
Depth that hides a stage overrun. Extra depth keeps presentation regular while a single stage exceeds its share, which means the jank appears as latency rather than as a dropped frame. The frame timeline read described in Attributing Jank to a Specific Pipeline Stage is what distinguishes the two.
The relationship between depth and the per-stage budget is the other half of the story told in Frame Budget Arithmetic and the Real Deadline for Main Thread Work: depth converts a serialized pipeline into an overlapped one, and the cost of that conversion is paid in the latency the user perceives.
References
- Graphics architecture: SurfaceFlinger and BufferQueue — Android Open Source Project
- Vulkan Specification: Swapchain creation and presentation modes — Khronos Group
- Apple Developer Documentation: CAMetalLayer — Apple Inc.