Updated June 2026. Tested on Flutter 3.x and Dart 3. Part of the Techalyst Flutter series.
Almost every app loads something: a profile, a list, a search result. That data takes time to arrive, and if you wait for it the naive way the whole screen freezes. Dart's async model and the FutureBuilder widget are how Flutter loads data while keeping the UI responsive. Get these clear and a whole class of janky apps becomes smooth ones.
Futures and async/await
A Future is a value that is not ready yet but will be, or will fail. A function marked async returns a Future, and await pauses inside that function until the future completes, then gives you the value.
Future<User> fetchUser() async {
final response = await http.get(Uri.parse('https://api.example.com/user'));
return User.fromJson(jsonDecode(response.body));
}
Reading top to bottom, this looks synchronous, which is the point. await lets you write asynchronous code that reads like ordinary steps instead of nested callbacks.
The part that confuses people: Dart runs on a single thread, so how does await not block? Because it does not block the thread, it suspends the function. While that function is waiting for the network, Dart is free to keep handling everything else, including rendering frames. That is why the UI stays alive during a load.
Handling errors
A future can fail, so wrap awaited calls in a normal try/catch:
Future<User> fetchUser() async {
try {
final response = await http.get(...);
return User.fromJson(jsonDecode(response.body));
} catch (e) {
throw Exception('Could not load user: $e');
}
}
No special async error syntax, just the try/catch you already know.
Showing the result with FutureBuilder
Awaiting data is half the job. The other half is showing the right thing while it loads, when it fails, and when it arrives. FutureBuilder does this. You give it a future, and it rebuilds as that future moves through its states, handing you a snapshot of where things stand.
FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Something went wrong: ${snapshot.error}');
}
return Text(snapshot.data!.name);
},
)
The snapshot tells you everything: connectionState for whether it is still waiting, hasError and error for failures, and data for the result. Handle all three and your UI always shows something sensible.
The trap everyone hits once
Notice that the example uses _userFuture, not fetchUser() directly. This matters. FutureBuilder rebuilds, and if you create the future inside build, a new request fires on every single rebuild, which can mean an infinite loop of loading:
// Wrong: a new future every rebuild
FutureBuilder(future: fetchUser(), builder: ...)
Create the future once and hold onto it, usually in initState on a stateful widget:
late final Future<User> _userFuture;
@override
void initState() {
super.initState();
_userFuture = fetchUser(); // created once
}
Now the same future is reused across rebuilds, and the request runs a single time. This one habit fixes most "why does my screen keep loading" bugs.
When it is a stream, not a future
A future gives you one value, once. When data arrives continuously, like a chat feed or a live location, you want a Stream and its companion widget instead. That is a close cousin of everything here, covered in streams and StreamBuilder.
Wrapping up
Async in Flutter comes down to a few reliable pieces. A Future is a value coming later, async and await let you handle it in straight-line code without freezing the thread, and try/catch covers failures. FutureBuilder turns that future into UI by giving you a snapshot for the waiting, error and data states. Just remember to create the future once and hold it, not inside build, and your loading screens will behave.
All comments ()
No comments yet
Be the first to leave a comment on this post.