In Flutter, a callback registered with addPostFrameCallback from a stream listener sometimes fires seconds late; why, and how do you make it reliable?
answer
- registering is not requesting
- idle scheduler produces no frames
- waits for someone else's frame
- ensureVisualUpdate or endOfFrame
- registration phase picks the frame
basics
~10 saddPostFrameCallback does not request a frame. On an idle screen, the callback waits until something else, such as a touch or an animation, schedules one. Request a frame too, with SchedulerBinding.instance.ensureVisualUpdate(), or await SchedulerBinding.instance.endOfFrame.
solid answer
~40 sThe source says it outright: `addPostFrameCallback` **does not request a new frame**. It appends the callback to a list that `handleDrawFrame` drains after the next frame's rendering pipeline. When the stream event arrives while nothing on screen is dirty, the scheduler is `idle` and no frame is coming, so the callback waits for an unrelated trigger: a tap, a cursor blink, an animation, a new route. To make it reliable, also request a frame: `SchedulerBinding.instance.ensureVisualUpdate()` schedules one when idle, or `await SchedulerBinding.instance.endOfFrame`, which schedules a frame when idle and completes after it. Also remember the ordering rules: a callback registered before the post-frame phase begins runs at the end of the current frame, one registered from inside a post-frame callback runs after the next frame, and callbacks run once in registration order.
code
dart · 12 linesimport 'package:flutter/scheduler.dart';
void runAfterNextFrame(VoidCallback action) {
SchedulerBinding.instance.addPostFrameCallback((_) => action());
// Registering does not request a frame; ask for one if none is pending.
SchedulerBinding.instance.ensureVisualUpdate();
}
Future<void> showBadgeAfterLayout() async {
await SchedulerBinding.instance.endOfFrame;
// Layout of the frame that just ended is now available.
}go deeper
Remember that addPostFrameCallback only waits for the next frame, and that a still screen may not produce one for a long time.
Explain which scheduler phase a registration happens in, when each lands, and why callbacks need a mounted check.
Diagnose late or missing callbacks as a missing frame request, fix them with ensureVisualUpdate or endOfFrame, and watch for self-re-registering loops that burn frames.
Set a shared helper for post-layout work so teams stop hand-rolling callbacks, and review idle frame counts as part of battery and performance budgets.
## The bug report A screen listens to a `Stream` of server updates. On each event it registers `SchedulerBinding.instance.addPostFrameCallback` to measure a widget, show an overlay or move focus. In testing it mostly works; in the field the overlay sometimes appears only when the user touches the screen, seconds later. ## Registering is not requesting `SchedulerBinding.addPostFrameCallback` adds the function to an internal list and returns. Its documentation is explicit: it **does not request a new frame**. If a frame is in progress and post-frame callbacks have not started yet, the callback runs at the end of that frame; otherwise it runs after the next frame, "whenever that may be, if ever". Flutter only produces a frame when something asks for one (`setState`, `markNeedsLayout`, `markNeedsPaint`, a ticker). When the stream event arrives and the handler changes nothing that is on screen, the scheduler stays in `SchedulerPhase.idle`. No vsync callback is requested, so the list of post-frame callbacks is not drained until an unrelated event schedules a frame. In development there is usually something animating, which hides the bug. The same thing happens in any code that registers from outside the frame: timers, platform-channel handlers, isolate messages, lifecycle notifications. When the device screen is off, frames are not scheduled at all, which can stretch the wait further. ## Three reliable fixes 1. **Request the frame explicitly.** After registering, call `SchedulerBinding.instance.ensureVisualUpdate()`. It schedules a frame when the scheduler is `idle` or in `postFrameCallbacks`, and does nothing when a frame is already running, because that frame will drain the list. `scheduleFrame()` also works; it is a no-op when a frame is already scheduled. 2. **Await the frame.** `await SchedulerBinding.instance.endOfFrame;` returns a future that completes after the current frame, scheduling one first if the scheduler is idle. It is the async form of "run this after the next frame". 3. **Make the change that needs the frame.** If the handler calls `setState` for data it just received, that already requests a frame, and a post-frame callback registered alongside it runs after that frame. ## Ordering rules that cause the other half of these bugs | Registered from | Runs | |---|---| | `idle` (timer, stream, input) | after the next frame, whenever one is scheduled | | `transientCallbacks` or build/layout/paint | at the end of the current frame | | inside another post-frame callback | after the **next** frame; the current list was copied and cleared before running | - Callbacks run in **registration order**, and each runs **exactly once**. - They **cannot be unregistered**. If the `State` that registered one is disposed first, the callback still runs, so it must check `mounted`. - A callback that calls `setState` schedules another frame, because `ensureVisualUpdate` treats the post-frame phase like idle. A callback that re-registers itself and calls `setState` every time therefore keeps the app producing frames forever, draining battery with no animation visible. ## Choosing between them - Use `addPostFrameCallback` together with `ensureVisualUpdate()` for fire-and-forget work. - Use `endOfFrame` when the following code is already async and wants to continue after layout. - Prefer a plain `setState` when the real need is to show new data; the post-frame step is only for work that needs sizes, positions or a finished frame. ## A worked timeline 1. The screen is still; the scheduler is `idle` and no frame is pending. 2. A stream event arrives; the handler registers a post-frame callback and returns without changing any widget. 3. Nothing is dirty, so no frame is requested and the callback sits in the list. 4. Four seconds later the user taps; the ink ripple starts a ticker, which schedules a frame. 5. That frame's post-frame phase finally runs the callback, and the overlay appears "late". With `ensureVisualUpdate()` after step 2, a frame is scheduled for the next vsync and step 5 happens roughly one frame later instead. ## How to spot it - Symptoms that change with unrelated activity (touching the screen, a spinner running) point at a missing frame request. - In debug builds, `debugPrintBeginFrameBanner` and `debugPrintEndFrameBanner` from `package:flutter/scheduler.dart` print frame boundaries, which shows whether a frame ran after the event.
- A post-frame callback registers another post-frame callback. When does the second one run?After the next frame, not in the current post-frame phase. `handleDrawFrame` copies the callback list and clears it before running the copy, so anything added during that loop lands in the fresh list, which the next frame drains. If nothing schedules that frame, the second callback waits too.
- Why can a post-frame callback run after the State that registered it was disposed?Post-frame callbacks cannot be unregistered, and `State.dispose` runs in `finalizeTree`, which comes before the post-frame phase in the same frame. So a screen closed by this frame's build is already disposed when its callback runs. Checking `mounted` first avoids using a dead context or controller.
- An app's frame counter keeps climbing while the screen looks static. What post-frame pattern can cause that?A post-frame callback that calls `setState` and re-registers itself. In the post-frame phase, `ensureVisualUpdate` schedules a new frame, whose post-frame phase repeats the cycle, so the app renders every vsync with no visible change. Register only when there is a real reason, and stop once the work is done.
saying these in an interview costs you the question
- addPostFrameCallback automatically schedules a frame so the callback runs soon.
- A post-frame callback registered inside another runs later in the same frame.
- Post-frame callbacks are removed automatically when the registering State is disposed.
- Calling setState inside a post-frame callback is ignored until the next user event.
- Post-frame callbacks run in reverse registration order.