If you've ever launched your app and stared at a white screen for what feels like an eternity before anything shows up, you're not alone. I've been there too. Recently, while working on a project that had grown quite a bit in complexity, I noticed that the startup time had become... let's say, uncomfortably long. The splash screen wasn't even getting a chance to say hello before the app had already made a bad first impression. That's when I realized I had a problem, and more importantly, that I had been thinking about app initialization the wrong way.
So in this article, we're going to fix that. We'll go through 7 practical tips that will help you dramatically reduce your Flutter app's startup time, eliminate that white screen, and give your users the smooth, snappy experience they deserve. Some of these are quick wins, others require a small shift in how you think about your app's startup flow. But all of them are worth it.
1. Move Heavy Work After runApp()
This is the most important one. You can skip the rest of the article and move on with your life after reading this (please don't). The root of most startup performance problems in Flutter comes down to a single habit: loading everything before calling runApp().
Think about what happens when your app launches. Flutter needs to paint the first frame on the screen as fast as possible. But if your main() function is busy initializing databases, fetching remote configs, setting up services, and registering half the universe before runApp() even gets called, Flutter has no choice but to wait. And while it waits... your user stares at a white screen.
The fix? Be ruthless about what truly needs to be ready before the first frame.
Ask yourself this question for every single thing you initialize: "Will the app crash or look broken without this on the very first frame?" If the answer is no, it doesn't belong before runApp().
In reality, the list of things that genuinely need to be ready before runApp() is much shorter than most people think. Here's what actually makes the cut:
- Theme → your app needs to know which colors and fonts to use from the very first pixel
- Language/Locale → same idea, the UI needs to know which language to render immediately
- Authentication state → you need to know if the user is logged in or not before deciding which screen to show first
That's pretty much it. Everything else, your analytics setup, your remote config, your ad services, your connectivity listeners, can wait. Initialize them after runApp(), in the background, while your user is already looking at something real.
2. Compress Your Assets
Are you still here? Great! This one is a quick win that most developers completely overlook. While you're focusing on the code side of initialization, your assets might be quietly working against you behind the scenes.
Think about your splash screen image. It's the very first visual your app needs to load, so if that image is a 2MB PNG that Flutter has to decode before painting anything, you've already lost precious milliseconds before the user sees a single pixel. The same goes for any font files or images that are part of your initial UI.
The fix here is simple: compress everything that gets loaded at startup.
For images, tools like TinyPNG or Squoosh can reduce file sizes dramatically, often by 60-80%, with little to no visible quality difference. For your splash screen specifically, consider using a simple vector image (SVG) or even a plain color with your logo instead of a heavy raster image. Flutter's native splash screen supports this out of the box and it's blazing fast.
For fonts, make sure you're only bundling the font weights you actually use. It's very easy to include an entire font family with 10 different weights when your app only uses 2 of them. Every unnecessary file is extra work Flutter has to do before your app feels alive.
A good rule of thumb: anything that gets loaded before or during your splash screen should be as lightweight as possible. Your goal is to make Flutter's job as easy as it can be in those critical first moments.
3. Show a Screen Immediately, Load the Real App Behind the Scenes
Here's the idea behind this one. Instead of making the user wait until every single thing is ready before showing anything, you show a simple screen instantly and then load the real app behind the scenes. By the time the user has registered what they're looking at, your app is already done initializing.
Think of it like a restaurant. A good waiter doesn't make you stand outside until your table is fully set, the food is cooked, and the wine is poured. They seat you immediately, hand you the menu, and let the kitchen do its thing in the background. You're already inside, you're comfortable, and everything else arrives naturally.
In Flutter, this pattern looks something like this:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load only the essential data
await setupEssentialServices();
runApp(const AppLoader());
}
AppLoader is not your real MaterialApp. It's a lightweight widget that shows something instantly, maybe your splash screen or a simple branded loading screen, while the rest of your services finish initializing in the background. Once everything is ready, you swap it out for your real MaterialApp.
class AppLoader extends StatefulWidget {
const AppLoader({super.key});
@override
State<AppLoader> createState() => _AppLoaderState();
}
class _AppLoaderState extends State<AppLoader> {
bool _isReady = false;
@override
void initState() {
super.initState();
_loadRemainingServices();
}
Future<void> _loadRemainingServices() async {
await setupRemainingServices();
setState(() => _isReady = true);
}
@override
Widget build(BuildContext context) {
if (!_isReady) {
return const MaterialApp(
home: SplashScreen(),
);
}
return const RealApp();
}
}
The beauty of this approach is that your user never sees a white screen. They land on your splash screen almost instantly, and by the time they're done admiring your beautiful logo, the app is fully ready to go.
4. Use compute() for Heavy Work
Let's talk about something that trips up a lot of Flutter developers: the UI thread. Flutter runs everything, your widgets, your animations, your build methods, on a single thread called the main isolate. Think of it as a single chef in a kitchen. As long as the orders are small and simple, everything runs smoothly. But the moment you ask that chef to also butcher a whole cow, debone a fish, and bake a soufflé at the same time, the orders start piling up and the whole kitchen grinds to a halt.
In Flutter terms, that "grinding to a halt" is what we call jank. Dropped frames, frozen animations, an unresponsive UI. And it often happens right at startup, when you're doing things like parsing a large JSON response, processing a big list of data, or running some heavy computation to set up your initial app state. So what's the solution?
Dart actually supports multiple isolates, which are independent threads of execution that don't share memory with each other. Think of it as hiring a second chef who works in a completely separate kitchen. They can handle the heavy lifting without ever interfering with the main kitchen's flow.
This is exactly where compute() comes in. It's Flutter's friendly wrapper around isolates that makes the whole thing approachable. You simply hand it a function and some data, it spins up a separate isolate, runs your function there, and hands you back the result. The main thread never feels a thing.
// A top-level or static function (required for compute)
List<WordModel> parseWords(String jsonString) {
final decoded = jsonDecode(jsonString) as List;
return decoded.map((e) => WordModel.fromJson(e)).toList();
}
// Using compute() to run it off the main thread
final words = await compute(parseWords, rawJsonString);
Notice that the function you pass to compute() must be a top-level or static function. This is because isolates don't share memory, so the function needs to be accessible without any context from the main isolate.
When should you actually use this?
Not everything needs compute(). Spinning up an isolate has its own small overhead, so using it for trivial work would actually make things slower. A good rule of thumb is to reach for compute() when:
- You're parsing a large JSON file or dataset
- You're doing heavy string manipulation or encryption
- You're processing images or running complex algorithms
- Any operation that takes more than a few milliseconds and makes your UI feel laggy
For anything quick and lightweight, just run it normally on the main thread. The chef can handle a simple sandwich without any help.
5. Use Lazy Loading for Your Dependencies
Let's talk about dependency injection and a mistake that's very easy to make without even realizing it. When you set up your app's services and dependencies at startup, there's a natural temptation to initialize everything upfront. It feels organized, it feels safe, it feels like you're in control. But here's the problem: most of those services won't be needed until much later in the app's lifecycle. Some of them might not even be needed at all during a particular session.
Initializing a service means allocating memory, running setup code, and sometimes even making network calls or reading from disk. If you're doing all of that for 15 different services before your app even shows the first screen, you're essentially paying a cost you don't need to pay yet. This is where lazy loading comes in.
The idea is simple: instead of initializing a service immediately when you register it, you tell your app "create this only when someone actually asks for it for the first time." Until that moment, it doesn't exist, it doesn't consume memory, and it doesn't slow down your startup. The difference looks something like this:
// Eager initialization — created immediately, whether you need it or not
final analyticsService = AnalyticsService();
// Lazy initialization — created only when first accessed
AnalyticsService? _analyticsService;
AnalyticsService get analyticsService {
_analyticsService ??= AnalyticsService();
return _analyticsService!;
}
It's a small change in how you think about your dependencies, but the impact on startup time can be significant, especially as your app grows and the number of services increases.
6. Use Future.wait() for Parallel Async Work
Alright, this one is all about efficiency. Let's say you have 3 services that need to be initialized at startup. The most natural way to write this is:
await initializeTheme();
await initializeLanguage();
await initializeAuth();
This looks clean and readable. But here's the problem: these three operations run sequentially. One after the other. initializeLanguage() doesn't even start until initializeTheme() is completely done. And initializeAuth() sits there waiting for both of them to finish before it even gets to wake up.
Cooking references are never enough... Imagine you need to boil three separate pots of water. The sequential approach would be: boil the first pot, wait until it's done, then start the second pot, wait until it's done, then start the third. That's... not how anyone actually cooks. You put all three pots on the stove at the same time and let them all boil together. Future.wait() is exactly that. It takes a list of futures and runs them all at the same time, then waits until every single one of them is done before moving forward.
await Future.wait([
initializeTheme(),
initializeLanguage(),
initializeAuth(),
]);
The total time is now determined by the slowest operation, not the sum of all of them. If each operation takes 300ms sequentially that's 900ms total. With Future.wait(), it's just 300ms. That's a 3x improvement with a one-line change
But wait, when can you actually use this?
This is the important part that a lot of articles skip over. Future.wait() is not a magic wand you can wave over every initialization call. It only works safely when your services are completely independent of each other. Think about it. If initializeAuth() needs the result of initializeDatabase() to do its job, you can't run them in parallel. Auth would try to use a database that isn't ready yet and everything would fall apart.
It's a very powerful tool, but it needs to be used thoughtfully!
7. Avoid Synchronous Work in main()
We've talked a lot about async operations so far, but there's another category of startup killers that's even more sneaky: synchronous work.
Here's why it's sneaky. When you await something async, Flutter at least has the opportunity to do other things while it waits. But synchronous code is different. It runs from start to finish without ever letting go of the thread. No pauses, no breaks, no mercy. The UI thread is completely blocked until every last line is done.
void main() {
final config = File('config.json').readAsStringSync(); // Synchronous file read
final decoded = jsonDecode(config); // Heavy JSON parsing
final settings = processSettings(decoded); // Complex computation
runApp(MyApp());
}
Every one of those lines is holding the UI thread hostage. Flutter cannot paint a single pixel until all of that is done. The user sees white. The rule here is simple: if it's heavy and it's synchronous, it has no business being in main().
The solution depends on what the work actually is:
- If it's I/O work: (reading files, accessing disk), swap the synchronous version for its async counterpart:
// Blocks the thread completely
final config = File('config.json').readAsStringSync();
// Frees the thread while waiting
final config = await File('config.json').readAsString();
-
If it's heavy computation (parsing, processing, transforming data), this is exactly the case for
compute()that we covered in Tip #4. Ship it off to a separate isolate and let the main thread breathe:
// Heavy parsing blocking the main thread
final settings = parseHeavyConfig(rawData);
// Same work, different thread
final settings = await compute(parseHeavyConfig, rawData);
Your main() function should be lean, fast, and humble. It should do the minimum amount of work needed to get something on the screen, and then get out of the way.
Conclusion
There you have it, my friends! Seven practical tips that can completely transform the way your app feels from the moment it launches. Let's do a quick recap of everything we covered:
- Move heavy work after
runApp()— be ruthless about what truly needs to be ready before the first frame - Compress your assets — don't let a heavy splash screen image undo all your hard work
- Show a screen immediately — perceived performance is just as important as real performance
- Use
compute()for heavy work — stop blocking the UI thread and let isolates do the heavy lifting - Use lazy loading for your dependencies — don't pay for services you're not using yet
- Use
Future.wait()for parallel async work — stop waiting in line when you can run everything at the same time - Avoid synchronous work in
main()— synchronous code is a silent killer that blocks the thread completely
Here's the bigger picture though. All seven of these tips share the same underlying philosophy. Your app should do the minimum amount of work needed to show something to the user as fast as possible, and defer everything else. Once you start thinking that way, you'll naturally write faster, more efficient Flutter apps without even trying. The white screen is not inevitable. It's a symptom of not prioritizing what truly matters at startup. And now that you know better, you can fix it.
If you enjoyed this article and want to stay connected, feel free to connect with me on LinkedIn.
Was this guide helpful? Consider buying me a coffee!☕️ Your contribution goes a long way in fuelling future content and projects. Buy Me a Coffee.


Top comments (1)
Coooooollllll 🎉