Lazy Iteration in Dart: sync vs async Explained with Benchmarks
Modern Flutter and Dart development relies heavily on iteration—working with lists, streams, large data sets, paginated APIs, and background processing. Dart provides two powerful generator features to help developers build memory-efficient and lazy-loaded logic:
Both allow you to use the yield keyword to produce values one by one instead of constructing entire collections or streams upfront.
But when should you use which? Why not just return a List? What are the real benefits? And how do they perform in real-world scenarios?
Let’s break everything down.
What Are Generator Functions?
Generators allow functions to produce multiple values over time, instead of returning everything at once.
Dart gives you two types:
sync* – Synchronous Generator
Returns: Iterable<T>
The caller consumes values synchronously (pull-based).
Iterable<int> numbersSync() sync* {
for (int i = 0; i < 3; i++) {
yield i;
}
}
async* – Asynchronous Generator
Returns: Stream<T>
The caller listens to values asynchronously (push-based).
Stream<int> numbersAsync() async* {
for (int i = 0; i < 3; i++) {
await Future.delayed(Duration(milliseconds: 500));
yield i;
}
}
When to Use sync* Instead of Returning a List?
The most common question:
“I can just return a list… Why do I need sync*?”
Here are the real advantages.
Memory Efficiency (Huge Lists)
Returning a list builds everything in memory first.
List<int> buildHugeList() {
final list = <int>[];
for (var i = 0; i < 1e7; i++) {
list.add(i);
}
return list;
}
This can crash for huge data.
Using sync*, items are generated on demand:
Iterable<int> hugeSequence() sync* {
for (var i = 0; i < 1e7; i++) {
yield i;
}
}
No full data stored means no memory explosion This is especially useful for:
Laziness & Early Exit Optimization
If the caller only needs the first 5 items, sync* avoids computing the rest:
for (var n in hugeSequence().take(5)) {
print(n);
}
With a List, you compute all 10 million items even if you need only 5.
More Expressive Code
yield makes generators clean and readable.
Iterable<int> oddsUpTo(int n) sync* {
for (int i = 1; i <= n; i += 2) {
yield i;
}
}
When to Use async*
Use async* when data arrives over time:
Real-world Flutter examples:
Pagination in an infinite scroll list
Stream<List<Post>> fetchPosts() async* {
int page = 1;
while (true) {
final posts = await api.fetchPage(page++);
yield posts;
}
}
Listening to sensor data
Stream<double> accelerometerStream() async* {
while (true) {
yield await platform.getAccelerometerValue();
}
}
Recommended by LinkedIn
File download progress
Stream<double> downloadProgress() async* {
for (var p = 0.0; p <= 1.0; p += 0.1) {
await Future.delayed(Duration(milliseconds: 200));
yield p;
}
}
Bluetooth/Wi-Fi scanning
Stream<Device> scanDevices() async* {
while (true) {
final device = await scanResult();
yield device;
}
}
Benchmarks (Dart Code + Results)
Benchmark #1 — Building 1,000,000 items
Comparing:
Code:
import 'dart:math';
void main() {
final sw = Stopwatch()..start();
final list = List.generate(1000000, (i) => i);
sw.stop();
print("List generate: ${sw.elapsedMilliseconds} ms");
final sw2 = Stopwatch()..start();
final it = generateSync(1000000);
final consumed = it.last;
sw2.stop();
print("sync* generate: ${sw2.elapsedMilliseconds} ms");
}
Iterable<int> generateSync(int n) sync* {
for (var i = 0; i < n; i++) {
yield i;
}
}
Output (Typical):
List generate: 38 ms
sync* generate: 12 ms
sync* avoids allocating a full list, reducing memory usage.
Performance may vary, but sync* shines when not all values are consumed.
Benchmark #2 — Large sequence, but stop early
final sw = Stopwatch()..start();
final firstFive = generateSync(10000000).take(5).toList();
sw.stop();
print(sw.elapsedMilliseconds);
Output: 0–1 ms
Because it stops after 5 yields. A list version would take hundreds of milliseconds.
Benchmark #3 — async streaming (simulated API calls)
Stream<int> loadData() async* {
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration(milliseconds: 200));
yield i;
}
}
void main() async {
final sw = Stopwatch()..start();
await for (final v in loadData()) {}
sw.stop();
print(sw.elapsedMilliseconds);
}
Output: 1000–1020 ms (5 × 200ms delays)
Matches real async behavior Perfect for progressive results
SO,
sync* — Iterable<T>
Best suited for:
async* — Stream<T>
Best suited for:
When not to Use Generators
Final Thoughts
Both sync* and async* are powerful yet underused features of Dart. When applied correctly, they can significantly improve your code’s:
That said, generators are not a silver bullet.
While sync* is extremely memory efficient, it introduces iterator overhead. For small datasets, performance-critical loops, or scenarios requiring random access (list[index]), returning a List can be simpler and faster.
The key distinction is this:
Generators are optimized for laziness, not raw speed.
Use sync* when you want on-demand computation and early exits. Use async* when values arrive over time and must be handled asynchronously. Use List when the data is small, fixed, and reused frequently.
Choosing the right abstraction isn’t about preference—it’s about understanding the cost model of your code.