You're building a Flutter app. Everything feels smooth — taps register instantly, animations glide, scrolling is butter.
Then one day you load a large dataset. Maybe 15,000 transactions from a crypto API. Maybe a chunky image processing job. Maybe just a fat JSON response.
And suddenly: the UI freezes. Buttons stop responding. Animations stutter. Scrolling locks up. Users notice.
Your first instinct? Blame async/await.
Bad news: async/await is innocent. The real culprit is something more fundamental — and once you understand it, you'll never guess at Flutter performance problems again. You'll engineer around them.
The Uncomfortable Truth: Dart Is Single-Threaded
Every Flutter app runs inside a Dart isolate. That isolate has:
- Its own memory
- Its own event loop
- One execution thread
One. Single. Thread.
Picture it as a conveyor belt:
→ RENDER FRAME → HANDLE TAP → RENDER FRAME → YOUR HEAVY WORK → RENDER FRAME →
The belt moves forward only when each item finishes. If "YOUR HEAVY WORK" takes 3 seconds, the belt stops for 3 seconds. No rendering. No touch events. Nothing.
That's your UI freeze. That's jank.
The Biggest Misconception in Flutter Development
Here's what most developers assume:
await heavyCalculation(); // "this runs in the background, right?"
Wrong.
async/await does not move work to another thread. It does not create parallelism. It does not make your code run somewhere else.
What it actually means is:
"Pause this function here. Resume it later when the Future completes."
That's it. That's the whole trick.
To prove it — this code freezes your UI completely, even with async/await:
Future<void> freezeUI() async {
for (var i = 0; i < 1000000000; i++) {} // CPU grinding on the UI thread
}
// Calling this:
await freezeUI(); // Still freezes. async didn't save you.
The work is still running on the same isolate as your UI. await just made it look like it wouldn't.
So What Does Async-Await Actually Do Well?
Async shines when your code is waiting, not working.
When you make a network request:
final response = await http.get(Uri.parse('https://api.example.com/data'));
Here's what actually happens under the hood:
- Dart initiates the request
- Dart tells the OS: "ping me when this finishes"
- The isolate is now free — it goes back to rendering, handling taps, running animations
- When the response arrives, Dart resumes your function from where it left off
No blocking. No freezing. The isolate was idle anyway — it was waiting on the network, not computing.
This is non-blocking I/O. And async/await handles it beautifully.
The problem only starts when you stop waiting and start working.
The Dart Event Loop (And Why It Matters)
To really understand async behavior, you need to know how Dart schedules work.
Dart's event loop has two queues:
Microtask Queue (High Priority)
Used for:
Future continuations (what runs after an await)
scheduleMicrotask() calls
Event Queue (Normal Priority)
Used for:
- Timer callbacks
- Network/IO completions
- Gesture events
- Platform messages
The loop runs like this:
1. Run all synchronous code
2. Drain every pending microtask
3. Execute one event from the event queue
4. Repeat forever
This ordering has a real-world consequence. Here's a classic puzzle:
void main() {
print("A"); // sync
Future(() => print("B")); // event queue
scheduleMicrotask(() => print("C")); // microtask queue
print("D"); // sync
}
Output:
A
D
C
B
Why? Because:
A and D are synchronous — they run first, in order
C is a microtask — it runs before any events
B is in the event queue — it runs last
Knowing this helps you reason about execution order, debug subtle timing bugs, and understand why async/await resumptions feel immediate (they're microtasks).
The JSON Parsing Trap
Here's where developers get burned all the time.
Your app fetches data. The network call is fine — async, non-blocking, smooth. But then:
final response = await http.get(url); // non-blocking, isolate is free
final data = jsonDecode(response.body); // CPU-heavy, blocks the isolate
jsonDecode on a small payload? No problem. On 15,000 transaction records? That single line can block your UI for 2–4 seconds.
The network call wasn't your enemy. The parsing was.
This is the trap: the operation after await can still freeze your UI if it's CPU-intensive.
The Fix: Isolates
An isolate is an independent Dart execution environment. It has:
- Its own memory heap (completely separate from your UI isolate)
- Its own event loop
- Its own thread
Unlike OS threads, isolates cannot share memory. This eliminates entire classes of bugs — no race conditions, no mutex deadlocks, no shared-state corruption. Instead, isolates communicate by passing messages.
Main Isolate Worker Isolate
───────────── ──────────────
UI rendering JSON parsing
Touch handling ←──────→ Image processing
Animations (messages) Heavy computation
While the worker isolate grinds through CPU work, your UI isolate keeps rendering at 60fps.
compute(): Isolates Without the Ceremony
Spawning a raw isolate involves some boilerplate. For one-off tasks, Flutter gives you compute():
// Before: freezes UI
final transactions = jsonDecode(response.body);
// After: runs in a separate isolate, UI stays smooth
final transactions = await compute(parseTransactions, response.body);
Where parseTransactions is a top-level or static function:
List<Transaction> parseTransactions(String rawJson) {
final decoded = jsonDecode(rawJson) as List;
return decoded.map((e) => Transaction.fromJson(e)).toList();
}
compute() under the hood:
- Spawns a new isolate
- Sends your data to it (via message passing — it gets copied)
- Runs your function
- Returns the result to the main isolate
- Destroys the worker isolate
One function call. UI stays responsive. Task done in parallel.
When compute() Isn't Enough: Persistent Isolates
compute() is great for one-shot jobs. But spawning and destroying isolates has overhead. If you're doing continuous background work — like a live transaction feed, streaming parser, or on-device ML inference — you want a persistent isolate.
// Pseudo-code for a long-running isolate
final receivePort = ReceivePort();
final isolate = await Isolate.spawn(backgroundWorker, receivePort.sendPort);
receivePort.listen((message) {
// Handle result from background isolate
setState(() => _data = message);
});
Use persistent isolates when:
- You're processing a continuous stream of data
- You're running local AI/ML models
- You have a background sync engine
- The spawning overhead of
compute() becomes measurable
The Decision Framework
Every time you write async code, ask: am I waiting or working?
| Scenario | Use |
| API call / network request | async/await |
| Database read/write | async/await |
| File I/O | async/await |
| Timer / delay | async/await |
| JSON parsing (large) | compute() |
| Image processing | compute() |
| Encryption / hashing | compute() |
| One-off heavy computation | compute() |
| Streaming data parser | Persistent Isolate |
| On-device ML inference | Persistent Isolate |
| Background sync engine | Persistent Isolate |
Real-World Example: Crypto Wallet Transaction Feed
Let's put it all together with a realistic scenario.
Your wallet app fetches 15,000 transactions. Naïve implementation:
// Freezes UI for multiple seconds
Future<void> loadTransactions() async {
final response = await http.get(transactionsUrl); // fine
final txs = jsonDecode(response.body); // blocks here
setState(() => _transactions = txs);
}
Better:
// UI stays responsive throughout
Future<void> loadTransactions() async {
final response = await http.get(transactionsUrl); // non-blocking
final txs = await compute(parseTransactions, response.body); // off-thread
setState(() => _transactions = txs); // back on UI thread
}
List<Transaction> parseTransactions(String json) {
return (jsonDecode(json) as List)
.map((e) => Transaction.fromJson(e))
.toList();
}
The user never sees a freeze. Scrolling stays smooth at 60fps while thousands of transactions parse in the background.
Common Myths, Killed
"Async/await makes my code multithreaded"
No. It handles asynchronous waiting. Multithreading requires isolates.
"If I put await before it, it won't block the UI"
Only if the work is I/O-bound. CPU-heavy code blocks regardless of await.
"compute() is some kind of magic optimization"
It's just a thin wrapper around isolate spawning. The magic is the isolate, not the function.
"Dart can only use one CPU core"
False. Each isolate can run on its own OS thread. Dart can use all your cores through isolates.
The Mental Model That Fixes Everything
Burn this into your brain:
async/await → for WAITING
isolates → for WORKING
That's the whole framework. Every Flutter performance bug related to async ultimately comes down to confusing these two. Once you stop conflating them, you stop guessing — and start building apps that are genuinely fast.
Your UI thread should only wait. Never grind.