author: Wesley Matlock read_time: 7 —
It started with an interview question:
“Your SwiftUI view freezes when scrolling. What tools would you use to debug that?”
And just like that, you’re staring at Instruments, half-guessing, half-praying. You think: Maybe Time Profiler? Maybe print the**.body? I’ve been there. I’ve also botched that question, which sent me down a rabbit hole I should’ve hit sooner.
If you’re using SwiftUI and relying on Firebase for performance diagnostics, you might be missing critical insights into the stuff that actually matters: dropped frames, recomposition overload, async hangs, memory bloat, and your views tanking the main thread.
In this post, we’re building a SwiftUI app that mimics a real-world trap — it looks fine, scrolls okay, but under the hood, it’s a performance crime scene. Then we’ll fix it. Not with Firebase. With tools Apple gave you, like os_signpost, MetricKit, and Instruments.
🫶 Quick thing before we keep going: If this post helps you squash one SwiftUI performance stutter, smashing that 👏 button (up to 50x!) helps it reach more devs. Appreciate you.
🛠 The Setup: Introducing FlightGlitch
A minimal aviation-themed SwiftUI app with async fetches, slow list updates, and enough view churn to nuke your frame rate.
// Flight.swift
struct Flight: Identifiable {
let id = UUID()
let number: String
let origin: String
let destination: String
}
// FlightGlitchViewModel.swift
@Observable class FlightGlitchViewModel {
@MainActor
func fetchWeather(for flight: Flight) async -> String {
// Simulate async delay + jitter
try? await Task.sleep(nanoseconds: UInt64(Int.random(in: 50_000_000...300_000_000)))
return Bool.random() ? "☀️" : "🌧️"
}
var flights: [Flight] = [
.init(number: "F9 123", origin: "DEN", destination: "SFO"),
.init(number: "F9 456", origin: "LAS", destination: "SEA"),
.init(number: "F9 789", origin: "ATL", destination: "ORD"),
.init(number: "F9 321", origin: "MCO", destination: "PHX")
]
}
// FlightGlitchView.swift
import SwiftUI
import os.signpost
struct FlightGlitchView: View {
// Create a static signposter to ensure it persists
static let signposter = OSSignposter(subsystem: "com.yourcompany.FlightGlitch", category: .pointsOfInterest)
let logger = Logger(subsystem: "com.yourcompany.FlightGlitch", category: "FlightView")
@State private var weather: [UUID: String] = [:]
@State private var viewModel = FlightGlitchViewModel()
var body: some View {
List {
ForEach(viewModel.flights) { flight in
flightRow(for: flight)
}
}
.animation(.default, value: weather)
}
@ViewBuilder
private func flightRow(for flight: Flight) -> some View {
VStack(alignment: .leading) {
Text("Flight \(flight.number)")
.font(.headline)
Text("\(flight.origin) → \(flight.destination)")
if let condition = weather[flight.id] {
Text("Weather: \(condition)")
.foregroundStyle(.secondary)
.transition(.opacity)
}
}
.onAppear {
Task {
// Use the static signposter
let signpostID = Self.signposter.makeSignpostID()
let state = Self.signposter.beginInterval("WeatherFetch", id: signpostID, "Flight: \(flight.number)")
logger.debug("Starting weather fetch for flight \(flight.number)")
let result = await viewModel.fetchWeather(for: flight)
weather[flight.id] = result
Self.signposter.endInterval("WeatherFetch", state)
logger.debug("Completed weather fetch for flight \(flight.number)")
}
}
}
}
```swift
#### 👀 What’s Wrong With It?
1. Every row in the List fires off its own async task on load — and yes, that's intentional for demo purposes. We're trying to simulate a worst-case scenario so we can catch it red-handed with Instruments.
2. There’s zero throttling or caching — great for demonstrating load storms.
3. We’re layering view transitions and updates during async fetches.
4. We’re going to profile it with signposts and catch the impact in real-time.
### 🧭 os\_signpost 101 (a quick crash course)
If you haven’t used os\_signpost before — think of it as a timeline breadcrumb system. It lets you mark chunks of code and measure their runtime directly in Instruments.
We’re using OSSignposter (iOS 16+) for Swift-native tagging. You wrap any async block like so:
```swift
let id = signposter.makeSignpostID()
let state = signposter.beginInterval("SomeLabel", id: id)
await someAsyncWork()
signposter.endInterval("SomeLabel", id: id, state)
```swift
You’ll see these intervals inside Time Profiler when Instruments is running — making it 10x easier to correlate “this thing slowed down here” with *what code actually ran*.
### 🔬 Profiling With Instruments
1. **Build and run the app on a real device** (Simulator won’t trigger MetricKit properly).
2. Open **Instruments** from Xcode > Open Developer Tools.
3. Choose the **Time Profiler** template and hit Record.
4. Scroll the list and let it do its slow thing.
5. You’ll see WeatherFetch intervals show up in the timeline, grouped by flight number if labeled right.
Look for:
- Spikes on the main thread
- Long intervals in the WeatherFetch signpost
- Overlapping signposts (hint: structured concurrency doesn’t wait unless you tell it to)
### 📦 MetricKit: System-Level Feedback
So we already showed how to use os\_signpost and Instruments to catch those gnarly runtime slowdowns. But what if you want to track long-term app performance across launches? That's where **MetricKit** really shines.
#### 🔍 How MetricKit Works (High Level)
**Automatic Collection**
- App Launch Times
- Hang Rates
- Memory Usage
- Disk Writes
- CPU & Battery Usage
**Delivery Schedule**
- You’ll get metrics ~once per day
- NOT real-time. Patience, Padawan.
**Real Device Required**
- Doesn’t work on the simulator
- Device needs to be online and used regularly
#### ✍️ Logging and Processing MetricKit Payloads
Here’s a way to collect and log metrics in your app:
#### Create a subscriber:
```swift
import SwiftUI
import MetricKit
@main
struct FlightMetricsApp: App {
@StateObject private var metricsLogger = MetricsLogger()
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class MetricsLogger: NSObject, ObservableObject, MXMetricManagerSubscriber {
private let logger = Logger(subsystem: "com.yourcompany.FlightMatrics", category: "MetricKit")
override init() {
super.init()
MXMetricManager.shared.add(self)
logger.info("MetricKit subscriber added")
}
deinit {
MXMetricManager.shared.remove(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
logger.info("Received metric payload")
if let jsonData = payload.jsonRepresentation(),
let jsonString = String(data: jsonData, encoding: .utf8) {
logger.debug("Payload: \(jsonString)")
}
}
}
}
```swift
What to look for:
- MXAnimationMetric: High animation frame drop count
- MXAppHangMetric: Hangs on main thread
- MXCPUMetric: CPU usage, often high for janky lists
### 🎯 Bonus Moves
**Custom Signposts**
```swift
import MetricKit
func trackCustomEvent() async {
let logHandle = MXMetricManager.makeLogHandle(category: "FlightTracking")
mxSignpost(.begin, log: logHandle, name: "WeatherFetch")
await fetchWeather()
mxSignpost(.end, log: logHandle, name: "WeatherFetch")
}
```swift
**Testing Without Waiting 24 Hours**
- Xcode → Debug → Simulate MetricKit Payloads
**Other Ideas**
- Save payloads to file:
```swift
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let fileURL = documentsPath.appendingPathComponent("metrics_\(Date().timeIntervalSince1970).json")
try? jsonData.write(to: fileURL)
```swift
- Or send it to an analytics service via URLSession
### ⚠️ SwiftUI-Specific Pitfalls
- Don’t profile .body recomputes unless you really know what triggered them.
- SwiftUI diffing may run on background queues — your main thread won’t always reflect it.
- Lazy views (like List) do surprising things depending on scrolling behavior.
### ✅ Fix It
To clean this up:
- Use async let + await grouping to parallelize fetches while maintaining structured concurrency for row fetches to spread load evenly
- Debounce view updates if network fetches aren’t UI-critical
- Move async fetches into view model with caching
- Profile again and compare Timeline + Metrics
### 🛫 Going Deeper: Advanced Profiling Tips
#### 🧵 Filter the Time Profiler Call Tree
After recording in Instruments, click into the call tree and use the search bar to filter by WeatherFetch. You’ll see exactly which async function or view caused the spike. Expand the call stack and look for FlightGlitchViewModel.fetchWeather. This gives you a real paper trail of where time was spent.
#### 🧮 Compare Parallel vs Sequential Async Work
Try removing async let and using sequential await calls instead. Profile both versions. You’ll notice overlapping signposts disappear, and your frame time either improves or worsens depending on fetch duration. This shows you how structured concurrency impacts real render time.
#### 📦 Interpreting MetricKit Payloads
Sample log output from MetricKit might include:
```swift
MXAnimationMetric: averageFrameRate = 47.8 FPS
MXAppHangMetric: totalHangs = 2
MXCPUMetric: cumulativeCPUTime = 0.87s
That averageFrameRate tells you your 60 FPS target isn’t being met. Hangs mean your async work is blocking the main thread — probably during recomposition. CPU time gives you insight into background thread pressure.
🧪 Before and After: Validate With Instruments
After applying your fixes, re-run the same Instruments profile. Compare the timeline:
- Are there fewer overlapping signposts?
- Are frame stutters gone?
- Has the WeatherFetch interval shortened? This is how you prove that your optimizations weren’t placebo.
🔄 MainActor & Thread Jumps
Add @MainActor to your async methods and compare performance again. It may resolve timing bugs where the UI updates happen off-thread. Use Instruments’ Thread Checker or signposts to track this — look for unexpected background work in UI views.
To clean this up:
- Use async let + await grouping to parallelize fetches while maintaining structured concurrency for row fetches to spread load evenly
- Debounce view updates if network fetches aren’t UI-critical
- Move async fetches into view model with caching
- Profile again and compare Timeline + Metrics
🎯 Bonus: More Real-World iOS Survival Stories
Want more on SwiftUI performance tuning? Check out my other post on building a Metallica-themed Calendar app that wrestles with Time Profiler, recomposition, and DateFormatter madness. It’s got signposts, Instruments tips, and a few surprise headbangs.
If you’re hungry for more tips, tricks, and a few battle-tested stories from the trenches of native mobile development, swing by my collection of articles: https://medium.com/@wesleymatlock. These posts are packed with real-world solutions, some laughs, and the kind of knowledge that’s saved me from a few late-night debugging sessions. Let’s keep building apps that rock — and if you’ve got questions or stories of your own, drop me a line. I’d love to hear from you.
Originally published on Medium