Answer
Object lifecycle
When an object no longer has any strong references, Swift destroys it. deinit runs as part of that final release. The important rule is simple: deinit runs in the execution context that removes the last strong reference. It is not tied to where the object was created, and it is not automatically on the main thread.
1. Simple case: drop the last reference synchronously
In the simplest case, the current thread drops the last strong reference. When that happens, deinit runs immediately before execution continues past the assignment.
import Foundation
final class Test {
deinit {
print("Is main thread:", Thread.isMainThread)
}
}
var test: Test? = Test()
test = nilRun compiles and executes on the server; output shows below.
2. Same pattern, last nil on a background queue
Now change only one thing: move the final test = nil onto a global queue. That is enough to change where deinit executes. The destructor still follows the last release site, not the place where test was first created.
In a playground or app with a run loop you can observe this directly. In a tiny command-line tool, the process may exit before the async block runs, which is why this block is not the in-browser Run snippet.
import Foundation
final class Test {
deinit {
print("Thread:", Thread.current)
}
}
var test: Test? = Test()
DispatchQueue.global().async {
test = nil // ⚠️ Main actor-isolated var 'obj' can not be mutated from a nonisolated context
}
// Example line you might see (background worker thread):
// Thread: <NSThread: 0xc4d4c4f00>{number = 2, name = (null)}3. What you should say in an interview
The short interview answer is: deinit has no fixed thread. It runs wherever the final strong reference is released. That is why “created on the main thread” does not imply “destroyed on the main thread.”
4. Why that bites: UIKit / AppKit
This matters because some APIs are thread-affine. UIKit and AppKit expect mutation on the main thread. If deinit can run on a background queue, then plain deinit is a risky place for view cleanup unless you have a stronger guarantee.
final class Test {
deinit {
// deinit is not guaranteed to be on the main thread
// someView.removeFromSuperview() // dangerous
}
}5. @MainActor does not make plain deinit main-actor-isolated
@MainActor changes the isolation of ordinary instance methods, but it does not make a plain deinit actor-isolated. A normal deinit is still checked as a synchronous nonisolated context. That is why Swift rejects direct calls to MainActor-isolated methods from inside it.
Not runnable here — this is a teaching snippet, not a complete compilable program. The important line is the cleanUp() call, which Swift rejects because deinit is nonisolated:
import Foundation
@MainActor
final class Test {
deinit {
print("Deinit on main thread:", Thread.isMainThread)
cleanUp() // ❌ Compile-time error!
}
func cleanUp() {}
}
var test: Test? = Test()
DispatchQueue.global().async {
test = nil
}
// Typical observation while the plain deinit body runs:
// Deinit on main thread: false6. Swift 6.2: isolated deinit
SE-0371: Isolated synchronous deinit is implemented in Swift 6.2. It lets global-actor-isolated classes and actors opt into isolated deinit, so the runtime can execute the deinit body on the correct actor executor. In practice, that means MainActor-only cleanup such as cleanUp() becomes legal again.
The tradeoff is that teardown may now be scheduled onto that executor instead of happening inline with the final release. That is safer for actor-isolated state, but less precise if you are trying to reclaim scarce resources immediately.
Not runnable here — this needs Swift 6.2+ and a proper actor-aware setup. The snippet is intentionally trimmed to highlight isolated deinit itself:
import Foundation
@MainActor
final class Test {
isolated deinit {
print("Deinit on main thread:", Thread.isMainThread)
cleanUp() // ✅ Now it works!
}
func cleanUp() {}
}
var test: Test? = Test()
DispatchQueue.global().async {
test = nil
}For file descriptors, sockets, database handles, or other resources where when cleanup happens really matters, teams often still prefer explicit close() or with-style APIs instead of relying on deinit timing.
Interview angle
Walk the answer in this order: last strong reference goes away → deinit runs there → there is no guaranteed thread → UI cleanup in plain deinit is risky → @MainActor does not make a normal deinit isolated → isolated deinit is the modern fix when you need actor-aligned cleanup and can accept scheduled teardown (SE-0371).