Blog
Hunting a UI Freeze: When the Bug Isn't in Your Business Logic
The screen was still rendering but completely unresponsive to touch, and the WebSocket timed out shortly after. The real cause wasn't in our business logic at all — it was a reliably reproducible layout livelock in SwiftUI. Here's the full investigation.
Follow product and engineering updates from this channel.
Browse categoryThis is a debugging post-mortem, not a marketing piece. If we got something wrong along the way, we'll say so — because in this investigation, we were wrong more than once.
What Happened
A user on iPad was in chat mode, asking an agent to produce a long response. While the response was still streaming in character by character, the entire app locked up: the screen was still rendering, but nothing was scrollable, nothing was tappable, and no button responded. A moment later, the WebSocket connection between the device and the server dropped with a "request timed out" error (-1001).
From the user's perspective it looked like a network failure. But we had a crucial piece of evidence on the side: a read-only protocol observer was listening to the same server data stream as a second client. Its logs were clear — the server was completely healthy the whole time, every streaming event was delivered, and that response completed successfully.
The freeze was on the client side, and it happened right at the moment the response was finishing its final render. The WebSocket disconnect was a consequence, not a cause: the loop that reads incoming packets runs on the main thread, so once the main thread locked up, packet reads stopped. Data from the server piled up in the TCP buffer, and after several dozen seconds the OS declared the connection timed out.
Three Wrong Theories
Right when the main thread froze, we captured a sample. Thousands of stack frames, all stopped inside SwiftUI layout updates — no business logic executing, no state being written. _printChanges was completely silent the entire time.
That itself was an important clue. If this had been a data-driven re-render loop, _printChanges would have been printing nonstop. Its silence meant the loop was happening at the layout layer, not the data layer.
But where in the layout layer? We suspected three places in turn, and we validated each one with a controlled simulator repro: same long response, same interaction, only one variable changed each time.
First suspect: UITextView self-measurement. Our message bubbles use UITextView to host selectable rich text, and its intrinsicContentSize triggers a full TextKit layout pass every time — that sounded like a plausible culprit. We swapped it out for a plain SwiftUI Text view. Still froze. Ruled out.
Second suspect: TextKit 2. Since iOS 16, UITextView defaults to TextKit 2, and its incremental layout can give unstable measurements for a fixed (text, width) pair on a long non-scrolling body. We forced it back to TextKit 1. Still froze. Ruled out.
Third suspect: rendering cost itself. Parsing Markdown segments and rebuilding attributed strings for long bubbles is genuinely expensive. We added caching — the peak load dropped, but the freeze remained. This was an amplifier, not the root cause.
All three theories hit dead ends. Two independent rounds of static analysis could each surface "suspicious-looking" amplifiers, but none of them could explain a CPU peg at 100% that persisted indefinitely and never recovered after the response finished.
Bisecting to the Root Cause
Since static analysis wasn't finding the source, we switched to the dumbest but most reliable method: turn off potentially related things one at a time and see which one brings CPU back to zero.
First we disabled auto-scroll — the proxy.scrollTo call that scrolls the list to the bottom when a new message arrives. The freeze vanished immediately. CPU dropped to zero.
Then we changed just one variable: swapped the LazyVStack that holds the message list for an eagerly-laying-out VStack, keeping everything else the same including auto-scroll. The freeze disappeared again.
Together, those two experiments pointed to one conclusion:
When chat content exceeds one screen, a
ScrollViewwith aLazyVStackenters a pure-layout livelock on anyscrollTocall — regardless of whether animation is enabled, and regardless of whether the anchor target is a real message or a placeholder view.
Here is why. LazyVStack only measures items in the visible region precisely; off-screen content gets estimated sizes. When you call scrollTo to a position, the change in scroll offset triggers on-demand loading of off-screen content, which causes the total content height to be re-estimated, which shifts the anchor target's position, which triggers another scroll. This "re-anchor ↔ re-estimate height" cycle has no data change driving it — it spins entirely within the layout layer — so it never stops on its own. That is exactly why the main thread pegged at 100% while _printChanges stayed completely silent. This is the layout livelock.
The Fix
Once the direction was clear, the fix converged quickly:
- Replace the message list container from
LazyVStackto an eagerly-laying-outVStack. A chat conversation tops out at a few dozen bubbles; the cost of eager layout is negligible, and with a fixed total height there is no room for the livelock to spin. - Change auto-scroll to non-animated, targeting a 1-pixel-tall sentinel view at the end of the list, using
anchor: nil(the "scroll until visible" semantic). We no longer anchor-scroll to a message that may be taller than the screen — that anchored positioning was the perpetual motion machine we found in the sample. - We also kept the two real optimizations discovered along the way: Markdown segment parsing and rendering now use a predictable LRU cache instead of
NSCache, which the system can wipe entirely under memory pressure. Every re-render becomes a table lookup.
We validated with the same 2,500-word Markdown response used to reproduce the bug. Before the fix, the main thread sat permanently at 100% once that response finished and the UI was completely unresponsive. After the fix, CPU drops back to 0% the moment the response ends, and scrolling, history browsing, and code block rendering all work normally.
Four Takeaways for SwiftUI Developers
ScrollView+LazyVStack+scrollTois an inherently unstable combination when any single item can be taller than the screen. Chat, logs, and streaming long-form content all fall into this category.- When debugging a layout loop that won't stop, silence from
_printChangesis the key signal. It means the loop is at the layout layer, not the data layer. Staring at@StateandonChangewon't find it — you need to bisect the view tree. - For auto-scroll, prefer
anchor: niltargeting a tiny sentinel view at the tail, rather than anchoring to real content whose size is unknown. - If you can lay out eagerly, don't reach for
Lazyjust to be safe. The savings fromLazycome from skipping off-screen layout, but the estimated sizes it introduces are exactly the fuel for this livelock. When the list itself isn't long, that tradeoff is a net loss.
Want to try Tactic Remote on your own machine? The iOS app is free on the App Store, and the companion server and CLI are free as well. For the fuller architectural story behind this fix, see Let the Server Own the Conversation History.
Try Tactic Remote
Control your coding Agents from your phone
Connect to Claude Code, Codex, and other Agents on your Mac, Windows, or Linux computer. Check progress and send the next instruction from iPhone or iPad.