Your Flutter app is a marvel of declarative UI. But then you add a feature that needs to process a large file or run a complex calculation. Suddenly the smooth animations start to stutter. The app feels sluggish. When the user taps a button nothing happens for a second or two.
This is a common failure mode. The cause is almost always the same. You are doing too much work on the main thread.
Most of your Dart code runs on a single thread. This is often called the UI thread or the main isolate. It runs an event loop. This loop processes everything in sequence. It handles user input like taps and scrolls. It runs timers. It executes your Dart code. And it paints every single frame of your application to the screen.
A phone screen typically refreshes 60 or even 120 times per second. To maintain a smooth 60 frames per second you have about 16 milliseconds to do everything required to draw the next frame. If you run a function that takes 100 milliseconds to complete you have just blocked the main thread. During that time your app is frozen. It cannot respond to user input or draw new frames. You will drop six consecutive frames. The user sees a jarring stutter.
This is why UI jank happens. It is not magic. It is just a long running task blocking the event loop.
When developers first encounter this they often reach for the wrong tools. They might try to optimize the slow function with clever algorithms. They might try to break the work into smaller chunks using timers. Or they might add a complex state management library hoping it will solve the problem.
These approaches can sometimes help with minor issues. But they fail to address the fundamental problem. If a task is inherently computationally expensive like parsing a megabyte of JSON or applying a filter to an image then optimizing it will only make it slightly less slow. It will still block the UI. The real solution is not to do the work on the main thread at all.
This is where Dart Isolates come in. The name sounds academic but the concept is simple. An Isolate is Dart’s model for concurrency. For our purposes you can think of it as a separate thread.
Each isolate has its own memory heap ensuring that no state is shared between isolates. This avoids the entire class of concurrency problems like race conditions and deadlocks that plague other languages. They communicate with each other by passing messages.
In the past using isolates could be cumbersome. You had to manually create a ReceivePort
and a SendPort
to manage the communication. This complexity discouraged many developers from using them. But that is no longer the case.
Modern Dart gives us a much simpler API. Isolate.run()
is a high level function that handles all the complexity for you. You give it a function to execute and it does three things. It spawns a new isolate. It runs your function inside that new isolate with the arguments you provide. Then it returns the result to your main thread as a Future
.
It’s the tool you should reach for first when you have a CPU intensive task.
Let’s look at an example. Imagine a function that does some heavy math.
// A function that simulates heavy CPU work.
// In a real app this could be cryptography or data analysis.
int heavyComputation(int loopCount) {
int total = 0;
for (var i = 0; i < loopCount * 100000000; i++) {
total += i; // Just some dummy work.
}
return total;
}
// In your widget’s build method or an event handler:
onPressed: () async {
print('Starting computation...');
// The line below would freeze the UI for a few seconds.
// final result = heavyComputation(5);
// This line runs the work in another isolate. The UI stays smooth.
final result = await Isolate.run(() => heavyComputation(5));
print('Computation finished. Result: $result');
}
The change is minimal. You wrap your function call in Isolate.run()
. The app’s UI remains perfectly responsive while the work is happening in the background. The user can still scroll and tap other buttons. When the work is done the Future
completes and you get your result.
Because isolates do not share memory you cannot pass complex objects between them directly. You can only pass data that can be copied. This includes primitive types like null
bool
int
double
and String
. It also includes objects like List
and Map
whose elements are also sendable.
This limitation is a feature not a bug. It forces a clean separation between your computational logic and your UI. Your background function should be a pure function. It takes simple data as input and returns simple data as output. It should not know anything about widgets or build contexts.
Here is a more practical example involving parsing a large JSON response from a network API.
import 'dart:convert';
import 'dart:isolate';
// This function is designed to run in an isolate.
// It only takes a string and returns a list of maps.
List<Map<String, dynamic>> parseUsers(String jsonString) {
// jsonDecode can be slow for very large strings.
final List<dynamic> parsedJson = jsonDecode(jsonString);
return List<Map<String, dynamic>>.from(parsedJson);
}
// In your app’s data layer or view model:
Future<void> loadUserData() async {
// First get the raw data from the network.
final String rawJson = await fetchUsersJsonFromApi();
// Now parse it in the background.
// The UI can show a loading spinner without freezing.
final List<Map<String, dynamic>> users = await Isolate.run(() => parseUsers(rawJson));
// Now that we have the data update the UI.
updateUiWithUsers(users);
}
This pattern is extremely effective. You fetch the raw data as a string. Then you hand off the expensive parsing work to another isolate. The main thread is free to show a loading indicator or handle other user interactions.
Isolates are for CPU-bound work. This is work that actively uses the processor to perform computations.
You do not need isolates for IO-bound work. IO-bound work is when your program is waiting for something external like a network request or a file read. Dart’s Future
and async/await
syntax are designed to handle this perfectly without blocking the UI thread. When you await
an http.get()
call the main thread is not blocked. It is free to do other work.
So the rule is simple. If your code is waiting for input or output use async/await
. If your code is busy calculating something for more than a few milliseconds use Isolate.run()
.
The goal is to keep your application fluid. Users have little patience for apps that freeze or stutter. By moving heavy computational work off the main thread with isolates you ensure your UI is always ready to respond. It is one of the simplest and most powerful performance tools in the Flutter ecosystem.
Think about your own app and try to answer this for yourself: what’s one heavy task in your app that could run in the background?
— Rishi Banerjee
September 2025