Find Real-Device Regressions With MetricKit And Instruments

The hardest performance regressions are the ones that do not show up on your desk.

Your SwiftUI feed feels fine in a simulator with ten local photos. Then a release goes out and real users start seeing scroll hitches in accounts with years of images. Or the editor survives every QA script, but iPhones in the field start getting memory-pressure terminations after people apply three filters and jump back to the feed. Nothing “crashes” in the place you are staring at. The app just gets slower, hotter, or heavier under real state.

That is where MetricKit and Instruments fit together. MetricKit tells you what happened across real devices. StateReporting tells you which app state was active when it happened. Your ingestion pipeline turns those reports into a regression signal. Instruments gives you the local trace you need to reproduce, fix, and verify the specific workflow.

This is not the Device Hub loop from the last post. Device Hub is great when you already have a concrete device state to mirror. This loop starts earlier: with field data from people using the released app.

The loop

Think of the workflow as four steps:

  1. Collect MetricKit metric and diagnostic reports from real devices.
  2. Group those reports by app state, build, OS, device class, and feature configuration.
  3. Pick a regression worth investigating and recreate the state locally.
  4. Use Instruments 27 to profile, compare, fix, and verify.

For this post, imagine a SwiftUI app called Photo Loom. It has a masonry-style feed, a photo detail screen, and an editor that can apply filters, generate thumbnails, and save drafts. Version 4.8 ships a new “live preview” filter stack. Two days later, your backend starts showing a worse scroll hitch rate in the feed and a new cluster of memory exception diagnostics from the editor.

That is exactly the kind of bug MetricKit is built to surface. It is not one angry stack trace. It is a pattern in the field.

Start MetricKit at launch

Apple rebuilt MetricKit in iOS 27 around a Swift-first MetricManager. The new API exposes metricReports and diagnosticReports as async sequences, and the reports are Codable, so you can encode the whole payload as JSON and send it to your server.

The exact names below are beta-shaped from the WWDC26 iOS 27 SDK sessions. Check them against the Xcode 27 seed you are building with before copy-pasting them into production code.

import Foundation
import MetricKit

actor PerformanceUploader {
    func uploadMetricReport(_ data: Data) async throws {
        // Send to your ingestion endpoint.
    }

    func uploadDiagnosticReport(_ data: Data) async throws {
        // Send to your ingestion endpoint.
    }
}

final class PerformanceReporter {
    private let manager: MetricManager
    private let uploader: PerformanceUploader
    private var metricTask: Task<Void, Never>?
    private var diagnosticTask: Task<Void, Never>?

    init(manager: MetricManager = MetricManager(), uploader: PerformanceUploader) {
        self.manager = manager
        self.uploader = uploader
    }

    func start() {
        guard metricTask == nil, diagnosticTask == nil else { return }

        metricTask = Task.detached(priority: .utility) { [manager, uploader] in
            for await report in manager.metricReports {
                do {
                    let data = try JSONEncoder().encode(report)
                    try await uploader.uploadMetricReport(data)
                } catch {
                    // Keep listening. A failed upload should not stop collection.
                }
            }
        }

        diagnosticTask = Task.detached(priority: .utility) { [manager, uploader] in
            for await report in manager.diagnosticReports {
                do {
                    let data = try JSONEncoder().encode(report)
                    try await uploader.uploadDiagnosticReport(data)
                } catch {
                    // Keep listening for the next diagnostic.
                }
            }
        }
    }

    deinit {
        metricTask?.cancel()
        diagnosticTask?.cancel()
    }
}

Two details matter here.

First, start listening at app launch. MetricKit reports are delivered to your app, and Apple specifically recommends setting up the manager early and keeping it alive so the streams can continue delivering reports.

Second, upload raw JSON before you get clever. You can absolutely extract individual values in the app, but the first version of the pipeline should preserve the original report shape. That lets you reprocess historical data when your dashboard changes or Apple adds more fields in a later beta.

Separate metrics from diagnostics

MetricKit has two kinds of signal.

Metrics are the trend line. They tell you how the app behaved over time: launches, hangs, animation hitches, scrolling hitches, CPU, memory, disk writes, network, GPU, and, in iOS 27, Metal frame-rate metrics for render-heavy apps and games. Metric reports arrive as daily data with interval entries, including the full-day aggregate and smaller windows when available.

Diagnostics are the investigation packet. They arrive when the system captures a notable event such as a crash, hang, or memory exception. In iOS 27, memory exception diagnostics are especially useful for apps like Photo Loom because memory-pressure terminations often used to feel like a missing chapter: you knew the app was killed, but not enough about why.

If you need to inspect a report on-device before uploading it, Apple showed the basic pattern:

import MetricKit

for await report in manager.metricReports {
    for entry in report.intervalEntries {
        let memoryMetrics = entry.values.filter { $0.metricGroup == .memory }

        for metric in memoryMetrics {
            switch metric {
            case .peakMemory(let peak):
                processPeakMemory(peak)
            default:
                break
            }
        }
    }
}

And for diagnostics:

import MetricKit

for await report in manager.diagnosticReports {
    switch report.result {
    case .crash(let crash):
        processCrash(
            backtrace: crash.callStackTree,
            reason: crash.terminationReason,
            category: crash.terminationCategory
        )
    case .hang(let hang):
        processHangDiagnostic(hang)
    default:
        break
    }
}

Those snippets are useful for routing. Your server probably wants the whole JSON report, but your app might tag uploads, sample aggressively, or prioritize memory exception diagnostics over routine metrics when the user is on a constrained connection.

Apple’s public sample demonstrates the crash and hang routing shape. If you want to handle memory exception diagnostics separately in app code, add the SDK’s memory-exception diagnostic case after confirming its exact enum spelling in your Xcode 27 seed.

Add app state before you need it

Global averages are a trap. If Photo Loom reports “scroll hitching got worse,” you still do not know whether the feed, album picker, search results, or editor thumbnail tray is responsible.

iOS 27’s StateReporting framework lets you define domains and report transitions as the app moves through meaningful states. MetricKit can then aggregate metrics by those states instead of blending the whole app together.

Start with one domain for the main surface:

import MetricKit
import StateReporting

let surfaceDomain = StateReportingDomain("com.example.PhotoLoom.surface")
let manager = MetricManager(enabledStateReportingDomains: [surfaceDomain])

let surfaceReporter = StateReporter.reporter(for: surfaceDomain.rawValue)

surfaceReporter.reportTransition(to: "Feed")
surfaceReporter.reportTransition(to: "Editor")
surfaceReporter.reportTransition(to: "PhotoDetail")

A domain can have one active state at a time. Use separate domains when independent facts can be true simultaneously. In Photo Loom, the current surface and the editor’s workload are separate concerns:

import StateReporting

let editorModeDomain = StateReportingDomain("com.example.PhotoLoom.editorMode")
let cacheModeDomain = StateReportingDomain("com.example.PhotoLoom.cacheMode")

You can also attach structured metadata with @ReportableMetadata. Keep this stable and low-cardinality. “Large library” is useful. A raw album identifier is usually too granular and too sensitive.

import StateReporting

@ReportableMetadata
struct FeedConfiguration {
    let librarySize: String
    let thumbnailSource: String
    let isLiveFilterPreviewEnabled: Bool
}

let feedReporter = StateReporter.reporter(
    for: surfaceDomain.rawValue,
    stableMetadata: FeedConfiguration.self
)

feedReporter.reportTransition(
    to: "Feed",
    stableMetadata: FeedConfiguration(
        librarySize: "large",
        thumbnailSource: "disk-cache",
        isLiveFilterPreviewEnabled: true
    )
)

Good states describe phases you would actually optimize:

  • Feed, PhotoDetail, Editor, Export.
  • librarySize: small | medium | large.
  • thumbnailSource: memory-cache | disk-cache | network.
  • editorMode: crop | filter | markup.
  • isLiveFilterPreviewEnabled: true | false.

Avoid states that create a private analytics system by accident:

  • User IDs.
  • Photo IDs.
  • Search text.
  • Exact filenames.
  • One state per row, item, or gesture.

Apple’s guidance is to use meaningful, stable phases rather than transient UI events. The goal is to make a regression actionable, not to produce a thousand tiny buckets no one can interpret.

Encode state-aware JSON

Once you report states, you want your JSON to preserve that grouping. Apple showed a MetricKit encoding option that groups state entries by StateReporting domain:

import MetricKit

for await report in manager.metricReports {
    let encoder = JSONEncoder()
    let formatKey = MetricReport.encodingFormatKey
    encoder.userInfo[formatKey] = MetricReport.EncodingFormat.byStateReportingDomain

    let data = try encoder.encode(report)
    sendToServer(data)
}

On the backend, store the raw report and extract a small query model from it. A practical ingestion row might include:

  • App bundle identifier, version, and build.
  • OS version and device family.
  • Report kind: metric or diagnostic.
  • Metric group and metric name.
  • Interval start and duration when available.
  • StateReporting domain, state label, and stable metadata.
  • Aggregate value or histogram bucket.
  • Diagnostic category and top app frames for diagnostics.
  • Upload time and schema version.

For the Photo Loom regression, your first useful dashboard is not fancy. It is a table that answers:

build   state   metadata                                 metric             p50     p95
4.7     Feed    large, disk-cache, live-preview=false    scroll hitch rate  3 ms/s  11 ms/s
4.8     Feed    large, disk-cache, live-preview=true     scroll hitch rate  9 ms/s  64 ms/s
4.8     Editor  filter, live-preview=true                peak memory        410 MB  930 MB

Now you have a direction. The regression is not “the app is slow.” It is “the feed hitches for large libraries when live preview is enabled, and the editor’s filter mode has a high-memory tail.”

That is the difference between guessing and field performance work.

Turn the report into a local reproduction

The MetricKit dashboard should produce a reproduction brief:

## Regression

Photo Loom 4.8 increased feed scroll hitch rate for large libraries with live
filter preview enabled. Memory exception diagnostics also rose in Editor/Filter.

## State

- surface: Feed
- librarySize: large
- thumbnailSource: disk-cache
- liveFilterPreview: true
- related diagnostics: memory exception, editor filter mode

## Local setup

- Install build 4.8.
- Use a large synthetic library fixture.
- Enable live filter preview.
- Start from a cold cache, then repeat with a warm disk cache.
- Scroll feed, open editor, apply three filters, return to feed.

MetricKit does not replace reproduction. It narrows the search space. You still need a local data fixture, a build that matches the regression, and a repeatable interaction you can record.

This is also where points of interest are worth adding. StateReporting is for MetricKit aggregation. OSSignposter is for trace landmarks in Instruments. Use both, but do not confuse them.

import os.signpost

let signposter = OSSignposter(
    subsystem: "com.example.PhotoLoom",
    category: .pointsOfInterest
)

func applyLivePreviewFilters(to photo: Photo) async {
    let interval = signposter.beginInterval("Apply Live Preview Filters")
    defer { signposter.endInterval("Apply Live Preview Filters", interval) }

    await filterPipeline.apply(to: photo)
}

When you open the trace later, that interval gives you a named landmark around the exact interaction your field data implicated.

Profile in Instruments 27

Instruments 27 is built around a clean performance loop: profile, identify, fix, verify.

For the Photo Loom scroll hitch, start with Time Profiler and the Points of Interest instrument. Record only the workflow from the reproduction brief:

  1. Launch the app with the large library fixture.
  2. Start recording.
  3. Scroll the feed until the hitch appears.
  4. Open the editor, apply the live preview filters, and return to the feed.
  5. Stop recording.

Then inspect the run in layers.

Use Points of Interest to select the interaction. If the hitch lines up with Apply Live Preview Filters, you have a strong clue that editor work is leaking into feed responsiveness.

Use Top Functions when the call tree is too spread out. Instruments 27’s Top Functions view is designed for exactly this moment: a helper called from many places can disappear inside the hierarchical call tree, while Top Functions makes the expensive function obvious. In a photo app, that might be thumbnail decoding, color conversion, a metadata parser, or a SwiftUI layout helper that runs far more often than expected.

Use the Swift Concurrency and Swift Executors views when async work is involved. Apple’s Instruments session shows how executor visibility can reveal tasks saturating the Main Actor. In Photo Loom, the bad version might start thumbnail rendering with a task that still runs main-actor-isolated work:

let thumbnail = await Task(name: "Render Feed Thumbnail") {
    await renderThumbnail(photo: photo, size: CGSize(width: 240, height: 240))
}.value

If the render work does not need main-actor isolation, the beta-shaped Swift concurrency fix may be to move it off the Main Actor with @concurrent, as Apple showed in the Instruments session:

let thumbnail = await Task(name: "Render Feed Thumbnail") { @concurrent in
    await renderThumbnail(photo: photo, size: CGSize(width: 240, height: 240))
}.value

Do not apply that blindly. First prove in Instruments that executor contention is part of the hitch, then make the isolation change in the smallest place that owns the expensive work.

For the memory spike, switch templates. Use Allocations, Leaks, VM Tracker, or the memory-focused instruments that match the symptom. MetricKit’s memory exception diagnostic tells you the field event happened; Instruments tells you whether the editor is retaining full-resolution images, duplicating filter buffers, or failing to release cached thumbnails after returning to the feed.

Compare the run

Run comparison is the part that keeps this from becoming vibes-based optimization.

Record three traces:

  1. Previous good build, same large-library fixture.
  2. Regressed build, same fixture.
  3. Fixed build, same fixture.

Use Instruments 27’s run comparison to put the good and bad runs next to each other. The goal is to identify the behavioral delta, not just the hottest function in one trace. A function can be expensive in both versions and still not be the regression. The regression is the work that appeared, moved to the wrong executor, started blocking the main thread, or grew enough to cross the frame budget.

After the fix, compare the fixed run against both baselines:

  • Against the regressed build, the hitch interval should shrink or disappear.
  • Against the previous good build, memory and CPU should be in the same neighborhood.
  • In the trace, the signpost interval should still cover the intended work.
  • In the Swift concurrency views, expensive work should not saturate the Main Actor.
  • In Top Functions, the old hot path should either be gone or no longer on the critical interaction.

Then ship the fix behind the same MetricKit pipeline. The real verification is the next field report from real devices.

A practical checklist

Here is the shape I would use before adopting this in a production app:

  1. Create a PerformanceReporter that starts MetricManager.metricReports and MetricManager.diagnosticReports at launch.
  2. Upload raw Codable JSON reports with app build, OS, and ingestion schema metadata.
  3. Define two or three StateReporting domains for stable app phases.
  4. Add low-cardinality @ReportableMetadata only where it changes how you would investigate a regression.
  5. Configure MetricKit JSON encoding so state entries are grouped by StateReporting domain.
  6. Add OSSignposter intervals around the workflows you routinely profile.
  7. Build a dashboard that compares metrics by build and state before it tries to be clever.
  8. Reproduce one field regression locally with a fixture.
  9. Use Instruments 27 Top Functions, run comparison, Swift concurrency/executor views, and points of interest to fix and verify.
  10. Watch the next MetricKit reports to confirm the fix held in the field.

Keep the state taxonomy small at first. A feed/photo/editor app can learn a lot from surface, librarySize, editorMode, and one or two feature flags. If you cannot explain how a state changes your debugging plan, do not add it yet.

Beta and source note

This post is based on Apple’s WWDC26 material and the Xcode 27 beta-era API shape available on June 16, 2026. Confirm exact SDK availability, symbol names, and Instruments UI labels against the Xcode 27 seed installed on your machine.

MetricKit gives you the field signal. StateReporting gives that signal enough context to act on. Instruments gives you the trace that proves what changed. The loop works because each tool answers a different question: where is the regression happening, under what app state, why does it happen locally, and did the fix really move the number that users felt?

Official Apple sources: