How to Implement Performance Monitoring in SwiftUI
Use OSSignposter from the os framework to bracket slow work with named signpost intervals, then visualize those intervals in Instruments' os_signpost template without touching a single third-party SDK.
import SwiftUI
import os
struct ContentView: View {
private let signposter = OSSignposter(
subsystem: "com.myapp",
category: "UI"
)
@State private var result = ""
var body: some View {
Button("Run") {
result = signposter.withIntervalSignpost("heavyWork") {
(1...500_000).reduce(0, +).description
}
}
Text(result)
}
}
Full implementation
The pattern below creates a lightweight PerformanceMonitor singleton that wraps both synchronous and async work in signpost intervals and emits point-in-time events. A ViewModifier extension lets any view opt in to lifecycle tracking with a single call. All data surfaces automatically in Instruments under the os_signpost template—no extra configuration required.
import SwiftUI
import os
// MARK: - PerformanceMonitor
final class PerformanceMonitor: Sendable {
static let shared = PerformanceMonitor()
private let signposter = OSSignposter(
subsystem: Bundle.main.bundleIdentifier ?? "com.myapp",
category: "Performance"
)
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.myapp",
category: "Performance"
)
private init() {}
/// Measure a synchronous block and return its result.
func measure<T>(_ name: StaticString, _ work: () throws -> T) rethrows -> T {
try signposter.withIntervalSignpost(name) { try work() }
}
/// Measure an async block and return its result.
func measureAsync<T>(_ name: StaticString, _ work: () async throws -> T) async rethrows -> T {
let state = signposter.beginInterval(name)
defer { signposter.endInterval(name, state) }
return try await work()
}
/// Emit a point-in-time signpost event (e.g. a cache hit).
func event(_ name: StaticString) {
signposter.emitEvent(name)
}
/// Write a structured log message visible in Console.app.
func log(_ message: String, level: OSLogType = .info) {
logger.log(level: level, "\(message, privacy: .public)")
}
}
// MARK: - View modifier
private struct PerformanceTrackedModifier: ViewModifier {
let name: StaticString
private let signposter = OSSignposter(
subsystem: Bundle.main.bundleIdentifier ?? "com.myapp",
category: "ViewLifecycle"
)
func body(content: Content) -> some View {
content
.onAppear { signposter.emitEvent(name) }
}
}
extension View {
/// Emit a signpost event each time this view appears on screen.
func trackPerformance(_ name: StaticString) -> some View {
modifier(PerformanceTrackedModifier(name: name))
}
}
// MARK: - Demo view
@Observable
final class FeedViewModel {
var items: [String] = []
var isLoading = false
func load() async {
isLoading = true
PerformanceMonitor.shared.event("feed.loadStart")
items = await PerformanceMonitor.shared.measureAsync("feed.fetch") {
try? await Task.sleep(for: .milliseconds(400)) // simulate network
return (1...30).map { "Article \($0)" }
}
PerformanceMonitor.shared.log("Loaded \(items.count) articles")
isLoading = false
}
}
struct FeedView: View {
@State private var vm = FeedViewModel()
var body: some View {
NavigationStack {
Group {
if vm.isLoading {
ProgressView("Fetching…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(vm.items, id: \.self) { Text($0) }
}
}
.navigationTitle("Feed")
.toolbar {
Button("Reload") { Task { await vm.load() } }
}
}
.trackPerformance("FeedView.appear")
.task { await vm.load() }
}
}
#Preview {
FeedView()
}
How it works
OSSignposter(subsystem:category:)— creates a signpost handle scoped to your bundle ID and a human-readable category. Instruments groups all recordings by these two strings, so use consistent values across your app.withIntervalSignpost(_:)— the synchronous convenience wrapper inmeasure(_:_:)callsbeginIntervalbefore the closure andendIntervalafter, even if the closure throws. It returns the closure's result so it composes cleanly into call sites.beginInterval/endIntervalpair inmeasureAsync— async work can't use the synchronous wrapper becauseawaitsuspension points fall outside the closure. ThedeferonendIntervalguarantees the interval closes even when the async task is cancelled or throws.emitEvent(_:)in theViewModifier— records a zero-duration pin in the Instruments timeline each time the view appears. This is lightweight enough to leave in production builds; signposts are no-ops when no profiler is attached.Logger.log(level:_:)— structured log messages appear in Console.app and in the Logging instrument alongside your signpost intervals, giving you human-readable context withoutprint()statements.
Variants
Annotate intervals with runtime metadata
Pass a formatted string to beginInterval to attach context—such as a record count or URL—that shows as a tooltip in Instruments. The OSSignpostIntervalState overload accepting a format string enables this.
import os
let signposter = OSSignposter(subsystem: "com.myapp", category: "Network")
func fetch(url: URL) async throws -> Data {
// Attach the URL as metadata visible in Instruments
let state = signposter.beginInterval(
"network.fetch",
"\(url.lastPathComponent, privacy: .public)"
)
defer { signposter.endInterval("network.fetch", state) }
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
Conditional signposting in release builds
Signposts are cheap when Instruments is not recording, but you can gate on signposter.isEnabled for hot paths that construct expensive format strings before passing them in. Example: if signposter.isEnabled { signposter.emitEvent("cache.hit", "\(key)") }. This avoids even the interpolation cost when no profiler is attached.
Common pitfalls
- iOS 15 minimum, not 17:
OSSignpostershipped in iOS 15 / macOS 12. If you set a deployment target below iOS 15, you'll need#available(iOS 15, *)guards or fall back to the olderos_signpost()C API. On iOS 17+ targets this is never a concern. - StaticString requirement on interval names: The
nameparameter ofbeginIntervalmust be a StaticString (a string literal), not aStringvariable. If you try to pass a dynamic string the compiler will error. Use the format-string overload (beginInterval(_:_:)) to attach runtime data as metadata instead. - Forgetting to call
endInterval: An unclosed interval shows as a runaway bar in Instruments and inflates timing data. Always pairbeginIntervalwithendIntervalinside adeferblock, especially around code paths that can throw or return early. - Accessibility: Signposting is invisible to VoiceOver—it doesn't replace accessibility labels. If a measured operation drives visible UI, ensure loading states still announce themselves via
.accessibilityLabelandaccessibilityAddTraits(.updatesFrequently).
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement performance monitoring in SwiftUI for iOS 17+. Use OSSignposter and Logger from the os framework. Create a PerformanceMonitor singleton with measure(_:_:) and measureAsync(_:_:) helpers. Add a ViewModifier that emits a signpost event on appear. Make all views accessible (VoiceOver labels, loading state announcements). Add a #Preview with realistic sample data showing a list that loads async data with a measured fetch.
In the Soarias Build phase, paste this prompt into the feature scaffold step so Claude Code wires the PerformanceMonitor into your existing @Observable view-models before generating screens—giving you Instruments visibility from day one rather than retrofitting it before submission.
Related
FAQ
Does this work on iOS 16?
OSSignposter is available from iOS 15, so yes—all the code on this page compiles and runs on iOS 15 and 16 without changes. The @Observable macro used in the demo view requires iOS 17+; swap it for ObservableObject + @Published if you target iOS 15–16.
Will signpost overhead affect my app in production?
No. When Instruments is not actively recording, the os_signpost subsystem short-circuits in the kernel driver and adds nanosecond-level overhead per call. Apple explicitly designed signposts to be always-on safe. If you have an extremely hot loop (millions of calls per second) check signposter.isEnabled before constructing format-string arguments, but for typical UI and network code leave them unconditional.
What's the UIKit / Objective-C equivalent?
UIKit apps traditionally used the C-level os_signpost() function directly with #import <os/signpost.h> and an os_log_t handle. OSSignposter is the Swift-native wrapper introduced in iOS 15 that replaces those boilerplate-heavy calls. Instruments treats both identically, so you can mix them in hybrid Swift/ObjC codebases.
Last reviewed: 2026-05-12 by the Soarias team.