How Dart Runs Your Code
Welcome to Part 1 of the Async & Concurrency in Dart series. Before we touch Future, async, await, Stream, or Isolate, we need to answer one deceptively simple question:
If Dart runs on a single thread, how does a Flutter app stay smooth while loading data from the network, animating a spinner, and reacting to your taps — all "at once"?
Get this one mental model right and every other topic in this series — futures, streams, error handling, even isolates — will click into place. Get it wrong and async Dart will feel like magic that randomly misbehaves. So we start here.
New to the series? Every later part assumes the event loop from this post. Bookmark it — you'll come back.
The big idea: one chef, one kitchen
Imagine a small restaurant with exactly one chef. There's no second cook, no sous-chef. Just one person.
A naive chef would take order #1, stand and stare at the oven for 20 minutes while a cake bakes, then start order #2. The restaurant would grind to a halt. Customers would leave.
A smart chef doesn't work like that. They put the cake in the oven, set a timer, and immediately start chopping vegetables for order #2. When the oven timer dings, they finish the cake. One person, never idle, never blocking — because the slow part (baking) happens elsewhere (the oven), and the chef only comes back when it's ready.
Dart is that one smart chef. Your code runs on a single thread (the chef). Slow operations — network requests, file reads, timers — are handed off to the system (the oven), and Dart keeps doing other useful work. When a slow thing finishes, its follow-up work gets queued, and the chef picks it up.
The mechanism that makes this work is called the event loop.
What the event loop actually is
Strip away the jargon and the event loop is just this — an infinite loop that does one thing forever:
// Pseudocode for Dart's event loop
while (appIsRunning) {
if (microtaskQueue.isNotEmpty) {
runAllMicrotasks(); // drain microtasks FIRST, completely
} else if (eventQueue.isNotEmpty) {
runOneEvent(eventQueue.removeFirst()); // then ONE event
}
}
That's the entire heart of asynchronous Dart. Two queues, a strict priority rule, one item at a time. Let's unpack each piece.
The two queues
Dart maintains two separate queues of "work to do later":
| Queue | What goes in it | How you add to it |
| --- | --- | --- |
| Event queue | I/O, timers, taps, Futures, drawing, isolate messages | Future(() {...}), Future.delayed, Timer, gestures |
| Microtask queue | Tiny internal follow-ups that must run ASAP | scheduleMicrotask(...), the continuation after an await |
Both queues are FIFO (first-in, first-out) — like a line at a checkout. The crucial rule is the priority between them:
The event loop empties the entire microtask queue before it touches even a single item in the event queue.
Picture the chef again. Microtasks are "I'll just quickly wipe my knife between cuts" — small, urgent, do-them-all-now jobs. Events are "start the next customer's order" — bigger jobs taken one at a time. The chef finishes every pending quick wipe before starting the next order.
Synchronous first, always
Here's the rule everyone forgets: all of your normal, top-to-bottom code runs first, to completion, before the event loop processes a single queued item.
The queued callbacks — the closures you pass to Future, scheduleMicrotask, Timer — only run after the current straight-line code finishes. Let's prove it:
import 'dart:async';
void main() {
print('1');
Future(() => print('2')); // event queue
scheduleMicrotask(() => print('3')); // microtask queue
print('4');
}
Most people new to async guess 1, 2, 3, 4. The real output is:
1
4
3
2
Walk through it like the event loop does:
print('1')→ runs immediately.1Future(...)→ does not run now; its callback is parked in the event queue.scheduleMicrotask(...)→ parked in the microtask queue.print('4')→ runs immediately.4main()ends. Now the event loop wakes up.- Microtask queue first → runs
print('3').3 - Microtask queue empty → take one event →
print('2').2
The synchronous code (1, 4) always wins. Among the deferred work, the microtask (3) beats the event (2) because of the priority rule.
Where await fits in
This is the punchline that makes the whole series make sense: the code after an await is scheduled like a microtask-style continuation. When you write await something, Dart effectively says "pause here, and when something completes, queue the rest of this function as follow-up work."
import 'dart:async';
void main() {
print('A');
future() async {
print('B'); // runs synchronously up to the first await
await null; // 'await null' still yields to the loop
print('C'); // queued as a continuation
}
future();
print('D');
scheduleMicrotask(() => print('E'));
}
Output:
A
B
D
C
E
Aprints. We callfuture().- Inside,
Bprints — anasyncfunction runs synchronously until its firstawait. await nullhands control back to the event loop; the rest (print('C')) is queued.- Back in
main,Dprints, thenEis scheduled as a microtask. mainends. The loop drains microtasks in order: theCcontinuation was queued beforeE, soCthenE.
Why "runs synchronously until the first await" matters: code at the top of an
asyncfunction (validation, logging, kicking off a request) executes right away, in the current turn. Don't assume anasyncfunction is "fully background." Only the part afterawaitis deferred. We'll lean on this in Part 3.
Why this keeps your UI smooth
In Flutter, the event loop is also how the screen gets drawn. Rendering each frame is itself an event. So the golden rule of a smooth app falls right out of the model:
Never block the single thread. Every event — including "paint the next frame" — has to wait its turn. If one event hogs the thread, frames can't be drawn, and the UI freezes (jank).
// ❌ BAD: a long synchronous loop blocks the thread.
// The event loop can't run ANY other event — including drawing —
// until this finishes. The app freezes for the whole time.
void freezeTheApp() {
var total = 0;
for (var i = 0; i < 5000000000; i++) {
total += i;
}
print(total);
}
While freezeTheApp() runs, taps queue up unhandled, animations stall, and the frame never paints. Nothing is "wrong" with the code — it's just that synchronous work monopolizes the chef.
The fixes are the rest of this series:
- For waiting on slow I/O (network, disk), use
Future/async/await— the thread stays free while the system does the slow part. (Part 2, Part 3) - For streams of events over time (sockets, user input), use
Stream. (Part 4) - For genuinely CPU-heavy work (parsing a huge JSON, image processing) that would block, move it to another thread with an isolate. (Part 8)
Notice the distinction: async/await does not add threads. It just lets the one thread juggle waiting efficiently. Only isolates give you true parallelism.
A trickier ordering puzzle
Let's combine everything. Predict the output before reading on:
import 'dart:async';
void main() {
print('start');
Future(() => print('event 1'));
Future(() {
print('event 2');
scheduleMicrotask(() => print('micro from event 2'));
});
Future(() => print('event 3'));
scheduleMicrotask(() => print('micro 1'));
print('end');
}
Show the answer and the reasoning
start
end
micro 1
event 1
event 2
micro from event 2
event 3
Step by step:
- Synchronous code runs:
start,end. - The microtask queue is drained completely before any event:
micro 1. - Now events, one at a time, draining microtasks after each:
event 1runs. No new microtasks → next event.event 2runs and schedules a microtask. Before the loop moves to the next event, it drains the microtask queue →micro from event 2.event 3runs.
The key insight: after every single event, the loop checks the microtask queue and empties it before taking the next event. Microtasks can "cut the line" ahead of already-queued events.
A word of caution about microtasks
Because microtasks always run before the next event (and before the next frame), flooding the microtask queue can starve the event queue — the UI never gets to draw. Rule of thumb:
Prefer the event queue (
Future(...)) for ordinary "do this later" work. Reach forscheduleMicrotaskonly when something genuinely must run before the next event/frame — which is rare in app code.
You'll almost never call scheduleMicrotask directly. It matters because await continuations and Future internals use that priority, and knowing it explains otherwise-baffling ordering bugs.
Practice Challenges
Challenge 1 — Predict the order. Without running it, what does this print?
void main() {
print('1');
Future(() => print('2'));
print('3');
}
Show solution
1
3
2
1 and 3 are synchronous. The Future callback (2) is parked in the event queue and runs only after main finishes.
Challenge 2 — Microtask vs event. Predict the output.
import 'dart:async';
void main() {
Future(() => print('event'));
scheduleMicrotask(() => print('microtask'));
print('sync');
}
Show solution
sync
microtask
event
Synchronous sync first. Then the loop drains microtasks (microtask) before any event (event).
Challenge 3 — The await split. Where does 'after' print relative to 'sync end'?
Future<void> run() async {
print('before');
await Future.delayed(Duration.zero);
print('after');
}
void main() {
run();
print('sync end');
}
Show solution
before
sync end
after
run() executes synchronously up to the await, printing before. The await defers the rest, so control returns to main and sync end prints. Only later does the continuation run after.
Challenge 4 — Spot the freeze. Why does this Flutter callback jank the UI, and what category of fix does it need?
onPressed: () {
final result = sortAndProcess(hugeListOfMillions); // pure CPU, ~2s
setState(() => _data = result);
}
Show solution
sortAndProcess is synchronous CPU work running on the UI thread. While it runs (~2s), the event loop can't process the "paint frame" event, so the app freezes and the button stays visually stuck. async/await would not help — there's nothing to wait on; the CPU is busy. The right fix is an isolate (e.g. Isolate.run or Flutter's compute) to run it on another thread — covered in Part 8.
Questions to test yourself
Q1 (basic). Is Dart single-threaded or multi-threaded by default?
Show answer
By default Dart runs your code on a single thread, inside one isolate. It achieves apparent concurrency with the event loop, not with multiple threads. True multi-threading requires explicitly spawning isolates.
Q2 (basic). Name Dart's two queues and which one has priority.
Show answer
The microtask queue and the event queue. The microtask queue has priority — the event loop empties it entirely before processing the next single event.
Q3 (intermediate). Does code in an async function start running immediately when the function is called, or only later?
Show answer
It starts immediately. An async function runs synchronously up to its first await. Only the code after an await is deferred (queued as a continuation). This is why putting validation or a quick log at the top of an async function still runs right away.
Q4 (intermediate). You add a Future(() => ...) and a scheduleMicrotask(() => ...) in that order, then your synchronous code ends. Which runs first, and why?
Show answer
The microtask runs first, even though the Future was added before it. The event loop always drains the whole microtask queue before taking any event-queue item; Future(...) goes to the event queue, while scheduleMicrotask goes to the higher-priority microtask queue.
Q5 (advanced). How can scheduling too many microtasks harm a Flutter app?
Show answer
Because microtasks always run before the next event — and drawing a frame is an event — an endless stream of microtasks can starve the event queue. The loop keeps servicing microtasks and never reaches the "paint frame" event, so the UI freezes. Prefer Future(...) (event queue) for normal deferred work; reserve scheduleMicrotask for the rare case that must run before the next event.
Q6 (advanced). Explain why async/await does not give you parallelism, while isolates do.
Show answer
async/await is concurrency on one thread: the single thread interleaves tasks by parking work in queues and resuming it when slow I/O completes. At any instant, only one Dart statement is executing — there's no second thread. So await only helps when you're waiting (I/O), not when you're computing. Isolates are separate execution units with their own memory and event loop, able to run on other CPU cores at the same time — that's real parallelism, and the only way to keep CPU-bound work from blocking the UI thread.
Wrapping up
The single most valuable model in async Dart:
- Dart runs on one thread driven by an event loop.
- Synchronous code runs first, to completion; queued callbacks run after.
- There are two queues — microtask (high priority, drained fully) and event (one at a time) — both FIFO.
- An
asyncfunction runs synchronously until its firstawait; the rest is a deferred continuation. - Never block the thread — it also draws your frames. Wait with futures/streams; compute heavy work in isolates.
Now that you know where deferred work goes, Part 2 zooms into the object that represents a single piece of deferred work — the Future: its states, how to create one, and how .then() chains relate to the await you'll use everywhere.