Why Your Flutter UI Freezes: Understanding Async, Await, Compute & Isolates

BackerLeader posted 6 min read

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:

  1. Dart initiates the request
  2. Dart tells the OS: "ping me when this finishes"
  3. The isolate is now free — it goes back to rendering, handling taps, running animations
  4. 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:

  1. A and D are synchronous — they run first, in order
  2. C is a microtask — it runs before any events
  3. 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:

  1. Spawns a new isolate
  2. Sends your data to it (via message passing — it gets copied)
  3. Runs your function
  4. Returns the result to the main isolate
  5. 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.

More Posts

Your Tech Stack Isn’t Your Ceiling. Your Story Is

Karol Modelskiverified - Apr 9

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

Everything about Stateful & Stateless widgets in Flutter

Lordhacker756verified - May 3

Beyond the Crisis: Why Engineering Your Personal Health Baseline Matters

Huifer - Jan 24
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!