Concurrency in Real Systems

UI Concurrency: One Thread Owns the Screen

Every mainstream UI framework restricts view mutation to a single thread, and it is not laziness — a view tree read by a layout pass while another thread mutates it has no coherent state to draw. So background work runs elsewhere and returns results to that thread, and the whole discipline follows from a frame budget.

The question this answers

The question

Why do UI frameworks insist that only one thread may touch the view, and what does that force on everything else?

The work

A list screen that loads 4,000 records, parses them, sorts them, and renders 20 visible rows while the user is scrolling.

What is shared

The view tree and the layout state derived from it, plus the model data the background work produces and the UI consumes.

The invariant — what must stay true under every interleaving

The view tree is mutated only from the UI thread, and no single unit of work on that thread exceeds the frame budget — so every frame renders and every input event is handled promptly.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The frame budget is the whole constraint

A display refreshing at 60Hz gives you about 16.7 milliseconds per frame, and that budget must cover input handling, application logic, layout, paint and compositing. Anything the UI thread does that exceeds the remainder drops a frame. Drop several and the interface visibly stutters; drop many and input stops being acknowledged at all, which users experience as the application being broken rather than slow.

That is a hard real-time-ish budget on one thread, and it is what generates every other rule in UI concurrency. Work must be small, or it must not run here. A 300ms JSON parse is not "a bit slow" — it is eighteen dropped frames and a frozen interface. The perceptual thresholds are covered in Core Web Vitals as Signals, Not Scores and the rendering pipeline in Layout, Paint and the Main Thread; what matters here is that the budget forces a concurrency architecture.

The structural similarity to Event-Driven Servers: Many Connections, One Loop is exact: one thread, handlers that must return quickly, and a failure mode where one long handler damages everything. The difference is the victim. On a server, a stalled loop delays other connections. On a UI, it freezes the thing a human is currently looking at, which is a much lower tolerance.

Two seconds of scrolling. Same work, on the UI thread and off it.ILLUSTRATIVE
UI thread — parse on the UI thread
parse + sort 4,000 records — 300ms
User input during that stall
scroll gesture — no visual response
UI thread — parse offloaded
frames continue — scroll is smooth
Background worker
parse + sort 4,000 records — 300ms
↑ work begins↑ work applied
runningreadywaitingblockedidle1 tick ≈ 16.7ms (one frame at 60Hz)

Why one thread, and not a lock

The obvious alternative — let any thread mutate the view, and put a lock around it — has been tried and abandoned across multiple ecosystems, for reasons worth understanding rather than accepting on authority.

First, the view tree is not one object but a large graph with derived state: layout depends on the whole subtree, and a layout pass reads thousands of nodes. Making that consistent under concurrent mutation means holding a lock across the entire layout and paint, which serializes exactly the expensive part and delivers no parallelism while adding deadlock risk. Second, UI callbacks are re-entrant by nature — a layout pass can trigger a measure that triggers a callback that mutates the tree — and re-entrant mutation under a lock is where deadlocks are born. Third, the platform below is frequently single-threaded anyway: the windowing system, the input queue and the compositor typically deliver and expect work on one thread.

So the frameworks made the constraint explicit rather than pretending it away. Confining state to one thread is a real concurrency strategy, not a limitation — it is the Immutability as a Concurrency Strategy and single-ownership argument applied to a mutable graph, and it means that inside a UI callback there is no shared mutable state, no lock, and no race. That is why UI code reads so simply, and the price is paid entirely at the boundary.

1// The rule: compute anywhere, mutate the view only here.
2
3async function loadRows(query: string) {
4 // 1. UI THREAD -- cheap, immediate, keeps the frame budget
5 setState({ loading: true }) // one small mutation, one frame
6
7 // 2. OFF THREAD -- arbitrarily expensive, cannot touch the view
8 const rows = await runInWorker(() => {
9 const raw = parse(fetchedBytes) // 180ms
10 return raw.sort(byRelevance) // 120ms
11 })
12 // runInWorker serializes in and out. The worker has NO reference
13 // to any view object, by construction - that is what makes it safe.
14
15 // 3. UI THREAD AGAIN -- the resumption lands back on the UI thread.
16 // This mutation must be small: swap the data, invalidate, return.
17 setState({ loading: false, rows })
18}
19
20// The two failure modes this shape prevents:
21//
22// (a) mutating the view from the worker
23// -> most frameworks throw. The ones that do not, corrupt layout
24// in ways that surface as an unrelated crash three frames later.
25//
26// (b) doing the 300ms of work in step 1 or step 3
27// -> no thread was violated, no exception thrown, and the UI
28// froze for 18 frames anyway. This is the common one.
The shape every framework enforces, whatever the API is called.

The boundary bug that no rule catches

Frameworks enforce "do not mutate the view off-thread" — that one throws. What none of them enforce is *staleness*: background work completes and posts a result to a UI that has moved on. The user typed a new query, navigated away, or the row was deleted. The result arrives and is applied anyway, and now the screen shows the answer to a question nobody asked.

This is a race condition, entirely within the rules, with no data race and no lock missing. The two results race to be applied last, and the loser is whichever finishes second — which is not necessarily whichever was requested second. It is the same shape as check-then-act across an await, and the fixes are the same family: attach a generation token to each request and discard results whose generation is stale, or cancel the outstanding work when the context changes. See Cancellation and Cancellation Propagation.

The compare below is the concrete form. Note that the fix is not a lock — it is a decision about which result is *allowed* to be applied, which is an invariant question rather than a synchronization one.

Correct threading, wrong screen
1let rows: Row[] = []
2
3async function search(q: string) {
4 const result = await runInWorker(() => searchIndex(q))
5 setState({ rows: result }) // applied unconditionally
6}
7
8// User types "ap", then "apple".
9// search("ap") -> worker takes 400ms (broad query, many hits)
10// search("apple") -> worker takes 60ms (narrow query)
11//
12// t=60ms "apple" results applied. Screen is correct.
13// t=400ms "ap" results applied. Screen now shows results
14// for a query the user replaced.
15//
16// No thread rule was broken. No lock is missing. The screen is wrong.
A generation token decides who may apply
1let rows: Row[] = []
2let generation = 0
3
4async function search(q: string) {
5 const myGen = ++generation // claim a generation on the UI thread
6 const result = await runInWorker(() => searchIndex(q))
7
8 if (myGen !== generation) return // a newer search superseded us: drop it
9 setState({ rows: result })
10}
11
12// t=60ms "apple" (gen 2) applies; generation is 2.
13// t=400ms "ap" (gen 1) resumes, sees generation === 2, discards.
14//
15// Better still: cancel the outstanding worker call when a new search
16// starts, so the stale work does not run to completion at all.

The invariant is "the screen shows the result of the most recent request", and no threading rule expresses it. Ordering by completion time is the bug; ordering by request generation is the fix. Cancellation is the stronger version, because it also stops paying for work whose result can no longer be used.

Key points

  • A 60Hz display gives roughly 16.7ms per frame for input, logic, layout and paint. That budget is the origin of every UI concurrency rule.
  • Frameworks confine view mutation to one thread because a layout pass reads thousands of nodes and re-entrant callbacks make locking the view tree both slow and deadlock-prone.
  • Single-thread confinement is a genuine concurrency strategy: inside a UI callback there is no shared mutable state and no race. The price is paid entirely at the boundary.
  • Background work computes anywhere and returns a result to the UI thread, where only a small mutation is allowed to happen.
  • Frameworks enforce "do not touch the view off-thread". They enforce nothing about doing 300ms of work *on* it, which is the more common freeze.
  • The boundary bug no rule catches is staleness: a result arriving for a screen that has moved on. Generation tokens or cancellation, not locks, are the fix.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The platform delivers input, timer and system events onto a single thread's queue; the framework runs handlers from that queue one at a time.
  • After handlers run, the framework performs layout and paint over the view tree, reading state that no other thread is permitted to be mutating.
  • Background work is dispatched to another thread or a worker, receiving copied or serialized data rather than references into the view.
  • Results are posted back onto the UI thread's queue and applied by a handler like any other event.
  • Any attempt to mutate the view from another thread is detected and rejected by the framework, because the alternative is silent layout corruption.
Interleavings that matter
  • A 300ms parse runs on the UI thread: input events accumulate unhandled for eighteen frames, then all apply at once, producing a visible jump the user reads as a bug rather than as latency.
  • A worker mutates a view node while the UI thread is mid-layout: the layout pass reads a subtree that changes underneath it and computes sizes for a tree that never existed. The crash, if any, appears later and elsewhere.
  • Search "ap" (400ms) then "apple" (60ms): "apple" applies at 60ms, "ap" applies at 400ms, and the screen shows results for the superseded query. Correct threading throughout.
  • The user navigates away while a load is in flight. The result posts to a screen that has been torn down, and applying it either throws or resurrects a destroyed view — the orphaned-task problem with a visible symptom. See Orphaned Tasks.
What it guarantees — and does not
  • Thread confinement guarantees that view state is never concurrently accessed, which removes data races on the view entirely.
  • It guarantees nothing about responsiveness. Long work on the confined thread is fully permitted and is the most common freeze.
  • Posting a result back guarantees it will be applied on the UI thread; it does not guarantee the UI still wants it, or that the target still exists.
  • Frameworks generally guarantee detection of off-thread mutation. They do not guarantee detection of off-thread *reads*, which can also observe a torn view state.
  • Nothing guarantees the order in which two background results arrive matches the order they were requested.
Where contention appears
  • The UI thread is the contended resource: every handler, animation callback, layout and paint competes for the same 16.7ms.
  • The handoff queue back to the UI thread is a shared point — a flood of background completions can itself consume the frame budget.
  • Serializing large payloads to and from a worker costs time on the UI thread at both ends, which can exceed the work saved for small tasks.
  • Shared caches touched by both the UI thread and workers reintroduce ordinary synchronization requirements, and are easy to add without noticing.
How it fails
  • Frozen interface from long work on the UI thread — the dominant real-world failure, and entirely within the threading rules.
  • Off-thread view mutation, producing either an exception or silent layout corruption that surfaces far from its cause.
  • Stale result applied after the context changed, showing the answer to a superseded question.
  • Applying a result to a destroyed screen, which either throws or leaks the destroyed view.
  • Handoff flooding: hundreds of small background completions posting individually and consuming the frame budget in dispatch overhead alone.
  • Silent cancellation gaps, where cancelling the UI-side wait leaves the background work running and still costing battery and network.
When it helps
  • Any work over a few milliseconds — parsing, sorting, image decode, compression, cryptography — where offloading directly converts a freeze into a smooth interaction.
  • Long-lived streams and subscriptions, where a background thread accumulates and the UI thread applies periodic small updates.
  • Batching: coalescing many background results into one UI-thread application, which protects the frame budget from dispatch overhead.
When it hurts
  • Very small tasks, where serialization to and from a worker costs more than doing the work inline.
  • Work that inherently needs view state, which cannot be moved off-thread and must instead be made incremental — split across frames rather than relocated.
  • When offloading is used to hide an algorithmic problem: a 300ms sort moved to a worker is still 300ms of battery and still delays the result.
How you would know
  • Dropped frames and long-task counts on the UI thread — the direct measurement of budget violations.
  • The duration distribution of UI-thread handlers, with a hard ceiling rather than an average as the target.
  • Input-to-visual-response latency, which is what users actually perceive — Core Web Vitals as Signals, Not Scores and The Half of the Budget You Cannot See From the Server.
  • Count of background results discarded as stale, which reveals whether cancellation is working or work is being wasted.
  • Handoff volume: results posted to the UI thread per second, since dispatch itself consumes budget.
Complexity it introduces
  • Every data path needs an explicit boundary: what is copied out, what is posted back, and who owns it in between.
  • Staleness and cancellation must be handled per request, and neither is enforced by the framework.
  • Lifecycle interacts with concurrency: a screen can be destroyed while its work is in flight, and every async path needs an answer for that.
  • Debugging spans two contexts, and a stack trace on the UI thread rarely shows what the background work was doing when it produced a bad result.
Simpler alternatives
  • Make the work smaller or incremental — chunk it across frames on the UI thread, which needs no boundary and no staleness handling.
  • Move the work to the server, so the client applies a result rather than computing one. Frequently the right answer for sorting and filtering large sets.
  • Virtualize the view so only visible rows are laid out, which usually removes the need for the offload entirely.
  • Use the platform's worker abstraction with structured transfer rather than sharing, which makes the boundary explicit and safe — Web Workers and Worker Threads.

What people believe, and what is true

Claim

The UI thread rule exists because the framework authors did not want to add locks.

Reality

Locking a view tree means holding a lock across layout and paint, which serializes the expensive part, delivers no parallelism, and deadlocks on re-entrant callbacks. Confinement is the better design.

Claim

If I do not touch the view from a background thread, the UI stays responsive.

Reality

The common freeze is long work *on* the UI thread, which breaks no threading rule. The framework will not warn you.

Claim

Moving work to a worker makes the app faster.

Reality

It makes the app responsive. The work still takes the same time and still costs battery — and for small tasks the serialization makes it slower overall.

Apply it