Key Takeaways
- Intermittent production crashes often need controlled fault isolation when logs and local testing cannot identify the failing path.
- A lower overall crash rate does not prove that the root cause is fixed. Unrelated changes can simply change the timing of a concurrency defect.
- One device that reproduces a failure probabilistically can turn speculative debugging into testable engineering work.
- HTML import through
NSAttributedStringhas a main-thread requirement that is easy to miss when the call sits inside asynchronous screen preparation. - A shared main-actor abstraction is a stronger safeguard than asking every call site to remember a framework threading rule.
Some production crashes announce themselves with a clean stack trace. This one did not. It appeared in a large-scale consumer iOS application, affected only some executions, and resisted every local reproduction attempt. The final code change was small, but reaching it required turning a vague SIGBUS report into a controlled set of experiments. This article walks through the investigation and the threading contract behind HTML import in NSAttributedString.
The Crash That Refused to Reproduce
Most production crashes I investigate start with a familiar routine. I read the crash report, look for the stack frame closest to the failure, try to reproduce the path, and then work backward toward the root cause. This incident did not fit that pattern.
The crash showed up in a large-scale consumer iOS application. It affected only a subset of executions and appeared at unpredictable times. Exact production metrics are confidential, but the incident was serious enough to require a focused investigation and an expedited hotfix.
Neither developers nor QA engineers could reproduce it consistently. We could repeat the customer flow without seeing anything unusual. The crash report included a SIGBUS signal and Foundation frames related to attributed string processing, but it did not point to a clear line in our Swift code.
SIGBUS is a low-level invalid memory access signal, not a diagnosis. It can come from several conditions, so it would have been wrong to assume from the signal alone that a specific variable had been released or reused. The evidence narrowed the search area, but it did not establish the cause.
That left a wide search space. I considered object lifetime, SwiftUI view model ownership, shared mutable state, unsynchronized work, and Foundation behavior. Management needed an answer quickly, and I initially had about a week to investigate and prepare a fix.
Starting With the Expected Suspects
I started with the checks that usually help with this kind of failure. I enabled Zombie Objects, added exception and symbolic breakpoints, reviewed ownership around the affected screens, and inspected the asynchronous paths that prepared their data.
I also made defensive improvements while I was in that part of the codebase. I tightened observable object ownership, reviewed whether several reference types really needed identity semantics, clarified synchronization, and made UI-related concurrency more explicit with async and await and MainActor isolation.
Those changes improved the code. The first hotfix also produced a noticeable reduction in the overall crash rate. But the original crash signature continued to appear in production, and customers were still reporting the behavior tied to the incident.
Why the First Hotfix Misled Us
The initial improvement looked encouraging because the aggregate crash rate moved in the right direction. It did not prove causation. Changes to lifecycle handling and synchronization can affect task scheduling, object retention, and timing. A timing-sensitive failure can become less frequent even when the underlying contract violation is still present.
That distinction became central to the investigation. We had improved the application, but we had not shown that we had removed the defect. The target crash still existed, and the remaining investigation window had narrowed to only a few days.
A Reproducible Device Changed the Investigation
The turning point was not another breakpoint or a more detailed stack trace. A QA engineer who also used the production application found that she could trigger the crash by repeating the affected flow. It was still intermittent and sometimes took four or five attempts, but that was enough.
For the first time, I had a practical test oracle. If a new build still crashed on that device, the change had not addressed the problem. If repeated attempts stopped producing the failure, the change deserved deeper validation. The device changed the work from production forensics into an experiment.
Turning Debugging Into an Experiment
My first experiments still focused on the screens closest to the visible failure. I cleaned up their code, adjusted ownership, reviewed asynchronous work, generated an IPA, and sent it to the QA engineer.
After several attempts, that build crashed again. The result was negative, but it was useful. Until then, I had been making reasonable changes without a reliable way to know whether they mattered. A reproducible failure made every rejected hypothesis informative.
The loop became simple: change one area, build an IPA, install it on the affected device, repeat the customer flow, and record whether the crash returned. Across the investigation, I shared roughly nine or ten experimental builds.
I also explored potential solutions through developer communities, including Stack Overflow, the Apple Developer Forums, and LinkedIn iOS development groups. However, the application's source code and internal debugging artifacts were confidential under company policies, so I could only discuss the problem at a general technical level without sharing proprietary information. While these communities offered opportunities to explore possible causes, they could not directly inspect or conclusively diagnose the issue. I therefore continued investigating through controlled experiments within our development environment.
Systematic Fault Isolation
I changed the question. Instead of asking which code looked suspicious, I asked which code had to remain for the crash to survive. I removed one view or behavior at a time, generated a new build, and tested the same flow on the affected device.
After six or seven elimination builds, the failure surface narrowed to one file. Nothing in that file looked obviously unsafe, so I repeated the process inside it. Several more experiments reduced the problem to one operation.
The line converted HTML data into an attributed string:
let attributedString = try NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html
],
documentAttributes: nil
)
The code had no force unwrap, unsafe pointer, manual memory management, or obvious shared state. At the call site, it looked like a normal Foundation conversion. That ordinary appearance had kept it low on the original list of suspects.
The Disclosure Code Path
The affected flow involved two important views of the application. Each view displayed a static disclosure section near the bottom, containing legal and informational text formatted as HTML. Although the content itself was static, it included interactive elements such as hyperlinks and phone numbers that users could tap to open external web views or initiate phone calls.
The application received this disclosure content as HTML and converted it into an attributed string before rendering it on the screen.
Because the disclosure section was relatively small and worked correctly during most executions, it initially appeared unlikely to be responsible for the crash. Attention naturally focused on the larger architectural components and asynchronous screen-loading operations.
The critical issue was that HTML-to-attributed-string conversion could be invoked from an asynchronous screen-preparation path without an explicit guarantee of main-thread execution. This mattered because Foundation's HTML importer has a threading constraint that was not enforced at the call site.
The Hidden Threading Contract
Apple documents an important constraint for HTML import through NSAttributedString: when the options specify HTML as the document type, the importer should not be called from a background thread. Apple says the method may try to synchronize with the main thread, fail, and time out. The broader attributed string documentation also notes that WebKit handles HTML conversion, which matters because this path is not just a pure data transformation.
Our problem was therefore not that the screens displayed attributed text. The unsafe condition came from creating HTML-backed attributed strings from an execution context where the main thread was not guaranteed.
The API did not make that requirement obvious at the call site. It accepted a Data value and returned an attributed string, so it looked like a straightforward transformation. The implementation carried a platform constraint that the surrounding asynchronous architecture had to enforce.
Why the Failure Appeared Random
Timing-dependent defects can survive many successful executions. Two users may receive equivalent HTML and call the same Swift function, yet reach the importer under different scheduling conditions. One execution may occur safely while another reaches the same code from background work during screen preparation.
This also explained why the defensive hotfix reduced the observed frequency without removing the signature. Lifecycle and synchronization changes changed timing. They did not eliminate the unsupported execution context.
The practical lesson was clear: changing the probability of a concurrency failure is not the same as removing its cause. For this incident to be considered resolved, the targeted crash signature and the reproducing flow both had to confirm the fix.
Turning the Fix Into an Application Wide Guarantee
The immediate repair was to perform the HTML conversion on the main actor. Fixing only the original call site would have addressed the visible incident, but it would not have prevented the same mistake elsewhere in a large codebase.
I searched for every place where the application converted HTML into an attributed string. Then I introduced a shared helper that made the execution requirement part of the API instead of an instruction each developer had to remember. The following code is a simplified and anonymized representation of that design:
enum HTMLAttributedStringRenderer {
@MainActor
static func render(from data: Data) throws -> NSAttributedString {
try NSMutableAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html
],
documentAttributes: nil
)
}
}
The UI-facing caller also stayed isolated to the main actor:
@MainActor
func updateDisclosure(from data: Data) throws {
disclosureText = try HTMLAttributedStringRenderer.render(from: data)
}
Existing call sites were routed through this helper. The design centralized the Foundation dependency, reduced duplication, and created one place to document and test the threading contract. More importantly, it turned the requirement from a convention into a code-level boundary.
This did not mean all screen preparation moved to the main actor. Networking, decoding, and other suitable work could remain asynchronous. Only the operation with the framework constraint, along with the UI state that consumed its result, needed explicit main-actor isolation.
Validating the Complete Fix
I sent the corrected build to the QA engineer and asked her to repeat the flow on the device that had exposed every earlier attempt. The failure no longer appeared. I then asked for additional testing across both affected views and nearby behavior before promoting the change to the hotfix branch.
After release, production monitoring showed a dramatic reduction in the targeted crash. Exact figures cannot be disclosed, but this outcome differed from the first hotfix in the way that mattered: the specific production signature no longer appeared at a meaningful rate.
The investigation ended with three validations rather than one. The known device stopped reproducing the issue, every related call site used the shared helper, and production monitoring confirmed the expected change.
What the Incident Changed
The most useful lesson was broader than one Foundation API. When evidence is incomplete, a debugging process has to create new evidence. A device that reproduced the crash intermittently was more valuable than a long list of plausible code changes because it allowed each hypothesis to fail clearly.
The incident also changed how I evaluate framework abstractions. A Swift initializer may look safe while hiding behavior inherited from Objective-C, Foundation, UIKit, WebKit, run loops, or older platform design. Swift concurrency makes execution context easier to express, but it does not automatically make every underlying API safe for concurrent use.
Finally, a successful repair should make the same defect harder to reintroduce. The shared helper mattered because it protected future call sites, not only the two views involved in the incident.
A Practical Investigation Checklist
- Treat a signal such as SIGBUS as evidence about the failure category, not as proof of one memory-management cause.
- Track the specific crash signature separately from the overall crash rate so unrelated improvements do not create false confidence.
- When one device can reproduce the issue intermittently, preserve that environment and use it as a controlled test target.
- Change or remove one area at a time, and keep a record of which build accepted or rejected each hypothesis.
- After identifying a framework contract, search the full codebase and enforce the rule through a shared abstraction when possible.
- Validate the fix through targeted reproduction, regression testing, and post-release production monitoring.
Conclusion
The final code change was small. The hard part was reaching it through incomplete crash information, an unreproducible flow, misleading early improvements, time pressure, and proprietary artifacts that could not be shared outside the organization.
Once a single device reproduced the failure, the investigation stopped depending on intuition. Repeated builds narrowed the problem from two screens to one file and then to one ordinary-looking Foundation call. The root cause was HTML import running without the main-thread guarantee required by its implementation.
When a stack trace cannot identify the bug, the next step is to design an experiment that forces the code to reveal it. In this incident, systematic fault isolation provided the evidence that ordinary debugging could not.