Est.
FeaturesLong read

Instruments Time Profiler for Swift Concurrency Bottlenecks

Release builds and the Swift Concurrency template turn vague slowness into concrete fixes.

Reporter · · 11 min read
Cover illustration for “Instruments Time Profiler for Swift Concurrency Bottlenecks”
Features · September 9, 2026 · 11 min read · 2,537 words

Swift Concurrency didn't make iOS apps slower. It made slowness harder to find. When work runs on a single main thread, a freeze has one obvious suspect. When work gets split across actors, tasks, and a cooperative thread pool, the same freeze could be coming from five different places, and guessing which one wastes more engineering time than the bug itself. This piece walks through a specific, repeatable Instruments workflow, built around Time Profiler inside the Swift Concurrency template, that turns "the app feels slow" into a named function, a named thread, and a fix you can actually verify.

Why profiling a debug build produces misleading data

Start with the build, because this is where most profiling sessions go wrong before they even begin. Debug builds trade speed for debuggability: optimizations are off, extra runtime checks are on, and the resulting CPU profile reflects none of what a real user experiences. Apple's WWDC26 session "Profile, fix, and verify: Improve app responsiveness with Instruments" makes this a precondition, not a suggestion. Profile a release build, or the data you collect is close to fiction.

Getting a release build into Instruments doesn't require a separate scheme or a manual flag flip. Product → Profile in Xcode does it automatically, compiling with optimizations on before launching the trace. Run it on a real device, too. Simulator CPU behavior doesn't track Apple Silicon or A-series cores closely enough to trust for timing-sensitive work.

None of this is exciting. It's administrative, and it's easy to skip when a bug feels urgent. But it's also the single most common reason a profiling session produces contradictory or confusing results: engineers spend an hour staring at a call tree that describes a build nobody will ship.

Choosing the Swift Concurrency template instead of a bare Time Profiler session

Instruments ships with a long list of templates, and for apps built on async/await, actors, and structured concurrency, the Swift Concurrency template is the right starting point, per Apple's own WWDC26 recommendation. It's not just Time Profiler with a different label. The template bundles Time Profiler for CPU sampling, the Swift Concurrency instrument for actor and task visualization, and System Trace for deeper thread-level inspection, all recording against the same timeline.

That timeline matters more than it sounds. It gives you a high-level picture of where work is actually happening across the recording, something a bare Time Profiler session simply doesn't show.

The template also forces an early, useful question: is CPU usage high during the hang, or is it idle? That answer splits the rest of the investigation into two different paths. High CPU points toward a code performance problem, something to optimize or move off the main thread. Idle CPU points toward a thread waiting on something else entirely, a lock, a disk read, an IPC call, and no amount of algorithmic tuning fixes that.

For SwiftUI-specific slowness, view bodies re-rendering more than they should, Instruments 26 adds a dedicated SwiftUI template with its own instrument. That's a complement to this workflow, not a replacement, and it gets its own section further down.

Reading the call tree: the three toggles that make the data usable

Time Profiler samples the CPU cores roughly a thousand times a second, capturing a stack trace at each sample. Add those samples up over a recording and you get a call tree: which functions were executing, and how much CPU weight each one carried. In raw form, though, this tree is close to unreadable. System library frames dominate the view, and parallel tasks blur together into a single confusing hierarchy.

Three Call Tree options fix that, and they're not optional extras, they're the difference between a report and a mess. Separate by Thread splits the recording so each thread's activity shows up on its own, which matters enormously for concurrency work where actor-isolated code and background tasks are interleaved. Invert Call Tree flips the backtrace so your own leaf functions rise to the top instead of the OS calls that led to them. Hide System Libraries strips out framework noise entirely, leaving only the code your team wrote.

Jacob Bartlett's 2024 profiling walkthrough of Check 'em describes this exact transformation: flipping those three toggles turned an unwieldy call tree into a readable report that surfaced a key bottleneck in the app. That's the practical payoff. The data doesn't change, but what you can see in it does.

The Heaviest Stack panel is worth a glance before diving into the full tree. It surfaces the single most CPU-intensive call path within whatever time range is selected, a fast first hint at where to look. And none of this works without symbolication: Release builds need their debug symbols kept accessible, or the call tree loses human-readable function names entirely.

Instruments 27 adds an Inspector panel that surfaces relevant details and actions based on whatever's selected in the timeline or detail view, cutting down on the back-and-forth between different parts of the interface.

Main Actor blocking: the most expensive category of Swift Concurrency bottleneck

The Main Actor runs on the main thread, and any heavy computation assigned to it blocks UI rendering for however long that work takes. This is still the most expensive category of bug in the Swift Concurrency world, structurally identical to old-school main-thread blocking, just easier to introduce by accident now that async code can quietly hop back onto the Main Actor.

In Time Profiler, it looks unmistakable: a massive spike in the timeline, one function eating almost all the CPU weight for that stretch. The WWDC26 demo walked through exactly this pattern, a trace where the app attempted to write over 1.7 gigabytes of data on the main thread. That single operation took more than 500 milliseconds, and nearly 300 of those milliseconds were spent off-core, waiting on disk I/O. Half a second is an eternity by UI standards, well past the point where a user perceives a freeze rather than a delay.

The fix isn't to make the write faster. It's to move the encoding and file-writing work onto the concurrent thread pool so the Main Actor stays free to keep rendering. The underlying work still takes the same amount of time; it just no longer holds the UI hostage while it runs.

Watch for the tell-tale shape in the call tree: a long, synchronous function sitting directly beneath a Main Actor frame. That's the function that needs to move. The WWDC26 diagnostic flow lays this out as one of exactly two responses to a high-CPU main-thread bottleneck: refactor the algorithm so it runs faster, or offload the work to a background task so it stops blocking the thread that draws the screen.

Actor contention and thread pool exhaustion: the subtler concurrency traps

Not every concurrency bug announces itself with a spike. Swift's cooperative thread pool aims for roughly one thread per CPU core, and when too many tasks pile up at once, the pool saturates and tasks start queuing instead of running in parallel. Compared to the old GCD era, where spinning up threads faster than the system could schedule them was a common failure mode, the cooperative pool is more resistant to outright thread explosion. But flood it with enough high-priority tasks and it still degrades.

This kind of contention is genuinely harder to spot. CPU usage looks moderate, maybe fragmented across several threads, and no single function dominates the call tree the way a Main Actor block does. Tasks show up as runnable but sit off-core anyway, a pattern Time Profiler alone doesn't fully explain. The WWDC26 workflow recommends pairing Time Profiler with System Trace when deeper thread-level diagnosis is needed.

Actor isolation adds its own version of the same problem. A serial actor processes one task at a time by design, so if a dozen tasks all need access to the same actor's state, eleven of them wait. That's not a bug, it's the point of actor isolation, but it becomes a performance problem when too much unrelated work gets routed through a single actor that was never meant to be a bottleneck.

The gain from getting this right is real. Bartlett's 2024 case study on restructuring around a properly isolated actor-based model found a 47% improvement in computation speed once contention was resolved, a result that suggests coordination overhead, while non-trivial, is smaller than what's gained from correct parallel execution. To diagnose it, use Separate by Thread and look for whether background threads are actually running concurrently, or whether they're all funneling through the same serial executor and taking turns.

Hidden leaf-function costs: finding the small functions that add up

Some slowdowns never produce a spike at all. They come from a cheap function, string formatting, date parsing, JSON encoding, that gets called thousands of times, and whose total cost only becomes visible once you add up every call across the whole session.

This is where the inverted call tree earns its keep. Flipping the hierarchy puts leaf functions, the lowest-level operations actually running on the CPU, at the top, ranked by their total time across the entire recording. It's the only view that catches a function costing microseconds individually but dominating the session in aggregate.

Bartlett's Check 'em profiling turned up a version of this in TOTP calculation: byte manipulation, string creation, cryptographic operations, each contributing to the overall cost. Added together, though, the total pushed computation past 10 seconds for rare inputs, and over a minute for the rarest combinations tested on an A17. No single frame in a non-inverted tree would have flagged that; only the aggregate view does.

Once the tree is inverted and system libraries are hidden, sort by Self time. Self time isolates the cost of a function's own code, excluding whatever it calls out to, which makes it the cleanest signal for leaf-level cost. From there, the fix is usually one of two things: replace the slow operation with a faster algorithm, or stop recomputing it altogether and cache the result through memoization or lazy evaluation.

When CPU is idle during a hang: system blocking and what Time Profiler can't see

Time Profiler only records active CPU cycles. If the main thread is suspended, waiting on a resource rather than executing code, the profiler shows nothing, and that blank stretch during a visible freeze is itself the diagnostic clue.

The usual suspects behind a CPU-idle hang, per the WWDC26 diagnostic flow, are file I/O, a synchronization lock, or inter-process communication. The 500-millisecond main-thread write mentioned earlier is a clean illustration: nearly 300 of those milliseconds were spent off-core waiting on disk, invisible to Time Profiler and only visible once System Trace entered the picture.

Tasks can get stuck the same way, blocked waiting on shared mutable state rather than actually idle. The Swift Concurrency instrument's task and actor visualization is built to surface exactly that, a task marked runnable that's actually sitting off-CPU, waiting its turn.

If Time Profiler comes back quiet during an obvious freeze, the next step is System Trace, in a separate recording aimed at the same reproduction steps, since the two instruments don't share a recording pass. This is the fork in the road that the whole WWDC26 diagnostic model is built around: high CPU means optimize the code or move it off the thread; idle CPU during a hang means something else entirely is holding that thread hostage, and no code change fixes it until the blocking dependency is found and removed.

Using the SwiftUI instrument in Instruments 26 alongside Time Profiler

Instruments 26, shipping with Xcode 26, adds a dedicated SwiftUI instrument and template, introduced in the WWDC25 session "Optimize SwiftUI performance with Instruments." It's built around four lanes, and each one answers a slightly different question.

Update Groups shows whether SwiftUI is doing any work at all in a given window; if CPU spikes while this lane sits empty, the bottleneck lives outside SwiftUI entirely. Long View Body Updates flags a view whose body property is taking too long to evaluate, color-coded orange or red by how likely it is to cause a visible hitch. Long Representable Updates does the same for view and view controller representable bridges. Other Long Updates catches everything else that doesn't fit the first three lanes.

The intended workflow chains these together with Time Profiler: use the SwiftUI instrument to find which view body is slow, then switch to Time Profiler, on the same timeline and the same selected range, to see exactly which line of code inside that body is burning the CPU. The updated SwiftUI template ships with Time Profiler and Hangs and Hitches already included alongside the new instrument, so one recording session captures all three layers at once.

The practical payoff shows up with unnecessary state invalidation. A view re-evaluating its body far more often than it needs to can look almost unremarkable in any single Time Profiler sample — not a spike, just a slightly elevated baseline. But the same pattern is flagged orange or red in the Long View Body Updates lane immediately, because SwiftUI is tracking the repetition itself, not just the CPU cost of each instance.

Closing the loop: measuring the fix and confirming improvement

A fix isn't real until a second trace proves it. Perception is an unreliable instrument here, and performance work has a long history of placebo improvements, changes that feel faster because the engineer expects them to, but move nothing when measured.

The WWDC26 workflow leans on "top functions" comparisons across trace sessions specifically to close that gap, and Instruments 27 speeds up each round of that cycle. The method itself is simple, even if it takes discipline to follow: record a baseline trace, ship the fix, record a second trace under matching conditions, same device, same OS version, same sequence of taps or inputs, and compare the same time ranges across both.

For a narrower, more surgical measurement, OSSignposter lets you wrap a specific suspected bottleneck, the WWDC26 demo used it around a lasso-selection code path, in a signpost interval. That gives a precise before-and-after cost for just that operation, instead of hunting for it across the entire call tree each time.

What counts as confirmed differs by bug type. A Main Actor block is confirmed fixed when the spike vanishes from the main thread and reappears, smaller and off the critical path, on a background thread. Thread pool exhaustion is confirmed fixed when Separate by Thread shows genuine parallel execution instead of tasks queuing through one executor. A leaf-function cost is confirmed fixed when it drops out of the top ranks in the inverted call tree.

Architecture plays a role here too. The Approachable Concurrency setting, introduced at WWDC25 and recommended by Apple for all projects, changes the default actor isolation model in Xcode 26 so fewer @MainActor annotations are needed in the first place, which in theory means fewer accidental main-thread assignments to catch later. But that's a claim about intent, not outcome. Profiling remains the only way to confirm the setting is actually doing what it's supposed to do in a given codebase, which is really the point of this entire workflow: not to guess at what should be faster, but to measure whether it is.

Sources

  1. High Performance Swift Apps
  2. Profile, fix, and verify: Improve app responsiveness with Instruments - WWDC26 - Videos - Apple Developer
  3. Optimize SwiftUI performance with Instruments - WWDC25 - Videos - Apple Developer
  4. swiftcrafted.dev

More in Features