Understanding Dart’s Event Loop: Why Your Async Code Acts Weird

Md. Al - AminMd. Al - Amin
4 min read

Have you ever written asynchronous code in Flutter or Dart, and something just didn’t behave the way you expected?

Maybe you scheduled a Future, but it didn’t run immediately.
Maybe your setState() inside a Future.delayed() broke the UI.
Maybe you used await, but things still executed out of order.

If that sounds familiar welcome to the world of Dart’s Event Loop.

In this article, we’ll dive deep into how Dart’s asynchronous engine works not just to fix bugs, but to reason like the Dart runtime itself.

What is an Event Loop?

The Event Loop is the core of Dart’s asynchronous execution model. It powers:

  • Futures

  • async / await

  • UI updates

  • Timers and animations

When you write Dart code, it’s executed on a single thread, which means asynchronous code must be carefully scheduled and executed without blocking.

So how does Dart handle this?
Using two queues:

Dart has TWO types of queues:

  1. Microtask Queue: High priority, runs before any event tasks.

  2. Event Queue (aka Event Loop): Regular events like I/O, timer callbacks.

Execution Order: Microtasks vs Event Tasks

Here’s the general order of execution in Dart:

  1. Run the current synchronous code

  2. Run all microtasks (until the microtask queue is empty)

  3. Run one event task

  4. Repeat steps 2–3

Let’s make it visual 👇

import "dart:async";

void main() {
  print('1');

  scheduleMicrotask(() => print('2'));
  Future(() => print('3'));
  Future.delayed(Duration.zero, () => print('4'));
  Future(() async {
    print('5');
    await Future.delayed(Duration.zero);
    print('6');
  });
  scheduleMicrotask(() => print('7'));

  print('8');
}

What gets printed?

1
8
2
7
3
4
5
6

Let’s break it down:

  • print('1'): sync -> Immediately

  • scheduleMicrotask(...): Microtask -> After sync

  • Future(...): Event queue -> Later

  • Future.delayed: Event queue -> Later

  • await Future.delayed(...): Event queue → Suspends, resumes as microtask

  • print('8'): Sync -> Immediately

So, microtasks always run before event queue tasks and that causes most unexpected behavior in Flutter apps.

Real-World Flutter Problem

Problem: UI Not Updating After await

void _onPressed() async {
  setState(() {
    _loading = true;
  });
  await Future.delayed(Duration(seconds: 2));
  setState(() {
    _loading = false;
  });
}

You click a button. The spinner is supposed to show, wait 2 seconds, then disappear.

But… the UI doesn’t show the spinner during the delay.

Fix: Yield control using Future.delayed(Duration.zero)

void _onPressed() async {
  setState(() => _loading = true);

  await Future.delayed(Duration.zero); // give time for rebuild
  await Future.delayed(Duration(seconds: 2));

  setState(() => _loading = false);
}

Why does this work?
Because Future.delayed(Duration.zero) moves the execution into the event queue, giving Flutter time to rebuild the UI before the delay.

How Dart Awaits Work (Under the Hood)

When you await a Future, Dart:

  1. Registers a callback

  2. Returns control to the event loop

  3. When the Future completes, schedules that callback as a microtask

So, in an async function, everything after await is treated like a microtask.

Use Case: Background Sync in a Flutter App

Imagine you’re building a task manager app. After opening the home screen, it fetches user tasks from local storage, then syncs with the server:

void initState() {
  super.initState();
  _loadTasks();
}

void _loadTasks() async {
  final local = await storage.getTasks();
  setState(() => _tasks = local);

  final updated = await api.syncTasks(local);
  setState(() => _tasks = updated);
}

This seems fine… but users report UI Jank during startup.

Optimized Approach

void initState() {
  super.initState();

  // Let UI load first
  WidgetsBinding.instance.addPostFrameCallback((_) => _loadTasks());
}

void _loadTasks() async {
  final local = await storage.getTasks();
  setState(() => _tasks = local);

  // Let microtasks/UI catch up
  await Future.delayed(Duration.zero);

  final updated = await api.syncTasks(local);
  setState(() => _tasks = updated);
}
  • addPostFrameCallback: Defers work until after first frame

  • Future.delayed(Duration.zero): Gives the UI time to breathe

This respects Dart’s event loop while improving perceived performance.

Best Practices for Dart’s Event Loop

Do

  • Use Future.delayed(Duration.zero) to yield control

  • Schedule background work after first frame using addPostFrameCallback

  • Use scheduleMicrotask for priority work (rare)

  • Profile UI Jank with DevTools

Avoid

  • Heavy sync logic in UI methods

  • Calling async code in initState() without care

  • Misusing await assuming it instantly pauses execution

  • Ignoring long frame times during async ops

Conclusion

Dart’s event loop isn’t just an engine detail it shapes how your code behaves. Understanding it helps:

  • Write smoother UI updates

  • Debug async bugs

  • Build better architecture for real-world apps

The next time you await doesn’t feel like it’s waiting, or your setState() doesn’t fire as expected check the queues. It’s probably a microtask thing.

Bonus Tip: Want to Explore Live?

Paste this code into DartPad and tweak the order of Future, scheduleMicrotask, and print statements to see the event loop in action.

0
Subscribe to my newsletter

Read articles from Md. Al - Amin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Md. Al - Amin
Md. Al - Amin

Experienced Android Developer with a demonstrated history of working for the IT industry. Skilled in JAVA, Dart, Flutter, and Teamwork. Strong Application Development professional with a Bachelor's degree focused in Computer Science & Engineering from Daffodil International University-DIU.