Frame Pacing: Why a Fast-Changing Number on Screen Is Never the Current One
The path from a computed value to a lit pixel takes two or three frames. Where the delay comes from, how to measure it, and what a patched framebuffer changes.
A value that updates every frame is already history by the time you read it. On a 60 Hz panel the number in front of you was computed somewhere between 17 and 50 milliseconds ago, and nothing about that is a defect — it is the normal cost of moving a value through a display pipeline.
On a legacy Hackintosh the cost is usually higher and, more importantly, it is uneven. A patched framebuffer, a connector declared with the wrong type, or a refresh rate the system reports incorrectly all show up the same way: the number moves in steps instead of flowing. This is worth understanding as a diagnostic, because the symptom points at a specific stage of the pipeline.
The path from a value to a lit pixel
Five stages sit between the two, and each one costs time.
Computation. The application updates its state. On a browser page this happens in a script callback, typically driven by requestAnimationFrame.
Rendering. The new state is drawn into a back buffer. Nothing is visible yet.
Composition. The window server takes the back buffer and combines it with everything else on screen. On macOS this is WindowServer; it is a separate process and it works on its own schedule.
Scanout. The GPU pushes the finished frame out over the display link, line by line.
Panel response. The pixels change state. On an IPS panel this takes several milliseconds on its own.
At 60 Hz one frame is 16.7 ms. A composited desktop normally holds two to three frames in flight, so the practical distance between a computed value and a visible one is 33 to 50 ms. At 120 Hz the frame is 8.3 ms and the same pipeline depth costs 17 to 25 ms.
Where vsync sits in this
The rendering side and the scanout side run at their own rates, and vsync is the rule that keeps them from colliding.
With vsync off, a finished frame is handed over immediately. Latency is lowest, and the display can start showing a new frame in the middle of drawing the old one — the visible seam is tearing.
With double buffering and vsync on, the finished frame waits for the next refresh boundary. Tearing is gone; the wait adds up to one full frame.
With triple buffering, rendering continues into a third buffer instead of blocking. Throughput improves and stalls are hidden, at the cost of one more frame of latency.
Each of the three is correct for a different goal. The pipeline is either short or smooth, and the setting decides which.
The mismatch that produces judder
The visible stepping usually comes from a rate mismatch rather than from raw latency.
Suppose the application updates its state 50 times per second and the panel refreshes 60 times per second. The ratio is not an integer, so some frames repeat the previous value and some skip one. The pattern repeats at the difference between the rates — 10 Hz — and that is exactly the frequency at which a fast-moving number appears to hesitate.
The same effect appears in a subtler form at 59.94 Hz, the rate a lot of displays actually run when the system reports 60. The two drift apart by one frame roughly every 17 seconds. On static content nobody notices. On a value that changes every frame the drift is visible as a periodic hitch.
The diagnostic is straightforward: if the stutter has a steady period, it is a rate mismatch. If it is irregular, look at scheduling or at GPU contention instead.
What a Hackintosh adds to this
Several things in a patched setup affect the pipeline directly, and all of them are checkable.
Connector type declared incorrectly. A DisplayPort output described as HDMI in the framebuffer patch can end up on a different timing path. The picture appears, the timing is not what the OS assumes.
Reported refresh rate against the real one. The system may show 60 Hz while the panel runs 59.94, or negotiate a mode the display accepts but does not run natively. SwitchResX or the display's own service menu will show the actual value.
Missing or partial framebuffer patches. With WhateverGreen, an incorrect connector count or wrong framebuffer-patch-enable values leave outputs working but composited through a fallback path, which is slower and less consistent.
Adaptive sync unavailable. VRR requires driver support that most legacy configurations do not have. The refresh rate stays fixed, so the mismatch above cannot be resolved by the display.
Mirroring. Two panels at different native rates force the composition path to a common denominator, and the pacing on both gets worse than either alone.
Measuring the gap yourself
The measurement needs no special hardware, only a phone that records at 240 fps.
Put a page with a per-frame counter on screen. Record the screen with the phone. Step through the recording and compare the value visible in each captured frame with the log of state updates from the page. At 240 fps each captured frame is 4.2 ms, which is enough resolution to count pipeline frames directly.
For the software half, log performance.now() at each state update and again inside requestAnimationFrame. The difference between those two timestamps is the render-side latency; everything beyond it is composition, scanout and panel.
The test needs a page where a number changes on every frame and which opens without an account. A browser crash game works well for this: the Spanish-language guide chicken road describes a round where a multiplier climbs continuously, and the free mode can be restarted as many times as the capture requires.
Run the same capture on a stock machine and on the patched build. The difference between the two is the part your configuration contributes.
What actually reduces the delay
In rough order of effect on a legacy build:
- Match the state update rate to the refresh rate. Driving updates from
requestAnimationFramerather than from a timer removes the mismatch entirely, because the callback is already aligned to the refresh boundary. - Set the panel to its native mode. A negotiated mode that the display internally rescales adds a frame and often more.
- Turn off unnecessary compositing effects. Transparency and shadows push more work through WindowServer, and on older GPUs that shows up in pacing.
- Fix the framebuffer patch before tuning anything else. An output on a fallback path will not respond predictably to any of the above.
- Avoid mirroring across panels with different native rates.
What this does not change
One point is worth stating plainly, because it is easy to over-read the measurement.
The delay is in the display path, not in the logic. In any client-server application the authoritative value lives on the server, and the client renders a copy of it. The result of an action is recorded against server state, not against what the panel was showing at that instant. Frame pacing determines how faithfully the screen tracks the state; it does not determine the state.
That distinction is what makes the measurement useful. It isolates the display stack as its own subsystem, which is exactly what you want when the question is whether a build is configured correctly.
Summary
- The gap between a computed value and a visible one is two to three frames: 33–50 ms at 60 Hz, 17–25 ms at 120 Hz.
- Vsync mode decides the trade: off is shortest and tears, double buffering costs a frame, triple buffering costs another and smooths.
- Steady periodic judder means a rate mismatch. Its period equals the difference between the update rate and the refresh rate.
- On a patched build, check connector types, the real refresh rate against the reported one, and the framebuffer patch before anything else.
- Measure with a 240 fps capture plus
performance.now()logging, and compare the patched build against a stock one.
