Performance
Why iOS apps feel slow even when the API is fast
A practical look at the client-side bottlenecks that make mobile apps feel slow after the backend has already done its job.
7 min read
The API is only one timer
Mobile teams often start a performance conversation with backend latency. The endpoint is 180 ms, the payload is cached at the edge, the database query is fine. Then somebody opens the app on an older phone and the screen still feels heavy.
That is not a contradiction. On iOS, the user does not experience API latency in isolation. They experience launch work, navigation state, JSON decoding, image loading, view invalidation, layout, diffing, persistence, main-thread contention and the timing of all of those things together.
A fast backend can still feed a slow app. The client can take good data and turn it into a bad experience. Most of the iOS performance optimization that matters happens after the response arrives.
The main thread tells the truth
If a screen stutters, freezes during navigation or ignores taps for a beat, start by asking what is happening on the main thread. It is common to find work there that nobody considers expensive because each piece looks harmless alone.
Date formatting in a cell. JSON mapping that grew over time. Image decoding after download. A large diff. A synchronous database fetch. A SwiftUI body invalidating more of the tree than expected. A logging call that does disk work. None of these needs to be dramatic to hurt the feel of the app.
The fix is not to move everything to background queues randomly. The fix is to identify the user-visible lane and protect it. Rendering, input and navigation need room. Expensive transformation, decoding, persistence and precomputation should happen where they cannot block touch and animation.
Decoding is the most common offender I see in code reviews. A view model receives Data, decodes it in a property setter, and the setter runs on the main actor. On a small payload nobody notices. On a large one, the tap that triggered the load freezes the screen. Decode away from the main actor and hop back only to publish:
@MainActor
final class FeedModel: ObservableObject {
@Published private(set) var items: [FeedItem] = []
func load() async throws {
let (data, _) = try await URLSession.shared.data(from: FeedAPI.url)
// Decode and shape the rows off the main actor.
let rows = try await Task.detached(priority: .userInitiated) {
let dtos = try JSONDecoder().decode([FeedItemDTO].self, from: data)
return dtos.map(FeedItem.init) // formatting happens here, once
}.value
items = rows // back on the main actor, one assignment
}
}Two details matter. The mapping into display values happens in the same background step, so the view never formats anything. And the result lands in one assignment, so SwiftUI sees one change, not a stream of them.
Images are usually a system, not an asset
A feed can have excellent API latency and still feel slow because images are treated as an afterthought. Large images arrive, get decoded at the wrong time, fight for memory, get requested again, or pop into cells after the user has already scrolled past them.
A real image pipeline has several decisions: request sizing, disk cache, memory cache, downsampling, cancellation, placeholder behavior, prefetch distance and eviction policy. The right answer depends on the product. A shopping app, banking app, chat app and media feed do not all need the same image behavior.
Downsampling is the decision most apps skip. A decoded bitmap costs memory by pixel count, not by file size. A 4000 pixel photo shown in a 300 point cell is decoded at full size unless you say otherwise. ImageIO lets you decode straight to the display size, without building the full bitmap first:
import ImageIO
import UIKit
func downsampledImage(at url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
return nil
}
let maxPixels = max(pointSize.width, pointSize.height) * scale
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // decode now, not at first draw
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixels
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else {
return nil
}
return UIImage(cgImage: cgImage)
}Call it off the main thread, cache the result keyed by URL and target size, and cancel the work when the cell leaves the screen. kCGImageSourceShouldCacheImmediately moves the decode cost to the moment you create the thumbnail, which is on your background task, instead of the first time the image is drawn on the main thread.
The mistake is thinking of images as UI decoration. In many mobile products, images are the performance profile. They decide memory pressure, scroll smoothness, perceived loading and how quickly the screen becomes useful.
SwiftUI performance is about what gets re-evaluated
SwiftUI does not redraw everything on every change. It re-evaluates the bodies of views that read the state that changed. That makes dependency reads the main performance lever. A view that reads one wide object is invalidated by every change to that object, and so is everything it builds below it.
The second lever is identity. ForEach and List track rows by their id. If the id is an array index, an insertion at the top renumbers every row, and SwiftUI treats them all as changed. If the id comes from the domain, a reorder is a reorder and nothing else is rebuilt.
The third is what the row body costs. A row body runs during scrolling, so whatever it allocates, it allocates at scroll rate. Here is a row that looks fine in review and stutters on a long feed:
// Before: sorts in body, keys on index, builds a formatter per row.
struct FeedView: View {
@ObservedObject var model: FeedModel
var body: some View {
let sorted = model.items.sorted { $0.date > $1.date }
List(sorted.indices, id: \.self) { i in
let formatter = DateFormatter()
formatter.dateStyle = .medium
Text(formatter.string(from: sorted[i].date))
}
}
}
// After: the model holds the presented order and the display string.
struct FeedItem: Identifiable {
let id: String // the server's stable id
let dateText: String // formatted once, when the data arrived
}
struct FeedView: View {
@ObservedObject var model: FeedModel
var body: some View {
List(model.items) { item in
Text(item.dateText)
}
}
}The fixed version hands SwiftUI a collection that only changes when the data changes, rows keyed on a stable id, and a body that renders a value instead of computing one. Moving formatting into the model when the data arrives is the single change that most often removes a list stutter, and it is usually the last thing people try.
On List versus LazyVStack: both create rows on demand. List reclaims rows the user has scrolled past, the way a collection view does. LazyVStack keeps rows it has created, so memory grows with how far the user scrolls. For an unbounded feed, List is the default. Switching a slow List to LazyVStack is often the wrong direction.
State can make a fast screen feel unstable
Performance is not only frame rate. A screen feels slow when it keeps changing shape. Content flashes in, disappears, reorders, shows stale values, then corrects itself. The user reads that as slowness even if every individual operation is technically fast.
This usually comes from unclear state ownership. The view loads cached data, then network data, then derived data, then an optimistic update, then a server correction. If those transitions are not designed, the screen feels nervous.
A better approach is to define the states explicitly: cached, refreshing, empty, failed, partial, pending action, synced. The app should not merely have data or not have data. It should know what kind of truth it is showing.
Ownership also decides invalidation. When one store holds balances, the feed and the form, a balance tick re-evaluates all three. Split them so each store owns one kind of truth, and a change reaches only the views that read it. Paging has the same rule: new pages append, they never reorder rows the user has already seen, or rows jump under the thumb and no rendering work fixes it.
How to measure iOS performance
Guessing is the anti-pattern. The workflow is short: measure on a real device, fix the biggest thing the data shows, measure again. Profile a release build on the oldest device you support. The simulator and a debug build both lie.
- Name the symptom first. Slow launch, a hitch while scrolling, a freeze after a tap, and memory growth are four different problems with four different instruments.
- Time Profiler for CPU. It samples call stacks, so you can see which of your functions own main-thread time during the slow moment. Filter to the main thread and look for your code, not the system frames around it.
- Hangs for freezes. It flags periods where the main thread was unresponsive long enough for the user to notice, and shows what it was doing.
- The SwiftUI instrument for view updates. It shows which view bodies were evaluated and how often. A count that scales with the number of rows, when one value changed, points to a wide dependency or unstable identity.
- Allocations for memory. Mark a generation before and after a flow, such as pushing and popping a screen, and look at what survived that should not have.
- Signposts for your own spans. Wrap a suspect region with
OSSignposterintervals and it appears on the timeline, which turns "the feed feels slow" into "decode took most of the load". - Change one thing, then re-record. Stack five fixes and measure once, and you will not know which one worked or which one regressed something else.
Instruments answers why it is slow on your desk. The field needs different tools. MetricKit delivers daily aggregated payloads from real users, covering launch time, hang rate, memory and disk writes, plus diagnostic payloads with call trees for hangs and crashes. Xcode Organizer shows the same signals per release, so a launch regression shows up as a trend line instead of a support ticket.
Beyond the tools, measure the path the user feels: time to first meaningful content, scroll hitches, image cache hit rate, main-thread stalls, launch time, pagination delay, and how often the user sees a full-screen loading state. The useful question is not whether the system is fast somewhere. It is where the user waits, where they lose trust and where the app blocks the next action.
Once you measure that path, performance work stops being mystical. You can decide whether to precompute, cache, cancel, paginate earlier, reduce payload size, split state, defer work, or change the product behavior.
What interviewers ask about iOS performance
I have asked some version of "the app is slow, what do you do?" in hundreds of interviews. The question is open on purpose. Weak answers jump straight to a fix: use LazyVStack, add caching, move it to a background thread. Strong answers ask what slow means, pick the matching instrument, and only then discuss causes.
The shape I listen for is a story with numbers: a baseline, one targeted change, a re-measurement. "I made scrolling smoother" tells me nothing. "Time Profiler showed most of the frame time in JSON decoding on the main thread, I moved it to a background task, and the hang rate in MetricKit dropped" tells me the candidate has done this for real.
The follow-ups separate levels:
- "You have ten thousand rows and scrolling stutters." Mid-level: lazy container, stable ids, cheap rows. Senior:
ListoverLazyVStackbecause it reclaims rows, formatting moved into the model, no sorting inbody. Staff: the list is a window onto data you do not fully hold, so page the source, keep a bounded working set, and derive identity from the server id across pages. - "Why is this image-heavy screen using so much memory?" The answer I want mentions decoded size versus file size and downsampling to the display size at decode time.
- "How would you know this is slow for users, not just for you?" MetricKit and Organizer, and at senior level, hang rate and launch time as release criteria rather than dashboards someone checks occasionally.
- "Which state change here invalidates the most views?" A candidate who can point at one wide dependency read and propose a narrower one understands SwiftUI performance. One who says SwiftUI redraws everything does not.
Red flags are consistent: a formatter created inside a row body, sorting or filtering in body, rows keyed on array index, and "we only have a few hundred items". That last one holds until the account with forty thousand arrives.