August 15, 2026 · 11 min read
SwiftUI Memory Hunt: Narrowing 725MB with jetsam Logs
Only screen transitions froze while scrolling still worked. jetsam logs showed a 725MB peak. Narrowing the cause with CoreAnimation and _logChanges() instead of guessing.
A user said that only screen transitions stopped; scrolling and Back still worked.
There was no crash report, naturally: the app had not died. Slowness in an app that stays alive leaves no crash log.
I opened the device's jetsam record, the file from the time the user saw the freeze.
JetsamEvent-2026-08-11-112056.ipsJustSend used a lifetime maximum of 725MB and ranked seventh across the system. The same list had ChatGPT at 362MB and WebKit at 339MB. Ours was twice as high.
jetsam records apps that did not die
jetsam is the iOS mechanism that reclaims memory by ending processes. When memory is low, it terminates lower-priority apps first.
I export the record from Settings → Privacy & Security → Analytics & Improvements. The files start with JetsamEvent-.
The file contains memory usage for every process alive at that moment, not only processes that died.
Our app was not terminated, so there was no crash report. It was still in the jetsam record.
| Item | Value |
|---|---|
| JustSend lifetime maximum | 725MB |
| System-wide rank | 7 |
| State at jetsam time | suspended |
| Resident in that state | 122MB |
The third row was the second problem. The app was in the background in suspended state while holding 122MB.
If a background app does not release memory, another app dies instead. That is where the user's “the whole system is slow” came from.
Compressor counts explained system pressure
The same record had this value.
compressions: 15,734,833iOS compresses unused pages when memory is low, and compression uses CPU.
More than 15 million compressions had accumulated. That explained the first report that the iOS system was affected: our app held memory, so the system kept compressing.
No crash delayed diagnosis
The app never received a watchdog kill, iOS's forced termination for an unresponsive app.
The symptom was therefore ambiguous. Transitions stopped, but scrolling and Back worked. The app was alive.
To the user it was “slow”; to me there was no reproduction clue.
A problem that does not die is harder to find than one that does.
I narrowed reproduction in the simulator
I saw 725MB on the device but did not know which screen caused it.
I suspected long bodies. The record in which the user saw the freeze was a long note.
I made a 2,500-line body and entered its detail screen in the simulator.
| Moment | Memory |
|---|---|
| Before entering detail | 45MB |
| After entering detail | 611MB |
Opening one screen multiplied memory by thirteen.
There was another number: CoreAnimation used 346MB and had 3,573 layers.
Those 3,573 layers pointed to the cause. Rendering 2,500 lines created a layer for every line.
Reproduction is half the fix
A device-only problem cannot tell me whether it is fixed. Once I saw 45MB become 611MB in the simulator, measurement was possible.
When making a reproduction, I need the character of real user data. 2,500 lines was not arbitrary; it came from the size of the reported record.
Laziness ended at the block boundary
The detail screen renders the body as ScrollView > LazyVStack > ForEach(bodyFlowItems). It looks lazy from outside.
Inside a block it was not. The view for one block was an ordinary VStack { ForEach(block.sentences) }. Highlight anchors are sentence-level, so each sentence needs a view, but this stack is not lazy.
A block boundary is a blank line, with code fences protected. Text written without blank lines becomes one block: pasted manuscripts, extracted attachments, transcripts, and logs.
Thousands of sentences in one block materialized at once.
Block structure alone made a sixfold difference at the same size
I measured three forms of the same 2,500-line, 285KB body in the iPhone 17 Pro simulator.
| Condition | Physical footprint | CoreAnimation |
|---|---|---|
| No blank lines → 1 block (before) | 623MB | 346MB / 3,573 layers |
| Same text split by blank lines → 2,500 blocks | 103MB | — |
| No blank lines → 1 block (after) | 102MB | — |
The first and second rows identify the cause. The character count is identical, but block structure alone makes a sixfold difference. The first and third rows prove the fix. Afterward, one block and 2,500 blocks are equivalent.
The fix was one line: change the sentence stack inside a block to LazyVStack.
// StreamDetailScreen.swift:929-938
// **문장 줄도 게을러야 한다.** 바깥 `LazyVStack`의 게으름은 블록 경계에서만
// 듣는다 — 빈 줄 없이 이어 쓴 원고(붙여넣은 문서·추출된 원문·자막)는 통째로
// 한 블록이 되고, 그러면 그 안의 문장 수천 개가 한꺼번에 실체화된다.
//
// 실측(2026-08-11, iPhone 17 Pro 시뮬, 2500줄 285KB):
// 빈 줄 없음(1블록) 623MB · CoreAnimation 346MB / 레이어 3,573
// 같은 글을 빈 줄로 나눔(2500블록) 103MB
return LazyVStack(alignment: .leading, spacing: StreamTokens.x1) {
I left the measured values in the comment. The next person who wants to turn this stack back into VStack sees the numbers first. 623 and 103 stop the “it does not look like it needs to be lazy” judgment.
I did not change the splitting rule. Block ordinals are highlight-anchor coordinates. Changing the rule would move every mark the user has already made. I cannot move user marks to save memory.
The flattened array must not be made every frame
There was another place on the same screen that defeated laziness.
The body has a section/block hierarchy, but it must be flattened into one stream for one LazyVStack. If that flattening happens inside body, an array of hundreds of blocks is allocated every frame.
// StreamDetailScreen.swift:93-97
/// 본문을 한 줄기로 편 것 — `LazyVStack`이 블록 단위로 게을러지도록.
///
/// 값이 바뀔 때 한 번만 만든다. `body`에서 매번 펴면 블록 수백 개짜리 배열을
/// 프레임마다 새로 할당하게 되고, 게으름으로 아낀 것을 그 자리에서 도로 쓴다.
@State private var bodyFlowItems: [BodyFlowItem] = []
I store it in @State and fill it once when the source changes. Laziness delays views; it does not reduce the cost of creating their list.
I did not make the head and tail lazy. Their counts are fixed. Only the middle body needs it, and I wrap the two fixed parts in VStack to retain the old spacing.
The hypothesis of fewer renderers was worse
My first hypothesis was that creating a Markdown renderer per sentence was the culprit. I changed to one renderer per block.
It became 948MB, with CoreAnimation at 423MB and 5,074 layers. It was worse than what I was fixing.
A Markdown renderer is larger when it parses and lays out a long piece at once. The variable was not renderer count but the number of materialized views.
One computed property rebuilt an LCS table on every transition
One more issue appeared in the same work. lines in the version comparison screen was a computed property.
A computed property runs every time body is evaluated. A transition animation evaluates body every frame. Inside it was an LCS table with a worst-case size of 2,000×2,000.
I moved it to @State and computed it once in .task(id:) with Task.detached.
// MemoVersionHistoryView.swift:306-337
/// nil은 "아직 계산 중"이고 빈 배열은 "차이 없음"이다. 둘을 같은 값으로 두면
/// 계산이 끝나기 전에 "변경 없음"이 한 번 스쳐 지나간다.
@State private var lines: [LineDiff]?
...
.task(id: version.id) {
lines = await Task.detached(priority: .userInitiated) {
LineDiff.diff(old: old, new: new)
}.value
}
The comment explains Optional. “Computing” and “no difference” both have no lines to show, but the screen text differs. If I merge them, “No changes” flashes before computation ends and the user reads it as the result.
.task(id: version.id) is the recomputation condition. It runs only when the compared version changes, regardless of how often body is evaluated.
The comparison screen transitioned in 1.2 seconds for the worst combination, 2,500 lines against 2,500 lines.
I removed two render waste paths at the same time
Separately from memory, the list screen was redrawing more than needed.
SwiftUI has the diagnostic function _logChanges(), which prints why a view's body was evaluated again.
It separated the cause into two branches.
First, four views held the thumbnail store as @ObservedObject. They subscribed to changes without drawing its value. Posting one thumbnail ran body for all attached views.
Second, the row view had nine closure properties. Closures are newly created each time, so SwiftUI's default comparison cannot call them equal. When the list surface ran once, every row followed.
The branches differ.
| Observation leak | Closure property | |
|---|---|---|
| Cause | Subscribes to a value it does not draw | Comparison fails because the value is new |
| When it runs | When the store publishes | When the parent runs once |
| How many | Four subscribed views | Every row on screen |
| Fix | Remove the subscription | Remove closures from properties |
They multiply. One thumbnail publication runs four views; if the list surface is one of them, every row below follows. Since thumbnails publish while scrolling, the multiplication continues.
I measured gestures by reevaluation count
A drawer gesture was the third branch. On a real iPhone Air, left and right gestures lagged and the system became slow too.
The lag did not reproduce in the simulator. On the M4 Max host, the main thread was 93% idle during the drag. I abandoned time as the metric and used hardware-independent measurement: how many times body reevaluates while opening the drawer.
| Measurement | RootView.body |
StreamScreen.body |
|---|---|---|
| Before | +11 | +11 |
| After | +1 | +1 |
The remaining one is the condition branch settling to open and cannot be removed.
The cause was where progress lived. Drag offset was @State on the root view, and root body read it directly in three places: mask curvature, offset, and background opacity. The list screen was inline in that root body; it received about sixty arguments, most of them newly made closures. Every finger movement rebuilt the entire list tree.
I moved progress into one observable value and separated the view owning the mask from the view owning drawer position. The root reads only open versus closed.
I also made the body arrive as a value rather than an @ViewBuilder closure. A closure recreates the body tree each time it is drawn and defeats the separation.
A gesture changes values dozens of times per second. A broad reevaluation scope multiplies that count.
I wrote the investigation order as eight steps
I narrowed the cause in this order.
| Order | What I inspect | What it tells me |
|---|---|---|
| 1 | Crash report | If absent, the app did not die |
| 2 | jetsam record | Maximum use and system rank of a living app |
| 3 | Resident amount in suspended |
What it holds after leaving the screen |
| 4 | Accumulated compressions | Pressure applied to the whole system |
| 5 | Simulator reproduction | Which screen is responsible |
| 6 | CoreAnimation use and layer count |
Whether rendering structure is the problem |
| 7 | Change only structure for same input | Which variable matters |
| 8 | _logChanges() |
Why it redraws |
I must not stop at step 1. No crash report is information: the app did not die.
Step 7 was decisive. The same 2,500 lines used 623MB as one block and 103MB as 2,500 blocks. Since character count was equal, structure was the remaining variable.
I also wrote down obstacles in real-device diagnosis. log stream no longer accepts a device-selection option. I had to list and copy system crash-log files with devicectl. The jetsam record is the source of truth for app-memory trends.
Repeatedly reading the accessibility tree was another trap. Polling starved the app and made entering detail look like 65 seconds. During measurement, I wait a fixed time and read once rather than polling.
I had to translate the user's words into symptoms
“The screen transition stops but scrolling works” meant this:
The app is alive, the main thread is not completely blocked, and only creating the new screen takes a long time. Creating it allocates a large amount of memory, which triggers system compression, and transition stops while compression uses CPU.
The user's sentence already contained the causal range. Scrolling means existing views work; a stopped transition points to the construction path.