During test teardown we hit a repeatable 'pointer being freed was not allocated' crash while deallocating a @MainActor ObservableObject, and this post explains why we treated it as a Swift runtime edge case instead of rewriting our architecture.
This is Part 3 in our Advanced SwiftUI: Lessons From Mistakes series:
-
Part 1 – ObservedOrStateObject
walks through the property-wrapper bug that kicked off this learning arc. -
Part 2 – SwiftUI Alerts: When Multiple Alerts Collide
covers how conflicting alerts prevented save failures from reaching users.
Prerequisites
You should be comfortable with:
- Basic Swift classes and properties.
- What an
ObservableObjectis and@Publishedin SwiftUI. - The idea of the main thread / main actor.
You do not need to be a concurrency expert.
Key concepts
- ObservableObject: A reference type that conforms to SwiftUI’s
ObservableObjectprotocol. You typically put your view model logic and mutable screen state on anObservableObject, then expose it to views with@StateObjector@ObservedObject. When its@Publishedproperties change, it sendsobjectWillChange, and any SwiftUI views observing it automatically re-render to reflect the new values. - @Published: A property wrapper used on properties inside an
ObservableObject. It automatically creates a Combine publisher for that property and wires it intoobjectWillChange, so any change to the property notifies SwiftUI (and any other subscribers) that the object’s state has updated. You can think of it as “make this property observable by the UI and other listeners.” - @MainActor / main actor: The main, UI-bound actor in Swift’s concurrency model. Code annotated with
@MainActoris isolated to this actor, which conceptually means it runs on the main thread and can safely touch UI state. Calling a@MainActormethod or accessing a@MainActorproperty from background work requires anawaithop to the main actor so Swift can serialize access and keep UI updates safe.
Deeper dive: ObservableObject, @Published, and @MainActor
Think of an ObservableObject as the place where your screen’s long-lived state and business logic live. A view holds onto an instance with @StateObject when it creates the object itself, or @ObservedObject when something else hands the object in. SwiftUI listens to objectWillChange on that object so that when the model changes, the view hierarchy knows to re-render.
@Published marks individual properties on an ObservableObject as observable. When a @Published property changes, the wrapper publishes an event and forwards it through objectWillChange. In practice: you mutate a @Published property, and any SwiftUI view observing that object will automatically update to show the new value, with no manual “notify the view” calls required.
The main actor (@MainActor) is Swift’s way of saying “this code belongs on the UI thread.” If you annotate a type with @MainActor, all of its instance properties and methods are treated as main‑actor‑isolated by default, as if you had written @MainActor on each one. Code running on a background actor or task must await a hop to the main actor before calling those methods or touching those properties. Under the hood, the main actor is an abstraction, but in an iOS or SwiftUI app you can treat “main actor” and “main thread” as effectively the same concept for UI work.
What @MainActor on a type actually means
When you write:
@MainActor
final class MyViewModel {
var count: Int = 0
func increment() {
count += 1
}
}Swift treats it as if you had written @MainActor on every instance member:
@MainActor func increment() { /* ... */ }
@MainActor var count: Int { get set }So, in practice:
- Yes: Accessing
countor callingincrement()from outside must be done on the main actor. - Callers in another actor or background task must
awaitthe hop, for example:await viewModel.increment(). - You can opt out for specific members with
nonisolated(ornonisolated(unsafe)), but by default everything is main‑actor‑isolated.
Actor vs thread
The main actor is an abstraction in Swift’s concurrency model. In an iOS / SwiftUI app it is typically executed on the main thread, so for UI code you can safely think of “main actor” ≈ “main thread.” The guarantee you get is: from Swift’s point of view, all uses of a @MainActor type must respect main‑actor isolation, and the compiler helps enforce that.
The deinit caveat (relevant here)
deinit is special: it is logically main‑actor‑isolated on a @MainActor type, but Swift doesn’t let you run async code or do explicit actor hops there. In practice, teardown paths can involve runtime internals where you can’t rely on “this definitely runs on the main actor like a normal method body,” which is why using @MainActor deinit as a cleanup hook is fragile and contributed to the weird behavior in the crash described below.
For normal methods and property access, though, the mental model is safe:
@MainActoron the class means its methods and properties should be used from the main thread (main actor) unless explicitly marked otherwise.
TL;DR
During test teardown we hit a crash like this:
malloc: *** error for object 0x262c5a6f0: pointer being freed was not allocatedThe crash happened while DependencyContainer and AuthSessionManager were being deallocated. We tried several reasonable fixes (changing @MainActor annotations, cleaning up in deinit, etc.), but the root cause turned out to be deep inside the Swift runtime / @Published teardown, not in our own logic.
Outcome:
- We restored a simple, predictable implementation.
- We rely on the runtime behaving correctly.
- All tests now pass cleanly.
This document explains what happened and why we decided not to keep layering hacks on top of the problem.
What was crashing?
Two types were involved:
AuthSessionManager– owns the current auth session and exposes it via@Published.DependencyContainer– app-wide DI container that owns a singleAuthSessionManagerinstance.
In tests, we build a DependencyContainer (often via makeTestingContainer) and tear it down between tests.
The crash stack trace consistently looked like this (simplified):
swift::TaskLocal::StopLookupScope::~StopLookupScope()
swift_task_deinitOnExecutorImpl
swift_task_deinitOnExecutorMainActorBackDeploy
AuthSessionManager.__deallocating_deinit
DependencyContainer.deinit
malloc: *** error for object ... pointer being freed was not allocatedKey point: the crash happened during deallocation, not while we were actively calling our own methods.
Why was this so confusing?
At first glance it looked like a normal memory bug we should be able to fix:
- We saw
AuthSessionManager.__deallocating_deinitin the stack. - We had just changed
DependencyContainerandAuthSessionManagerto use@MainActor. - The error message mentions a pointer being freed twice.
- The crash log always showed the same heap address, for example:
malloc: *** error for object 0x262c5a710: pointer being freed was not allocated.
This suggested things like:
- Maybe we were clearing
currentSessionindeinitfrom the wrong thread. - Maybe
DependencyContainerwas being destroyed off the main actor.
Those are problems we can usually fix by adjusting ownership or actor isolation.
However, every time we tried a reasonable fix, the crash either persisted or moved slightly but did not disappear.
What we tried (and why it seemed sensible)
1. Clearing state in AuthSessionManager.deinit
We first added:
@MainActor
deinit {
currentSession = nil
}Why this seemed reasonable:
- We wanted to make sure all UI-related state was cleared on the main actor.
currentSessionis@Published, and we were already treating it as main-thread-only state.
What happened:
- The compiler complained about mutating main-actor-isolated state in
deinit. - Even when we forced it, we still saw the malloc crash during teardown.
Lesson: deinit is special. You cannot rely on it running on the main actor, even if the type is @MainActor.
2. Removing the mutation from deinit
Next we tried to do nothing in deinit:
deinit {
// no-op
}Why this seemed reasonable:
- If touching
currentSessionindeinitwas unsafe, maybe simply letting the object die would be safer. @Publishedshould clean itself up automatically.
What happened:
- The crash still appeared in
AuthSessionManager.__deallocating_deinitdeep in the Swift runtime. - So even without our custom cleanup, deallocation could still hit the bug.
Important: An empty deinit is a poor workaround here. It can make the crash rarer or harder to reproduce, but it only masks the underlying teardown bug instead of fixing it.
Lesson: the problem was not just our deinit body.
3. Moving @MainActor around
We experimented with:
- Marking
DependencyContaineras@MainActor. - Removing
@MainActorfromAuthSessionManager. - Marking only certain methods as
@MainActor.
Why this seemed reasonable:
- The crash involved
swift_task_deinitOnExecutorMainActorBackDeploy, which suggested actor-isolated teardown. - Ensuring container + manager live on the same actor should, in theory, make deallocation deterministic.
What happened:
- The exact stack trace changed slightly, but the malloc error stayed.
- We were clearly fighting against how Swift tears down actor-isolated objects, not fixing our own logic.
Lesson: actor annotations can change when deinit happens, but they don’t rewrite how @Published and tasks are destroyed internally.
4. Replacing @Published with a manual CurrentValueSubject
We also tried to avoid @Published entirely:
final class AuthSessionManager: AuthSessionManaging {
private let currentSessionSubject = CurrentValueSubject<AuthSession?, Never>(nil)
private(set) var currentSession: AuthSession? {
didSet { currentSessionSubject.send(currentSession) }
}
}Why this seemed reasonable:
- Maybe the bug lived specifically inside
@Published+ObservableObjectdeallocation. - A manual Combine subject might avoid that path.
What happened:
- The app and tests expect
AuthSessionManagerto be anObservableObjectused with@StateObject/@ObservedObject. - Removing
ObservableObjectbroke existing view code in multiple places.
We could have refactored everything to use a different pattern, but that would be:
- A large change.
- Easy to get wrong.
- Not clearly safer given the underlying runtime behavior.
So… what is actually going on?
Here’s the most honest explanation we can give:
AuthSessionManageris anObservableObjectwith a@Publishedproperty.- It’s owned by
DependencyContainer. - During test teardown, the test framework tears down the app and container.
- While everything is deallocating, the Swift runtime cleans up:
- Tasks associated with the main actor.
- The
@Publishedstorage.
- Somewhere inside that internal cleanup, the runtime tries to free memory that has already been freed.
Crucially:
- The crash happens after our own code has already stopped running.
- The stack shows mostly Swift runtime and concurrency internals.
- Our changes to
deinitand actor isolation shifted the crash around, but did not remove it.
This strongly suggests a runtime-level bug or edge case, not a simple misuse in our source code.
Why we decided not to keep fighting it
At some point, fixing this locally would require one of these:
- Re-architecting the entire auth/session stack to avoid
ObservableObjectand@Published. - Adding fragile workarounds that depend on undocumented runtime behavior.
- Carrying complex, hard-to-explain code “just” to avoid a crash in a very specific teardown scenario.
Given that:
- The crash only showed up during aggressive test teardown.
- We had a simpler setup that behaved well in normal app use.
- We can rerun tests easily and watch for regressions.
…we chose a pragmatic compromise:
- Keep the code simple and idiomatic.
- Avoid doing anything fancy in
deinit. - Let the runtime handle cleanup.
If Apple fixes this in a future Swift / iOS release, we instantly benefit without carrying weird hacks.
What we learned
1. Not every crash is your fault
Sometimes, especially around concurrency, you will run into behavior that is caused (or at least heavily influenced) by the runtime or standard library. It’s okay to say:
We understand our code’s behavior, and the remaining issue is likely in the underlying framework.
This doesn’t mean “give up quickly”, but it does mean you don’t have to rewrite your entire app to work around a bug you don’t own.
2. Be careful with deinit
deinit runs when the system is already tearing things down. In async / actor-based code:
- You can’t reliably hop to the main actor here.
- You shouldn’t start new async work.
- You should avoid doing anything that might trigger more deallocation or complex side effects.
Prefer explicit cleanup methods that you call from a known, safe context (for example, a logout handler on the main actor).
3. Prefer simple, understandable code over clever hacks
We tried several clever ideas (main-actor deinit, manual subjects, etc.). They all made the code harder to reason about without fully solving the problem.
In the end, the simplest version of AuthSessionManager is also the most maintainable:
- It is
ObservableObjectwith an@Publishedsession. - It exposes clear methods:
loadSession,saveSession,clearSession. - It doesn’t do tricky work in
deinit.
4. Document hard problems clearly
This document exists so that:
- Future you doesn’t waste days chasing the same crash.
- New teammates understand the trade-offs we made.
- We have a place to reference if Swift/Apple fix the underlying runtime behavior and we want to revisit the design.
FAQ
- What caused the SwiftUI @Published 'pointer being freed was not allocated' crash?
- The crash happened while AuthSessionManager and DependencyContainer, both using @MainActor and @Published, were being deallocated during test teardown. The stack trace pointed into Swift concurrency and @Published teardown internals rather than into business logic, so the issue was treated as a Swift runtime edge case.
- Why did you treat the @Published teardown crash as a runtime edge case instead of rewriting the architecture?
- Multiple reasonable refactorings still reproduced the same deallocation crash in the same runtime paths, and the app behaved correctly in normal usage. Because the bug only appeared under test teardown, it was safer to keep a simple architecture and rely on the runtime being fixed than to ship invasive workarounds that could introduce new problems.
Welcome to The infinite monkey theorem
Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

