Beyond Firebase: Using MetricKit, os_signpost, and Instruments  in a Modern SwiftUI App

Beyond Firebase: Using MetricKit, os_signpost, and Instruments in a Modern SwiftUI App

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. Theres zero throttling or caching — great for demonstrating load storms.
3. Were layering view transitions and updates during async fetches.
4. Were going to profile it with signposts and catch the impact in real-time.



### 🧭 os\_signpost 101 (a quick crash course)

If you havent 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.

Were 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

Youll 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 wont 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. Youll 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 doesnt 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**

- Youll get metrics ~once per day
- NOT real-time. Patience, Padawan.

**Real Device Required**

- Doesnt work on the simulator
- Device needs to be online and used regularly

#### ✍️ Logging and Processing MetricKit Payloads

Heres 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

- Dont profile .body recomputes unless you really know what triggered them.
- SwiftUI diffing may run on background queues — your main thread wont 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 arent 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. Youll 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. Youll 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