Quick answer: Swift iOS games crash through fatal errors, signals, and Mach exceptions, and a release crash is just addresses until you symbolicate it. Catch faults with signal handlers plus a Mach exception port for the ones signals miss, archive the dSYM for each build to symbolicate, and report device model, iOS version, and Metal context. The App Store organizer helps, but your own pipeline gives you grouping and player context.

Shipping a game on iOS means shipping into a controlled but unforgiving environment. The hardware is consistent compared to Android, but a crash still terminates the app instantly, and Apple's privacy rules and the closed runtime shape how you capture one. A Swift fatal error, a Metal validation failure, or a memory fault all end the process, and the crash report you get is a list of addresses until you match it against the right symbols. This post covers signal and Mach exception handling, symbolicating with dSYM files, capturing device and Metal context, and how that fits with Apple's own crash tools.

How iOS surfaces a crash

A crash on iOS arrives through a few distinct channels. Swift's own fatalError, a failed force unwrap, or a precondition failure trap deliberately and end the process. Lower level faults, a bad memory access or an illegal instruction, are delivered first as Mach exceptions by the kernel and then, if unhandled, translated into POSIX signals like SIGSEGV and SIGABRT. Understanding both layers matters because a handler installed only at the signal level can miss exceptions that a Mach exception handler would catch, and vice versa, so robust capture usually listens at both.

The runtime constraints are real. After a crash you are running in a damaged process, so the handler must be async signal safe: no allocation, no Objective C or Swift runtime calls, just writing the captured state to a pre opened file. Apple also discourages and in some contexts restricts third party exception handling, and a debugger attached during development will intercept signals before your handler sees them. So you build the capture carefully, test it on device without the debugger, and keep the in handler work to the bare minimum needed to record the fault.

Signals and Mach exception ports

The signal approach installs sigaction handlers for the fatal signals on an alternate stack, captures the thread state and a backtrace, and writes a minimal report. It is portable and simple, but signals are a translation of the underlying Mach exception and can lose information or arrive after the original fault context is gone. For the highest fidelity, a Mach exception handler registers an exception port and receives the fault directly from the kernel with full thread state, which is how the most thorough crash reporters on Apple platforms work.

In practice most teams do not write the Mach port machinery by hand. The hard parts, suspending the crashed thread, reading its registers, and writing a report without touching the broken runtime, are exactly where a mature reporting library earns its place. What you do need to understand is the division of labor: NSException for Objective C exceptions if any of your code or dependencies still use them, signals for the POSIX view, and the Mach port for the authoritative kernel level capture. Cover the layers your game actually exercises rather than assuming one handler catches everything.

Symbolicating with dSYM

An iOS crash report records addresses relative to each loaded image. To turn those into function names and source lines you need the dSYM, the debug symbol bundle Xcode produces for each build, matched by the build's UUID. Symbolication is the process of mapping addresses to symbols using the dSYM, done by Xcode's organizer, the atos and symbolicatecrash tools, or a backend that ingests the dSYM. As with every platform, the rule is one dSYM per build, archived when you build, never regenerated, because only the exact dSYM for that binary will symbolicate its crashes correctly.

Bitcode and App Store recompilation historically complicated this, since Apple could rebuild your binary and the dSYM you kept might not match the shipped one; in that case you download the App Store generated dSYM from the developer portal. Whatever your build path, verify that the dSYM UUID matches the binary that players actually run. A mismatched dSYM symbolicates to plausible but wrong functions, which is worse than no symbolication because it sends you chasing the wrong code. Confirm the match as part of your release process so your symbolicated traces are trustworthy.

Device and Metal context

iOS hardware is consistent enough that context is more about state than fragmentation, but it still matters. Capture the device model, the iOS version, the available memory, and the thermal state, since iOS aggressively terminates apps under memory pressure and a jetsam kill looks different from a code crash. Memory terminations are common in graphically heavy games, and distinguishing them from genuine crashes changes what you fix: a jetsam means trim your memory footprint, not hunt a bug. Record your build version and the current scene so each report has game level context.

Graphics crashes on iOS run through Metal, and Metal context is the axis they vary along. A command buffer error, a validation failure, or a GPU hang behaves differently across the chip generations Apple ships, so capturing the device's GPU family and whether Metal validation was active helps you place a render crash. Because the device range is narrower than Android, a crash that hits one specific model often points at a real per device issue worth a targeted fix. The combination of device, memory, thermal, and Metal context tells you which kind of failure you are looking at before you read a line of the stack.

Setting it up with Bugnet

Bugnet stores your symbolicated iOS crashes with their device context so each one arrives as a named stack plus the model, iOS version, memory state, and Metal details rather than a raw address dump. You supply the dSYM per build and the addresses resolve to your functions and lines. Alongside automatic crashes, the in game report button lets a player describe a problem and attach the current game state, which captures the non fatal frustrations, a stuck level or a visual glitch, that never trigger a crash handler but still drive players away.

Even on consistent hardware, the same crash repeats across thousands of installs, and occurrence grouping folds those into one issue with a count so you triage once. The count separates a rare edge case from a launch blocking failure, and filtering by device model or iOS version reveals whether a crash is universal or tied to one configuration, which is exactly how you tell a memory termination on older devices from a logic bug everywhere. Custom fields let you tag the scene or feature so the pattern behind a cluster of crashes is visible across reports rather than buried in each one.

Working with Apple's tools

Apple gives you the Xcode organizer and crash reports collected from users who opt into sharing diagnostics, and the MetricKit framework delivers crash and hang diagnostics on device that your app can read and forward. These are valuable and you should use them, but they have gaps: organizer crashes depend on user opt in so coverage is partial, and neither carries your own player context or your custom game state. Your own pipeline complements them by capturing every crash, attaching the context you choose, and grouping across the whole player base.

The practical setup runs both: rely on a tested capture path for completeness and grouping, and use MetricKit and the organizer as a cross check and for the system level metrics they provide that you cannot gather yourself, like detailed hang and energy diagnostics. Test the whole thing on a real device, off the debugger, across a couple of OS versions, and confirm crashes symbolicate correctly. A Swift game that captures, symbolicates, contextualizes, and groups its crashes ships into the App Store knowing it will see and rank its failures rather than guess at them.

On iOS, match the dSYM by UUID and capture memory state. A mismatched symbol file or a missed jetsam kill sends you debugging the wrong thing.