Flutter Client That Works in the Real World: Three Solutions for Complex Scenarios
In theory, a Flutter app's architecture looks perfect: Bloc for state, Dio for API, go_router for navigation. In practice, real projects face issues beyond textbook examples—from user switches to background operations. How do you build a client that won't fall apart at the first deviation from the happy path?
Let's consider a full-featured language learning app with many complex functions: multi-provider authentication, anonymous login, translation, speech recognition, content generation, offline mode, and WebSocket connections. Standard packages (flutter_bloc, dio, get_it) solve individual tasks but don't cover scenarios where product logic goes beyond simple examples. Main issues arise with:
- Linking an anonymous user to a permanent account without data loss
- Working with a nominally available but unstable network
- Handling long operations (tens of seconds) without blocking the UI
- Proper identity switch on logout/login
- Guaranteed state cleanup after user logout
These cases require not just new packages, but fundamental architectural solutions. Here are three approaches that helped create a robust client.
Separation of Identity and Application Access
The standard approach—use Firebase Auth as the single source of truth. But with complex business logic, this leads to problems. For example, switching from anonymous login to Google registration loses data, and server access rules become tightly coupled to Firebase.
We separated responsibilities:
- Firebase handles identity (email/password, Google, Apple, anonymous login)
- Backend generates its own JWT token after identity confirmation
Workflow:
- User logs in via Firebase
- Client gets Firebase ID token
- Sends it to backend
- Backend returns application JWT
- All subsequent requests (REST and WebSocket) use this JWT
This solves three key problems:
- Provider independence: switching Firebase to another identity provider doesn't affect backend logic
- Transport unification: one token works for HTTP, WebSocket, and permissions management
- Smooth account linking: anonymous user retains data when upgrading to a permanent account
Critical mistake here—trying to use the Firebase token directly for API access. Backend must manage its own permissions and session lifecycle. Two-stage authentication adds complexity, but pays off in flexibility and real-world applicability.
Full Context Reset Instead of Manual Cleanup
Attempting to logically clear state after logout (zeroing TokenCubit, UserCubit, cache) inevitably leads to leaks. Especially in scenarios:
- User switch (logout → login under another account)
- Linking anonymous profile to registered
- Session recovery after expired token
- Returning from a deeply nested screen
Instead of spot cleaning, we recreate the entire provider context. Technical implementation:
- In the root widget AppInitializer, create a unique key
- On critical auth state changes (logout, user switch), change the key
- Subtree with MultiBlocProvider rebuilds from scratch
- All Cubits initialize via GetIt clean slate
final GlobalKey<_AppInitializerState> appContextKey =
GlobalKey<_AppInitializerState>();
class _AppInitializerState extends State<AppInitializer> {
Key _appKey = UniqueKey();
void resetApp() {
setState(() {
_appKey = UniqueKey();
});
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
key: _appKey,
providers: ProvidersManager.getAllProviders(),
child: widget.child,
);
}
}
This method is inelegant but predictable. After reset, the app behaves like a fresh session instance. Important:
- Clearly separate long-lived services (e.g., WebSocket connection) from screen states
- Configure DI so singletons aren't affected by recreation
- Don't use this for simple apps (fewer than 5 screens)
For complex projects, full context reset is cheaper and more reliable than endless manual sanitization.
Hybrid HTTP + WebSocket for Long Operations
The standard pattern final response = await dio.post(...) breaks on operations taking tens of seconds (text analysis, content generation, recognition). Problems:
- HTTP request timeouts
- Instability when app is backgrounded
- Poor UX during long waits
- Tricky error diagnostics
Solution—switch to a hybrid model:
- Client sends request with header
X-Async-Background: true - Server returns
task_idinstead of final result - Client subscribes to completion event via WebSocket
- After notification, fetches result with separate REST request
Key advantages:
- Evolvability: a fast endpoint can become background without client-side rewrites
- Reliability: WebSocket handles completion signal, REST handles result delivery
- Diagnostics: separate points for tracing, retries, and error handling
For a mobile client, crucial to implement:
- Automatic reconnect
- Heartbeat mechanism
- Message queue
- App lifecycle event handling
- Separate streams for status, data, and errors
This turns WebSocket from a mere "notification channel" into a core infrastructure layer. The X-Async-Background header serves as an interface to a ready backend solution, not a client hack.
What Matters: Key Takeaways
- Separate identity and access: Firebase/Apple/Google confirm identity, but don't manage resource access rights
- Reset context, don't clear state: on user switch, recreate the provider tree instead of manual cleanup
- Design for evolution: build long operations as HTTP (start) → WebSocket (signal) → HTTP (result)
- Test beyond happy path: anonymous login → registration, high-ping network, user switch in deep nav stack
- Isolate infrastructure layers: WebSocket as a system component, not an exception
These solutions emerged not from theoretical preferences, but after real-world failures. Flutter provides the tools, but an app's resilience depends on how you handle boundaries between components. Focusing on scenarios where things go wrong is the only way to build a production-ready client.
— Editorial Team
No comments yet.