HomeDocumentationFlutter under the hood
Flutter under the hood
17

Threads, Isolates, and Concurrency: Why `async`/`await` Doesn't Create Threads

Flutter Concurrency: Threads, Isolates, async/await, and the Event Loop

March 24, 2026

The sentence that confuses everyone

"Dart is single-threaded."

You've read it. You might have said it. And then you've watched your Flutter app download images, parse JSON, animate a scroll, and respond to a tap — all apparently at the same time. How?

The answer involves a careful distinction that most explanations blur: concurrency is not parallelism. Dart is genuinely single-threaded in its default execution context. It also genuinely does multiple things at once. These two facts don't contradict each other — they describe different mechanisms, and understanding the difference is one of the most valuable mental models you can have as a Flutter developer.

This post is about what's actually happening. Not the simplified version — the real version, from the event loop up to OS threads.

The event loop: one thread, many tasks

In the article about processes, we established that your Flutter app's Dart code runs on the UI thread — one OS thread, one execution context. The Dart VM's main isolate owns this thread. Every build() method, every state update, every onTap handler, every Future callback runs here.

But "one thread" doesn't mean "one thing at a time" in the way you might think. The thread doesn't wait for each operation to finish before starting the next. Instead, it runs an event loop — a continuous cycle that picks up tasks, executes them, and picks up the next one.

Here's the loop, simplified to its essence:

javascript
┌──────────────────────────────────────────────┐
│                                              │
│  ┌─────────────────────────────────┐         │
│  │   Any microtasks queued?        │         │
│  │   → Execute ALL of them first   │         │
│  └─────────────┬───────────────────┘         │
│                │                             │
│                ▼                             │
│  ┌─────────────────────────────────┐         │
│  │   Any events in the queue?      │         │
│  │   → Execute the NEXT one        │         │
│  └─────────────┬───────────────────┘         │
│                │                             │
│                ▼                             │
│  ┌─────────────────────────────────┐         │
│  │   Back to microtasks            │         │
│  └─────────────┬───────────────────┘         │
│                │                             │
│                └─────────── (repeat) ────────┘
│                                              │
└──────────────────────────────────────────────┘

Two queues. One thread. Running continuously.

The microtask queue contains small, high-priority tasks that must complete before the event loop processes the next event. Future.then() callbacks, scheduleMicrotask() calls, and Dart's internal bookkeeping go here. The loop drains the entire microtask queue before touching the event queue.

The event queue contains everything else: I/O completions (a network response arrived, a file was read), timer callbacks (Timer, Future.delayed), user input events (taps, gestures), and Flutter's frame callbacks (the signal to rebuild the UI). The loop processes one event at a time, checking for microtasks between each.

This is the mechanism that makes Dart feel concurrent on a single thread. When you await a Future, you're not blocking the thread. You're telling the event loop: "I'm done for now. When this Future completes, put my continuation in the microtask queue, and I'll pick up where I left off." The thread, meanwhile, goes back to the loop and processes other events — rebuilding the UI, handling a tap, running another timer callback.

What async/await actually does

This is where most misconceptions live. Let's be precise.

dart
Future<void> fetchAndDisplay() async {
  final response = await http.get(Uri.parse('https://api.example.com/data'));
  final parsed = jsonDecode(response.body);
  setState(() => _data = parsed);
}

When fetchAndDisplay() is called:

  1. Execution runs synchronously until it hits await. The http.get() call starts the network request — but http.get() doesn't perform the request on the Dart thread. It hands the request to the Dart VM's I/O subsystem, which delegates it to the operating system's network stack. The actual bytes-on-wire work happens outside the Dart thread entirely — it's handled by the OS kernel and the I/O thread we discussed previously.
  1. The await suspends fetchAndDisplay(). The function returns a Future<void> to its caller. The Dart thread is now free. It goes back to the event loop. It can rebuild the UI, handle taps, run animations — whatever's in the queues.
  1. Time passes. The OS completes the network request. The I/O thread receives the response and posts a completion event to the Dart isolate's event queue.
  1. The event loop picks up the completion event. It resumes fetchAndDisplay() at the line after await. Now jsonDecode() runs synchronously on the Dart thread — it's CPU work, parsing bytes into objects. Then setState() runs, also synchronously.

The crucial insight: `await` doesn't create a thread. It doesn't pause a thread. It yields control back to the event loop. The Dart thread was never blocked. It was never waiting. It was doing other things while the OS handled the network request.

This is concurrency without parallelism. One thread, interleaving tasks by yielding at await points. It works beautifully for I/O-bound work — network requests, file reads, database queries — because the actual waiting happens outside the Dart thread, in the OS kernel.

Where single-threaded concurrency breaks

The model has a flaw, and it's the same flaw every event-loop-based system has (Node.js developers know this intimately).

If a task doesn't yield — if it's pure CPU work with no await points — it blocks the event loop. The thread is busy computing, and nothing else can run until it's done.

dart
// This blocks the UI thread for the entire duration
List<int> findPrimes(int max) {
  final primes = <int>[];
  for (var i = 2; i <= max; i++) {
    var isPrime = true;
    for (var j = 2; j * j <= i; j++) {
      if (i % j == 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) primes.add(i);
  }
  return primes;
}

There's nothing to await here. No I/O to delegate. Just a loop that runs until it's done. If max is 10,000,000, this takes roughly 800ms. During those 800ms, the event loop doesn't run. No frames are rendered. No taps are processed. No animations advance. The UI freezes for 48 frames.

Adding async to the function doesn't help:

dart
// Still blocks — async without await is just a regular function
// that happens to return a Future
Future<List<int>> findPrimes(int max) async {
  // ... same loop, same blocking ...
}

The async keyword doesn't move execution to another thread. It just means the function can use await and returns a Future. If there's no await inside, the function body runs synchronously on the same thread, start to finish.

This is the fundamental limitation: the event loop can only interleave tasks at yield points (`await`). CPU-bound work has no yield points. For CPU-bound work, you need actual parallelism — a second thread running simultaneously. That's what isolates are for.

Isolates: actual parallelism

A Dart isolate is a separate execution context with its own heap, its own event loop, and its own OS thread. When you spawn an isolate, the Dart VM creates a new OS thread — a real, additional thread in your process — and runs Dart code on it.

The word "isolate" is precise: isolates are isolated from each other. They do not share mutable memory. You cannot pass a reference to a Dart object from one isolate to another and modify it from both sides. All communication is by message passing — data is copied (serialized and deserialized) across the boundary.

This is a deliberate design decision. Shared mutable state between threads is the source of data races, deadlocks, and an entire category of bugs that are notoriously hard to reproduce and debug. Dart eliminates them by eliminating shared mutable state. The trade-off: you pay the cost of copying data between isolates instead of sharing it.

Isolate.run — the simple case

For one-off heavy computations:

dart
Future<void> _computePrimes() async {
  // Runs on a NEW thread, in a NEW isolate
  final primes = await Isolate.run(() => findPrimes(10000000));

  // Back on the main isolate's thread
  setState(() => _primes = primes);
}

Isolate.run() does four things:

  1. Creates a new isolate (new OS thread, new Dart heap)
  2. Runs the provided function on that isolate
  3. Sends the result back to the calling isolate via message passing (the List<int> is copied)
  4. Destroys the temporary isolate

While findPrimes runs on the background isolate's thread, the main isolate's thread is free. The event loop keeps running. Frames keep rendering. The user keeps scrolling. When the result arrives, the await resumes and setState runs on the main thread.

This is parallelism. Two threads, two isolates, two heaps, running simultaneously on different CPU cores.

compute() — the Flutter convenience

compute() predates Isolate.run() and does essentially the same thing with a slightly different API:

dart
final primes = await compute(findPrimes, 10000000);

The function you pass to compute() must be a top-level function or a static method — not a closure that captures variables. This restriction exists because the function reference must be serializable across the isolate boundary. Isolate.run() is more flexible (it can take closures in many cases) and is generally preferred in modern Dart.

Isolate.spawn — the persistent case

For long-running background work that needs bidirectional communication:

dart
late SendPort _bgSendPort;

Future<void> _startBackgroundProcessor() async {
  final receivePort = ReceivePort();

  await Isolate.spawn(_backgroundWorker, receivePort.sendPort);

  // First message from the background isolate: its SendPort
  _bgSendPort = await receivePort.first as SendPort;
}

// This function runs entirely in the background isolate
void _backgroundWorker(SendPort mainSendPort) {
  final bgReceivePort = ReceivePort();

  // Send our SendPort back so the main isolate can talk to us
  mainSendPort.send(bgReceivePort.sendPort);

  // Listen for work
  bgReceivePort.listen((message) {
    if (message is String && message == 'process') {
      final result = _heavyComputation();
      mainSendPort.send(result);
    }
  });
}

SendPort and ReceivePort are the message passing primitives. Each isolate has a ReceivePort (inbox) and gives out its SendPort (the address of that inbox) to whoever needs to send it messages. Messages are copied — the sender's object and the receiver's object are independent.

The background isolate stays alive, running its own event loop, until you explicitly kill it or close the ports. This is the pattern for background sync, real-time data processing, or any work that's ongoing rather than one-shot.

What gets copied, what gets transferred

Message passing between isolates copies data. For small messages — a string, a number, a small map — the cost is negligible. For large data, it matters.

Copied (default behavior):

dart
// A 10MB list gets serialized, sent, and deserialized
// Two copies exist briefly in memory
final result = await Isolate.run(() {
  return List.generate(10000000, (i) => i * 2);
});

Transferred (zero-copy for typed data):

dart
// TransferableTypedData moves ownership — no copy
final data = TransferableTypedData.fromList([
  Int32List.fromList(largeIntList)
]);

// After sending, 'data' is no longer usable in this isolate
sendPort.send(data);

TransferableTypedData transfers the underlying byte buffer's ownership from one isolate to the other. No copy. The sender loses access. The receiver gets the original bytes. This is critical for large buffers — image data, audio samples, FFI results.

For typical app development — sending a parsed API response, a list of model objects, a search result — the copy overhead is small enough that you don't think about it. For performance-sensitive paths — camera frames, real-time audio, large datasets — TransferableTypedData or careful chunking is necessary.

The event loop in detail

Let's go deeper into the two queues, because the priority rules affect real behavior.

Microtasks

Microtasks are high-priority continuations. They execute between events — all of them, before the next event is processed.

What creates microtasks:

  • Future.then(), Future.whenComplete(), Future.catchError() callbacks
  • scheduleMicrotask() directly
  • await continuations (when the awaited Future is already complete)
  • Dart internal bookkeeping (zone callbacks)
dart
Future<void> example() async {
  print('1');

  Future(() => print('4'));         // event queue
  scheduleMicrotask(() => print('2')); // microtask queue
  Future.microtask(() => print('3')); // microtask queue

  print('1.5');
}
// Output: 1, 1.5, 2, 3, 4

print('1') and print('1.5') run synchronously. The microtasks (2 and 3) run before the event (4), because the event loop drains all microtasks before processing the next event.

The danger of microtask flooding

Because all microtasks must complete before the next event, a chain of microtasks can starve the event queue:

dart
// DON'T DO THIS — starves the event loop
void recursiveMicrotask(int count) {
  if (count > 0) {
    scheduleMicrotask(() => recursiveMicrotask(count - 1));
  }
}

recursiveMicrotask(1000000);
// No events (including frame rendering) can process
// until all 1,000,000 microtasks complete

This is rare in practice, but it explains why Future() (which schedules on the event queue) and Future.microtask() (which schedules on the microtask queue) exist as separate constructors. If you want to give the event loop a chance to breathe between iterations, use Future() or Timer.run() instead of scheduleMicrotask().

Events

Events are the workload of the event loop. One event is processed per iteration (with all microtasks drained between events).

What creates events:

  • I/O completions (network, file, image decode)
  • Timer and Future.delayed callbacks
  • Future() callbacks (shorthand for Timer.run())
  • User input events (taps, gestures, keyboard)
  • Flutter frame callbacks (SchedulerBinding.scheduleFrame)
  • Isolate messages arriving via ReceivePort
  • FFI .listener() callbacks arriving from native threads

That last one connects to the FFI callbacks post. When C calls a NativeCallable.listener() from a background thread, the FFI machinery posts a message to the Dart isolate's event queue. The callback runs when the event loop reaches that message — safely, on the Dart thread, with no thread-safety issues.

Frame scheduling

Flutter's frame rendering is itself an event. When setState() is called, it marks the widget as dirty and calls SchedulerBinding.scheduleFrame(), which requests a callback from the platform's vsync signal. When the vsync fires (every 16.67ms at 60Hz), the platform posts a frame callback event to the Dart event queue.

When the event loop reaches this frame event, the framework runs:

  1. Animations (tick all active AnimationControllers)
  2. Build (call build() on all dirty widgets)
  3. Layout (calculate sizes and positions)
  4. Paint (produce the display list for the Raster thread)

All of this happens inside one event callback. If it takes more than 16ms, the next vsync fires before the current frame is done — and that frame is dropped.

This is why the advice "don't do heavy work on the UI thread" is really about the event loop. Heavy synchronous work blocks the thread, which means frame events can't be processed, which means dropped frames.

Concurrency vs parallelism: the complete picture

Now we can state the difference precisely.

Concurrency is about structure — organizing your program so multiple tasks make progress by interleaving on a single thread. The event loop provides concurrency. async/await provides concurrency. One thread, many tasks, interleaved at yield points.

Parallelism is about execution — multiple threads running simultaneously on multiple CPU cores. Isolates provide parallelism. Each isolate runs on its own OS thread, doing actual computation at the same physical time as the main isolate.

javascript
| | Concurrency (event loop) | Parallelism (isolates) |
|---|---|---|
| **Threads** | One | Multiple |
| **Use for** | I/O-bound work (network, files, DB) | CPU-bound work (parsing, crypto, image processing) |
| **Mechanism** | `async`/`await`, Futures, Streams | `Isolate.run()`, `Isolate.spawn()` |
| **Data sharing** | Same heap — no copying | Separate heaps — message passing (copy) |
| **Overhead** | Near zero | Thread creation + data serialization |
| **Risk** | Event loop blocking if no yield points | Memory overhead per isolate (~2MB heap minimum) |

Most Flutter apps need concurrency everywhere (every network call, every database query uses async/await) and parallelism in a few specific places (JSON parsing of large responses, image processing, cryptographic operations, FFI-heavy workflows).

How isolates map to OS threads

In Post 6, we described the threads in your Flutter app's process. Let's map them precisely:

The main isolate runs on the UI thread. This thread is created by the Flutter engine when it initializes the Dart VM. Your main() function, your widget tree, your state management, your event loop — all of this runs on this single OS thread.

Background isolates each get their own OS thread. Isolate.run() creates a thread, runs the computation, and destroys the thread. Isolate.spawn() creates a thread that lives until the isolate is killed. Each thread has its own stack, runs its own event loop, manages its own Dart heap.

The raster thread is not a Dart isolate. It's a C++ thread managed by Flutter's engine, running Impeller's rendering pipeline. No Dart code runs on it.

The platform thread (main thread from the OS perspective) runs the platform's event loop — ART on Android, the main run loop on iOS. Platform channel calls arrive here. This is a separate OS thread from the Dart UI thread, though they coordinate closely.

The I/O thread is an engine-level thread that handles asynchronous I/O. When you await an HTTP response or a file read, the actual OS-level I/O operation runs on this thread (or is delegated to the OS kernel), and the completion notification is posted to the Dart event queue.

So a typical Flutter app with one background isolate has at least five OS threads: platform, UI (Dart main isolate), raster, I/O, and the background isolate. The OS scheduler distributes these across CPU cores. On a device with 8 cores, all five can genuinely run in parallel.

Practical patterns

Pattern 1: I/O work — just use async/await

dart
Future<List<Post>> fetchPosts() async {
  final response = await httpClient.get('/api/posts');
  return (jsonDecode(response.body) as List)
      .map((json) => Post.fromJson(json))
      .toList();
}

The network request runs outside the Dart thread. The JSON parsing runs on the Dart thread — but for a typical API response (a few KB to a few hundred KB), jsonDecode takes under a millisecond. No isolate needed.

Pattern 2: Heavy parsing — isolate it

dart
Future<List<Post>> fetchLargeDataset() async {
  final response = await httpClient.get('/api/export');

  // 5MB response — jsonDecode could take 50-100ms
  return await Isolate.run(() {
    return (jsonDecode(response.body) as List)
        .map((json) => Post.fromJson(json))
        .toList();
  });
}

Same network call, but the response is large enough that parsing blocks the UI. Move the parsing to an isolate. The response.body string is copied to the background isolate — for a 5MB string, this copy takes roughly 1-2ms, far less than the 50-100ms the parsing would block the UI thread.

Pattern 3: FFI computation — isolate the call

From the FFI series:

dart
Future<int> countPrimesNative(int max) async {
  return await Isolate.run(() => NativeMath.countPrimes(max));
}

The C function runs on the background isolate's thread. The main isolate's UI thread is free. When the C function returns, the result (a single int) is sent back via message passing — trivial copy cost.

Pattern 4: Stream processing — long-lived isolate

dart
class ImageProcessor {
  late final SendPort _workerPort;
  final _results = StreamController<ProcessedImage>();

  Stream<ProcessedImage> get results => _results.stream;

  Future<void> start() async {
    final receivePort = ReceivePort();
    await Isolate.spawn(_worker, receivePort.sendPort);
    _workerPort = await receivePort.first as SendPort;

    // Listen for results from the worker
    final resultPort = ReceivePort();
    _workerPort.send(resultPort.sendPort);
    resultPort.listen((message) {
      if (message is ProcessedImage) {
        _results.add(message);
      }
    });
  }

  void processFrame(Uint8List frameData) {
    _workerPort.send(TransferableTypedData.fromList([frameData]));
  }

  static void _worker(SendPort mainPort) {
    final workerReceive = ReceivePort();
    mainPort.send(workerReceive.sendPort);

    late SendPort resultPort;

    workerReceive.listen((message) {
      if (message is SendPort) {
        resultPort = message;
      } else if (message is TransferableTypedData) {
        final bytes = message.materialize().asUint8List();
        final processed = _applyFilters(bytes);
        resultPort.send(ProcessedImage(processed));
      }
    });
  }
}

A long-lived isolate processes camera frames. The main isolate sends frame data via TransferableTypedData (zero-copy transfer). The background isolate processes each frame and sends results back. The main isolate's UI thread never touches the pixel data.

Pattern 5: The thing you should NOT do

dart
// DON'T — wrapping synchronous CPU work in a Future doesn't help
Future<List<int>> findPrimesFuture(int max) async {
  return findPrimes(max); // ← still blocks the UI thread
}

// DON'T — awaiting a compute-heavy function doesn't create a thread
final primes = await findPrimesFuture(10000000); // ← 800ms of jank

The async keyword doesn't move work off the thread. The await doesn't parallelize anything. If the function body is synchronous CPU work, it runs synchronously on the Dart thread. The only way to move it off the thread is to run it in an isolate.

The cost of isolates

Isolates aren't free. Each one costs:

  • Memory: a Dart heap (minimum ~2MB), a stack, GC metadata. Ten isolates is 20MB+ of overhead.
  • Startup time: creating an isolate takes 5-50ms depending on the platform and what needs to be initialized.
  • Communication: every message between isolates is serialized and deserialized. Small messages (primitives, short strings, small maps) are cheap. Large object graphs are expensive.

The rule of thumb: use Isolate.run() when the work takes more than ~2ms (enough to drop a frame). Don't use isolates for trivial work — the overhead of creating the isolate and copying the result exceeds the time saved.

For apps that frequently need background computation, consider a persistent isolate (Isolate.spawn()) that stays alive and processes a queue of work items. The startup cost is paid once, and only the message-passing cost applies per task.

How this connects to everything

The event loop, async/await, and isolates form the concurrency foundation that everything else in Flutter builds on:

  1. Garbage collection runs as part of the event loop — the GC pauses the Dart thread briefly (microseconds for young gen, milliseconds for old gen), which is why it usually doesn't cause jank but can if the old gen collection coincides with a frame callback.
  1. Impeller renders on the Raster thread while the Dart event loop builds the next frame on the UI thread — the two-thread pipeline that makes 60fps possible.
  1. FFI callbacks use NativeCallable.listener() to post events from C's threads to the Dart event queue — the event loop is the mechanism that makes cross-thread callbacks safe.
  1. Handlers are what the event loop calls — every event that arrives in the queue ultimately invokes a handler function.
  1. Processes contain all of this — every thread, every isolate, every event loop, sharing one address space, one fate.

The mental model

Here's the picture to carry forward:

Your Flutter app has one main Dart thread running one event loop that processes two queues (microtasks first, then events). This is where your entire widget tree lives. async/await lets you interleave I/O-bound tasks without blocking this thread.

When you need actual parallelism — CPU-bound work that would block the event loop — you create isolates, which are separate OS threads with separate Dart heaps. They communicate by copying data through message passing. They provide safety (no shared mutable state, no data races) at the cost of isolation (no shared mutable state, no direct references).

The event loop gives you concurrency. Isolates give you parallelism. async/await is syntactic sugar over the event loop. None of it creates threads — except isolates, which explicitly do.

One thread does more than you'd expect. When it's not enough, you add another. And the language makes sure the two threads can't corrupt each other's data, because they can't see each other's data.

That's Dart's concurrency model. Simple to use, surprisingly deep underneath, and designed so that the common case — I/O-bound Flutter apps — never needs to think about threads at all.

Related Topics

dart event loop explainedflutter async await not threadsdart isolate vs threadflutter concurrency vs parallelismdart single threaded explainedflutter event loop microtaskdart isolate run computeflutter background threaddart sendport receiveportflutter jank concurrencydart future stream event loopflutter isolate message passing

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.