Isolate or Share Safely — Dart and Swift's Two Philosophies of Concurrency
For years in Flutter I never wrote a mutex. My first day in Swift I met DispatchQueue.main.async, then actor and Sendable. It turns out both languages attack the same enemy — the data race — with opposite philosophies: Dart forbids sharing, Swift makes sharing safe. Here's the thread map for anyone crossing from Flutter to iOS.
One enemy, two languages
The bane of concurrent programming is the data race: two threads touching the same memory at the same time, at least one of them writing. The result is corrupted data and crashes you can't explain. Dart and Swift give this single problem two diametrically opposed answers — and understanding both is exactly what fills the "thread" gap in your head when you cross from Flutter to iOS.
In one line: Dart forbids sharing, Swift makes sharing safe. The rest is the unpacking of that sentence.
The Dart side: one thread, one event loop
A Dart isolate (an isolated unit of execution) is, by its official definition: its own private memory (heap) and a single thread running an event loop. All Dart code runs inside an isolate; a program starts in the default main isolate, and most apps never spawn another one.
On top of that single thread spins an event loop — the mechanism beneath Flutter's smoothness. It has two queues:
- microtask queue — higher priority;
Futurecompletions andawaitcontinuations land here. - event queue — timers, I/O, gestures, and isolate messages.
The rule is simple: after each event the loop fully drains the microtask queue, and only then takes the next item from the event queue. So async/await doesn't run your code in parallel; it just yields the turn while waiting and hands the thread to other work. One thread, in order, but never blocking.
Where's the parallelism? Isolates
One thread means one core. For genuinely parallel work — parsing a huge JSON, processing an image — Dart spawns a new isolate. And here is where it departs most sharply from Swift:
Two isolates cannot see each other's memory. There is no shared mutable state. The only channel is message passing.
Communication goes through SendPort/ReceivePort, and the relationship is asymmetric: one ReceivePort can be fed by many SendPorts. Dart's model is in fact an implementation of the Actor model — independent actors that don't share state and talk only by messages. That's why I never wrote a mutex in years of Flutter: with no shared memory, there's nothing to lock.
The modern API is Isolate.run():
// Offload heavy work to a background isolate, get the result back
final result = await Isolate.run(() => parseHugeJson(bytes));
A small historical correction: Isolate.run is often called "a Dart 3 feature," but it actually landed in Dart 2.19 (January 2023, Flutter 3.7) — just before Dart 3. It manages the whole isolate lifecycle (spawn, run, return the result, terminate) in one call. Flutter's compute() is roughly equivalent to Isolate.run on mobile/desktop; on the web there are no isolates, so compute runs the work on the main thread.
A subtle detail: the result is not copied back — it is moved via Isolate.exit (transfer) — so returning a result is not a copy, despite the message-passing model.
"Data races can't happen in Dart" — half true
Here you have to be honest, because there's a widely repeated half-truth. Between separate isolates, with no shared memory, low-level data races really are impossible — by design. But the official Dart docs themselves warn, in the very next sentence after that famous claim: "That said, isolates don't prevent race conditions all together."
So within a single isolate you can still hit a logical race condition through async interleaving: a shared variable's value can change between two awaits. Indeed, package:mutex's own docs spell it out: "Although Dart uses a single thread of execution, race conditions can still occur when asynchronous operations are used inside critical sections." It's no accident that lock packages exist in Dart at all. On top of that, native memory allocated via dart:ffi can be shared across isolates, and races are possible there.
The accurate sentence is: Dart structurally eliminates the shared-memory data race — but not every race condition.
The Swift side: a genuinely multithreaded pool
Swift's world is built the opposite way: shared-memory and truly multithreaded.
First the classic layer, GCD (Grand Central Dispatch): you push work onto queues (DispatchQueue), and GCD runs it on a thread pool. The problem: when a thread blocks and work remains on the queue, GCD spawns more threads and can climb well past the core count. This is thread explosion — memory balloons, context-switch cost rises.
The modern layer, Swift Concurrency (async/await, actors): underneath is a cooperative thread pool with a single contract — never more threads than cores. It kills thread explosion at the root.
And here's the sharpest contrast with Dart. In Swift, await does not block the thread, it suspends it: the continuation is stored on the heap and the thread is freed. But after suspension your code may not resume on the same thread. In Dart, by contrast, all of an isolate's code always runs on that isolate's single thread.
Dart: "everywhere in async, always the same thread." Swift: "after an await, which thread — no guarantee."
The practical consequence: in Swift you must not hold a lock across an await, and thread-local data doesn't survive one. (I unpacked the machinery of this in "Under the await Line.")
How Swift makes sharing safe: actors + Sendable
If memory is shared, how are data races prevented? The answer is the exact opposite of Dart's: it doesn't forbid sharing, it makes the compiler prove it's safe.
- actor (SE-0306, Swift 5.5): wraps mutable state; only one task accesses it at a time (mutual exclusion), and without blocking the thread. Unsynchronized access isn't a runtime discipline — it's a compile error.
- Sendable: a protocol marking types that can safely cross isolation boundaries. Value types (struct/enum) are Sendable when their members are. Any argument or result crossing an actor boundary must be Sendable, and the compiler checks this statically.
- @MainActor: the actor representing the main thread. At runtime it's interchangeable with
DispatchQueue.main. Code marked@MainActorruns only on the main thread — a compiler-guaranteed version of iOS's "UI only on the main thread" rule.
@MainActor
final class ProfileViewModel {
var name = "" // accessed only on the main thread — compiler-guaranteed
}
actor ImageCache {
private var store: [URL: Data] = [:] // isolated against races
func insert(_ d: Data, for url: URL) { store[url] = d }
}
One trap: actors are reentrant. While you're suspended at an await, another call can enter the same actor; an assumption you verified before the await may no longer hold after it. So actors eliminate the low-level data race but not the high-level race condition — exactly like the single-isolate case in Dart. The two languages meet at that very boundary.
A small behavioral difference: an actor's executor resembles a serial DispatchQueue but is not FIFO — it runs by priority (to avoid priority inversion). A GCD serial queue is strict FIFO.
The two philosophies converge: Swift 6 → 6.2
Swift 6's "strict concurrency" made compile-time data-race safety mandatory. But friction appeared: the language treated everything unannotated as "usable concurrently" (nonisolated); writing even a simple, single-threaded program could demand a downpour of annotations and false-positive warnings.
Swift 6.2 (September 15, 2025) answered with "approachable concurrency." Two key changes:
- MainActor by default (SE-0466): with a per-module opt-in setting (
-default-isolation MainActor), unannotated code now defaults to@MainActorisolation. Code starts single-threaded by default — until you explicitly ask for parallelism. - @concurrent (arriving with SE-0461): the explicit way to say "actually run this in parallel." Unannotated
nonisolated asyncfunctions now stay on the caller's actor by default (previously they always hopped to the global pool).
Notice it? Swift is walking toward where Dart has been for a decade: single-threaded by default, parallelism on explicit request. Dart got there via isolation from the start; Swift is evolving to the same ergonomics from a shared-memory model. The same summit, from two different slopes.
The "Flutter is single-threaded" myth
One last bridge, because this sentence is both true and misleading. Your Dart code does run on a single thread — the root isolate's UI task runner — true. But the Flutter engine is not single-threaded. The engine doesn't create its own threads; the embedder (the platform shell, e.g. iOS's) does, and the engine asks for four task runners:
- Platform — the OS's main thread; every interaction with the engine must happen here (Flutter's counterpart to iOS's main-thread rule).
- UI — all root-isolate Dart code (build/layout/paint, timers, microtasks) runs here.
- Raster — where the C++ engine rasterizes the scene for the GPU.
- IO — helper work like asset/image decoding.
There's a current development too: in recent versions (a rollout that began on iOS/Android in 3.29 and became the default in 3.32), the UI and platform threads were merged — the dedicated UI thread is gone, and Dart code runs directly on the native platform thread. So the classic "Dart runs on a separate UI thread" description is no longer current; ironically, this brings Flutter even closer to iOS's UIKit model (running on the main thread).
At a glance
| Dimension | Dart | Swift |
|---|---|---|
| Model | Single thread + event loop | Genuinely multithreaded pool |
| Unit of parallelism | Isolate (separate heap) | Task + cooperative pool (shared heap) |
| Communication | Message passing (copy / move) | Shared memory + actor |
| Data-race prevention | Isolation (no sharing) | Compile-time checking (Sendable / actor) |
Thread after await | Always the same (the isolate's thread) | No guarantee |
| UI rule | UI runner / platform thread | @MainActor = main thread |
| Default direction | Single thread (from the start) | Toward single thread (with 6.2) |
Mental model
In one sentence: Dart eliminates the data race by forbidding sharing (separate heap + messaging); Swift makes sharing safe by having the compiler prove it (actor + Sendable). One says "don't touch at all," the other "touch, but prove it." If you're coming from Flutter, your intuition is built on isolation; in iOS, rebuild that intuition on the compile-time guarantees of @MainActor and actor — and in Swift 6.2's "main actor by default" world, you'll find yourself back in that familiar single-threaded calm without even noticing.