If you've built anything beyond a simple counter app in Flutter, you've probably felt that sinking feeling: your UI freezes for a moment while your app parses a huge JSON file or processes a dataset. The culprit is almost always the same—you're running heavy work on the UI thread. In this article, I'll show you how isolates for heavy work keep your frame rate smooth and your users happy.
Understanding Dart's single-threaded model
Dart runs your entire app on a single isolate by default. Think of an isolate as a separate instance of the Dart runtime with its own memory heap and event loop. Your UI code, business logic, network calls, and database queries all compete for CPU time on this one isolate.
When you parse a 5 MB JSON file synchronously on the main isolate, the event loop stalls. Frames drop. Buttons don't respond. Users see jank.
Here's what happens under the hood:
- Frame 1 renders at 60 FPS
- Your code calls a blocking JSON parser
- The event loop can't process the next frame build
- Frames 2, 3, 4 are skipped
- User sees a visible stutter
The solution is to move that heavy work onto a different isolate, leaving the main isolate free to rebuild widgets and maintain 60 FPS.
Dart runs UI code on one isolate
By default, your Flutter app starts with a single isolate. All your widget builds, animation frames, and gesture handling happen on this main isolate. The Dart runtime schedules tasks on an event loop—microtasks have priority, then macrotasks like frame callbacks.
Real-world scenario: You're building a trading app that fetches a 10 MB order history CSV every 30 seconds. Parsing that CSV should take 200 ms, but doing it on the main isolate causes a 200 ms frame stutter. Users see lag when they're trying to tap buttons or scroll.
The architecture is straightforward: one isolate, one heap, one event loop. You can spawn additional isolates, but they're isolated from each other. They can't directly share Dart objects—they communicate via message passing.
// This runs on the main isolate
void main() {
runApp(const MyApp());
}
class HomePage extends StatefulWidget {
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late Future<List<Order>> orders;
@override
void initState() {
super.initState();
// This will block the main isolate
orders = _loadOrdersSync();
}
Future<List<Order>> _loadOrdersSync() async {
// Simulates parsing large data synchronously
final raw = await fetchOrdersJson();
return parseOrdersSync(raw); // 200 ms stutter here
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: orders,
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView(
children: snapshot.data!
.map((order) => OrderTile(order))
.toList(),
);
}
return const Center(child: CircularProgressIndicator());
},
);
}
}Heavy parsing blocks frames
Let's be concrete about what "heavy" means. In my experience across e-commerce, CRM, and trading platforms:
- JSON parsing of 1+ MB files: almost always blocks visibly
- Image processing (resizing, filtering): 50+ ms on main thread = dropped frames
- Regex operations on large strings: 100+ ms easily
- Sorting or filtering 10,000+ items: varies, but risky
- Crypto operations (hashing): always expensive
- CSV parsing with complex transformations: 100+ ms common
The key metric is: if your operation takes longer than 16 ms (the time budget for 60 FPS), it will drop frames. In practice, keep operations under 10 ms on the main thread to stay safe.
Here's a real scenario from a ride-hailing app I worked on: we were fetching ride history (500 rides, each with 10 fields). Parsing and sorting took 180 ms. On slower phones, it was worse. The app felt sluggish for a full second after opening the history screen.
// Problematic approach
List<RideHistory> _parseRideHistorySync(String jsonString) {
final List<dynamic> data = jsonDecode(jsonString);
// This takes ~180 ms on main isolate
return data
.map((json) => RideHistory.fromJson(json))
.toList()
..sort((a, b) => b.date.compareTo(a.date));
}The user initiates loading by tapping a button. They expect instant feedback. Instead, they tap, and nothing happens for 180 ms. That's noticeable jank.
Isolate.run moves work to another isolate
Dart provides a simple API: Isolate.run(). You pass a function, and Dart spawns a temporary isolate, runs that function there, collects the result, and shuts down the isolate. All while your main isolate keeps rendering frames.
This is perfect for one-off heavy computations. You don't manage the isolate lifecycle—Dart handles it.
The function you pass must be a top-level function or static method. It can't be a closure that captures variables from the calling isolate (in practice, avoid closures). The function's return value is sent back via message passing.
// Top-level function - runs on spawned isolate
List<RideHistory> _parseRideHistorySync(String jsonString) {
final List<dynamic> data = jsonDecode(jsonString);
return data
.map((json) => RideHistory.fromJson(json))
.toList()
..sort((a, b) => b.date.compareTo(a.date));
}
// Call it from main isolate
Future<List<RideHistory>> loadRideHistory() async {
final jsonString = await fetchRideHistoryJson();
// Spawns temp isolate, runs parsing there, returns result
return Isolate.run(
() => _parseRideHistorySync(jsonString),
);
}Practical benefit: the UI stays responsive. While the background isolate parses 500 rides, the main isolate renders a loading spinner, responds to user taps, and animates transitions smoothly.
I used this in a SaaS dashboard where we loaded financial reports (2+ MB JSON). The operation moved from a 400 ms freeze to an invisible background task. Users saw a spinner and could scroll around the app while waiting.
compute() is the simple helper
Flutter provides compute(), a wrapper around Isolate.run() that's slightly more ergonomic. It's defined in foundation.dart.
compute() works identically to Isolate.run()—you pass a top-level function and its argument. The only real difference is syntax and a tiny bit of error handling sugar.
I prefer Isolate.run() for modern codebases (it's more explicit), but many developers reach for compute() out of habit.
import 'package:flutter/foundation.dart';
// Same top-level function
List<RideHistory> _parseRideHistorySync(String jsonString) {
final List<dynamic> data = jsonDecode(jsonString);
return data
.map((json) => RideHistory.fromJson(json))
.toList()
..sort((a, b) => b.date.compareTo(a.date));
}
// Using compute()
Future<List<RideHistory>> loadRideHistory() async {
final jsonString = await fetchRideHistoryJson();
// compute(function, argument)
return compute(_parseRideHistorySync, jsonString);
}Under the hood, compute() calls Isolate.run(). Choose whichever feels clearer to you.
A complete example: e-commerce product parsing
Let me walk through a realistic scenario from an e-commerce platform I built. We fetch a product catalog (500 items, 50 KB JSON) and need to parse, filter, and sort by relevance.
// Domain model
class Product {
final String id;
final String name;
final double price;
final List<String> tags;
final double rating;
Product({
required this.id,
required this.name,
required this.price,
required this.tags,
required this.rating,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
tags: List<String>.from(json['tags'] ?? []),
rating: (json['rating'] as num).toDouble(),
);
}
}
// Heavy lifting - runs on background isolate
List<Product> _parseAndSortProducts(String jsonString) {
final List<dynamic> raw = jsonDecode(jsonString);
final products = raw
.map((json) => Product.fromJson(json as Map<String, dynamic>))
.toList();
// Sort by rating descending
products.sort((a, b) => b.rating.compareTo(a.rating));
return products;
}
// Widget using the isolate
class ProductListPage extends StatefulWidget {
@override
State<ProductListPage> createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
late Future<List<Product>> products;
@override
void initState() {
super.initState();
products = _loadProducts();
}
Future<List<Product>> _loadProducts() async {
final response = await http.get(Uri.parse('...'));
// Parsing happens on background isolate
return Isolate.run(
() => _parseAndSortProducts(response.body),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: FutureBuilder<List<Product>>(
future: products,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: Text('No products'));
}
final productList = snapshot.data!;
return ListView.builder(
itemCount: productList.length,
itemBuilder: (context, index) {
final product = productList[index];
return ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
trailing: Text('⭐ ${product.rating}'),
);
},
);
},
),
);
}
}The UI is snappy because parsing doesn't block the main isolate. While the background isolate decodes and sorts 500 items, the main isolate renders the loading spinner, handles taps, and maintains frame rate.
Common mistakes
Mistake 1: Passing closures instead of top-level functions. This doesn't work well because closures capture variables from the calling isolate, which creates issues with message passing.
// Don't do this
final data = await fetchData();
final result = await Isolate.run(
() => parseData(data), // Closure capturing 'data'
);Instead, pass the data as an argument to a top-level function.
// Do this
List<Item> parseDataSync(String rawJson) {
return jsonDecode(rawJson)
.map((j) => Item.fromJson(j))
.toList();
}
final data = await fetchData();
final result = await Isolate.run(
() => parseDataSync(data),
);Mistake 2: Spawning isolates for tiny operations. The overhead of spawning an isolate (message passing, memory allocation) is real. For operations under 10 ms, you're better off keeping them on the main thread. Use isolates only for heavy work.
Mistake 3: Not handling errors. Background isolates can throw exceptions. Always wrap calls in try-catch or handle rejections in FutureBuilder.
Future<List<Product>> _loadProducts() async {
try {
final response = await http.get(Uri.parse('...'));
return Isolate.run(
() => _parseAndSortProducts(response.body),
);
} catch (e) {
// Handle error gracefully
rethrow;
}
}Mistake 4: Assuming isolates are cheaper than async/await. Isolates are for CPU-heavy work. Network I/O is fine on the main isolate because it's not blocking—it's async. Don't over-isolate.
Key takeaways
- Dart apps run on one main isolate by default; all UI work competes for CPU on the event loop.
- Heavy operations (JSON parsing, image processing, regex on large data) block frames and create jank.
- Use
Isolate.run()to spawn a background isolate for expensive computations; pass top-level functions, not closures. compute()is a Flutter helper wrapper aroundIsolate.run(); both do the same job.- Keep the main isolate free to render frames. The difference between a responsive app and a sluggish one is often just one isolate call away.
- Only isolate work that takes 10+ ms; smaller tasks don't justify the overhead.
- Always handle errors from background isolates.






Comments
Loading comments…