Home

Four times I optimised the wrong thing

“Measure first” is advice everyone agrees with and I keep failing to follow, because a plausible hypothesis feels like knowledge. Here are four times it was not, with the real numbers.

1. The list that was not the list

Symptom: a screen took about 900ms to appear.

My hypothesis: too many rows. I was ready to add pagination.

What the profiler said: DateFormatter.string(from:) at 61% self weight. I was constructing a formatter inside a map over the response, once per row, and DateFormatter init loads locale data every time.

Fix: move one line out of the closure. 900ms → 140ms.

Pagination would have taken two days, made the UX worse, and produced a screen that was still slow per page.

2. The image cache that made things worse

Symptom: memory warnings on a grid of thumbnails.

My hypothesis: too many images in memory. I built an LRU cache with an eviction policy.

What was actually happening: each thumbnail was a full-resolution photo. UIImage holds the decoded bitmap, so a 4000×3000 photo is about 48MB regardless of being drawn at 80 points.

My cache was carefully evicting 48MB objects that should never have been 48MB. Downsampling at decode time with CGImageSourceCreateThumbnailAtIndex took each one to about 100KB, and the cache became unnecessary — the whole grid fit in less memory than three of the old images.

Fix: delete the cache, downsample on decode. Peak memory 1.2GB → 90MB.

3. The JSON parser that was fine

Symptom: a sync operation took 12 seconds.

My hypothesis: JSONDecoder is slow. I spent an evening evaluating faster parsers.

What the profiler said: decoding was 400ms of the 12 seconds. The rest was 200 sequential network requests, each waiting for the previous one, because I had written the sync as a for loop with an await inside it.

Fix: a task group with a concurrency limit of six.

try await withThrowingTaskGroup(of: Page.self) { group in
    for url in urls {
        group.addTask { try await fetch(url) }
    }
    …
}

12s → 2.1s, and the JSON decoder I had spent an evening researching was never the problem.

Warning

This is the most common shape of the mistake: optimising CPU work when the actual cost is waiting. A profiler set to sample wall time shows blocked threads accumulating weight, which looks exactly like work. If the top of your trace is a network or lock frame, you have a concurrency problem, not a performance one.

4. The animation that was a layout

Symptom: a transition stuttered on older devices.

My hypothesis: the animation was too complex. I simplified it, reduced the spring, removed a blur.

What Instruments said: the hitches were not in rendering at all. A GeometryReader inside each row was participating in the sizing pass, and the layout was running several times per frame during the transition.

Fix: replace it with containerRelativeFrame. The original animation, unmodified, ran at 60fps.

I had spent two days making the design worse to fix something that was not the design.

The pattern

In all four cases my hypothesis was plausible. Long lists are slow, image caches help, JSON parsing is expensive, complex animations drop frames. Each is true in general and none was true here.

What they have in common: I was reasoning about the code I had recently written, and the cost was somewhere else — in a formatter, in a decode, in a loop’s structure, in the layout system. Attention goes to the thing you last touched, and cost does not.

What I do now

Take a trace before forming a hypothesis. Not after. If I have already decided what is slow, I will read the trace looking for confirmation and find it.

Write down the number first. “The screen takes 900ms.” Without a baseline, “it feels faster” is the only available measure and it is always positive after you have spent a day.

Fix one thing, measure again. Two changes and an improvement tells you nothing about which one mattered.

Release builds, real devices. Debug builds have optimisations off; the simulator has your Mac’s CPU. Both produce a performance profile that is not your users’.

The counter-argument

There is a real limit to “always measure”. Some things are known to be expensive and do not need a trace to justify avoiding — an O(n²) loop over user data, a synchronous network call on the main thread, decoding an image at ten times the size it is drawn.

The distinction I use: avoid known-bad patterns by default, and measure before doing anything that costs design quality. Not putting a network call on the main thread is free. Adding a cache, splitting a screen into pages, or simplifying an animation is not — those trade something real, and that trade needs evidence.