Mobile Computing · Mobile Performance
The Main Thread Message Queue and Why a Single Long Task Delays Frames
How a looper serializes work onto one thread, how synchronization barriers let frame callbacks overtake queued messages, and what makes a task on the queue expensive beyond its own duration.
Mobile user interfaces are single-threaded by contract. Every view mutation, every input callback, and every frame’s measure and layout runs on one thread, and that thread runs work from a queue in order. The design removes an entire class of concurrency bugs and concentrates all latency into one place. Understanding jank starts with understanding what that queue does with a task that takes too long.
The queue is ordered, not scheduled
A looper is a loop around a priority queue of messages ordered by delivery time. post inserts a message with a due time; the loop takes the earliest message whose time has arrived, runs its callback, and returns for the next. Messages with the same due time run in insertion order, which makes the queue stable and makes ordering observable by the app.
Two properties follow. First, the loop never runs two callbacks concurrently, so a callback’s duration adds directly to the latency of everything behind it. Second, the loop blocks when the queue is empty until either a message becomes due or a file descriptor becomes ready, which is why the thread can wait on a socket without a separate thread. That blocking wait is the mechanism behind Handler-based I/O and behind the platform’s ability to deliver input without polling.
The ordering has a consequence that is easy to overlook: a long task does not merely delay the frame that was due next, it delays every message queued behind it, including messages posted by other subsystems with earlier deadlines. One slow database write on the main thread can push a frame callback past its vsync, and the frame callback has no way to express that it was more important.
Barriers let frame work jump the queue
If the queue were strictly first-in-first-out by due time, a frame callback would have to wait behind anything already queued, and a background task posted a millisecond earlier could cost a frame. Platforms avoid this with a synchronization barrier: a special message inserted into the queue that causes all later synchronous messages to be held until the barrier is removed, while asynchronous messages continue to be delivered.
The mechanism is a counter, not a reordering. When a barrier is posted, the queue records its position; subsequent synchronous messages are enqueued as usual but the loop will not dispatch them while the barrier is active, even if their due time has passed. Messages posted with the asynchronous flag bypass the barrier and are dispatched normally. When the barrier is removed, held messages become eligible again in their original order.
The platform uses this for the vsync signal. The traversal callback that measures, lays out, and draws is posted as an asynchronous message, and the barrier that precedes it is posted at the start of the frame. The effect is that the frame’s work runs at the head of the queue, ahead of whatever synchronous work had accumulated, and then the accumulated work resumes. Input events are dispatched through the same mechanism, which is why input can preempt a long queue of app messages but a long app message can still block input that arrives during it.
This design has a cost that shows up in profiles. Because held messages resume after the frame, a burst of synchronous posts can be pushed back by every frame, and a task that posts a message that posts another message can be delayed repeatedly without ever being starved outright. The work is not lost, it is deferred, and the deferral can be arbitrarily long if frames keep coming.
What makes a task expensive beyond its own duration
A task’s contribution to latency is its duration plus the cost of the transitions around it. The transitions are not free.
The first is dispatch: taking a message, invoking a callback through whatever indirection the framework uses, and catching exceptions. For a task that does almost nothing, dispatch dominates. A pattern that posts thousands of tiny messages, one per item, pays dispatch thousands of times, and the total can exceed the cost of doing the work in one message by a wide margin. Batching is the standard remedy and the reason bulk operations exist in list adapters and diffing utilities.
The second is the queue’s own bookkeeping. Inserting a message into a time-ordered queue costs a comparison and a pointer splice, which is cheap per operation but not free when a single callback enqueues a large batch. Some implementations coalesce messages with identical callbacks and arguments to collapse redundant posts; that coalescing changes semantics, because two posts that were meant to run twice now run once.
The third is the wakeup. If the loop is blocked waiting for a message and a new message arrives with an earlier due time, the loop must be woken and its wait recomputed. On a device trying to enter a low-power state, each wakeup has a cost beyond the CPU time, and a stream of messages arriving just in time to prevent idle is a battery problem as much as a latency problem.
Measuring occupancy rather than duration
The useful metric for a queue is not the duration of any one task but the fraction of wall time during which the thread is running a callback. A thread that is busy 40% of the time has 60% slack and can absorb a burst; a thread busy 95% of the time will drop frames under any additional load, because the next frame’s work cannot start until the current task finishes.
Occupancy also explains why the same code janks on one device and not another. A slower core makes each task longer, so the same queue of work occupies a larger fraction of the period. A device with fewer big cores suffers more from background contention. Neither changes the app’s code, and neither is visible in a profile that reports only the app’s own methods.
Instrumentation for occupancy needs two timestamps per message: when dispatch began and when it returned. The difference is the task’s cost; the gap between consecutive dispatches is idle time. Summing the first and dividing by wall time gives occupancy directly. Sampling profilers approximate this by counting how often the main thread’s stack is inside a callback, which is unbiased but has variance proportional to the sampling interval.
Stalls the queue cannot absorb
The classic failure is a synchronous wait on the main thread. Any call that blocks until another thread completes turns the queue into a serialization point, and no barrier can help because the thread is not in the loop at all. Blocking on a future, on a lock held by a worker, or on a condition variable all produce the same symptom: the main thread is absent from every sample, the frame is late, and the profiler shows no hot method because the thread is parked.
The second is a task that re-posts itself immediately. A callback that, at its end, posts itself again with no delay creates a loop that consumes every idle slot in the queue and never lets the thread sleep. The frame work still runs, because the barrier puts it first, but the thread never idles and the device never reaches a low-power state. Deferring with a delay, or driving the loop from the frame callback instead, breaks the cycle.
The third is lock contention with a background thread that holds a lock the main thread needs. The main thread appears to be doing nothing while a worker holds the lock, and the duration is set by the worker’s critical section. Priority inversion makes this worse: if the worker is on a lower-priority thread and the main thread blocks on its lock, the platform’s priority inheritance must raise the worker, and if that mechanism is not in play the main thread waits for a thread that the scheduler has decided is less important.
The fourth is a barrier that is never removed. If the code that posted a barrier fails before removing it, synchronous messages stop being delivered while asynchronous ones continue, and the app appears to work for frame callbacks and animation while every posted task silently stalls. The symptom is distinctive and the cause is a missing cleanup path in an exception handler.
Interaction with the rest of the pipeline
The queue determines when the frame’s measure and layout begin, but not when the frame reaches the display. A frame that starts on time and then spends its budget in layout is a different problem from a frame that starts late because the queue was occupied. Separating them requires timestamps for both: the vsync time and the moment the traversal callback entered. The gap between them is queue latency, and the remainder is the app’s own work. Frame Budget Arithmetic and the Real Deadline for Main Thread Work covers how the remainder is split between threads, and Attributing Jank to a Specific Pipeline Stage covers the attribution method in full.
The queue also interacts with memory: a message holds a reference to its target and to its arguments, so a queue full of deferred work keeps objects alive that would otherwise be collectible. A backlog therefore raises the heap’s live set at exactly the moment the app is allocating for a frame, which is the subject of Allocation Rate, Heap Growth, and Garbage Collection Pauses.
References
- Looper reference — Android Developers
- Choreographer reference — Android Developers
- CFRunLoop reference — Apple Inc.