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:

Prerequisites

You should be comfortable with:

  • Basic Swift classes and properties.
  • What an ObservableObject is and @Published in 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 ObservableObject protocol. You typically put your view model logic and mutable screen state on an ObservableObject, then expose it to views with @StateObject or @ObservedObject. When its @Published properties change, it sends objectWillChange, 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 into objectWillChange, 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 @MainActor is isolated to this actor, which conceptually means it runs on the main thread and can safely touch UI state. Calling a @MainActor method or accessing a @MainActor property from background work requires an await hop 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 count or calling increment() from outside must be done on the main actor.
  • Callers in another actor or background task must await the hop, for example: await viewModel.increment().
  • You can opt out for specific members with nonisolated (or nonisolated(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:

@MainActor on 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 allocated

The 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 single AuthSessionManager instance.

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 allocated

Key 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_deinit in the stack.
  • We had just changed DependencyContainer and AuthSessionManager to 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 currentSession in deinit from the wrong thread.
  • Maybe DependencyContainer was 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.
  • currentSession is @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 currentSession in deinit was unsafe, maybe simply letting the object die would be safer.
  • @Published should clean itself up automatically.

What happened:

  • The crash still appeared in AuthSessionManager.__deallocating_deinit deep 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 DependencyContainer as @MainActor.
  • Removing @MainActor from AuthSessionManager.
  • 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 + ObservableObject deallocation.
  • A manual Combine subject might avoid that path.

What happened:

  • The app and tests expect AuthSessionManager to be an ObservableObject used with @StateObject / @ObservedObject.
  • Removing ObservableObject broke 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:

  1. AuthSessionManager is an ObservableObject with a @Published property.
  2. It’s owned by DependencyContainer.
  3. During test teardown, the test framework tears down the app and container.
  4. While everything is deallocating, the Swift runtime cleans up:
    • Tasks associated with the main actor.
    • The @Published storage.
  5. 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 deinit and 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 ObservableObject and @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 ObservableObject with an @Published session.
  • 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.

Subscribe to The infinite monkey theorem

We fling fresh posts—no banana peels attached—straight to your inbox.