A Flutter screen can create an Azure SignalR connection and then forget who owns it.
The user pops the route. The app is backgrounded. The user logs out.
If nobody stops the connection, subscriptions leak. If a provider is disposed too early, the feed dies while the user is still on a child route. If you reconnect and treat the socket as a perfect log, you will miss events, because a new connection ID is not a replay.
This post is about that ownership problem. It stays on the Flutter client and explores Flutter lifecycle APIs, Riverpod disposal rules, and SignalR reconnection behavior.
A note about implementation: The Dart snippets are illustrative patterns, not production-tested code. They have not been compiled or analyzed. Microsoft does not ship an official Dart SignalR client, so confirm the API behavior against the community client you use.
The Problem
A HubConnection is not a widget state field you can ignore in dispose. It holds a transport, an access token, group membership, and event handlers.
Something in your application architecture has to start it, stop it, and decide what leaving the screen actually means.
Three situations can look similar, but they require different decisions:
- The user leaves the feed route but stays signed in. You may want the connection gone.
- The operating system hides or pauses the app. The process may still be alive, or it may be killed without another callback.
- The user logs out. Every connection, token, and cached event ID must go.
Using one policy for all three situations can either leak resources in the background or interrupt the feed when the user simply opens another screen.
What SignalR Reconnection Means for Your Flutter App
Azure SignalR treats disconnects as normal.
Network instability, suspended applications, load balancer changes, and routine service maintenance can all interrupt connections.
That means retry and reconnection should be part of your connection lifecycle, not an afterthought.
The ASP.NET Core SignalR .NET client also demonstrates an important behavior: automatic reconnection is opt-in. With the default WithAutomaticReconnect() configuration, the documented retry delays are 0, 2, 10, and 30 seconds.
But there is something more important to understand.
A new connection ID does not mean your previous session has been restored.
SignalR group membership is not automatically preserved when a connection reconnects with a new connection ID.
Your client may need to:
- Rejoin previously subscribed groups.
- Restore event subscriptions.
- Retrieve missed messages.
- Synchronize its local state with the backend.
Azure SignalR also supports stateful reconnect, which can recover messages within a limited window when the same connection ID is retained.
However, a mobile application that has been paused for several minutes should not rely on this feature as its only recovery mechanism.
For reliable event delivery, an application-level catch-up API is still important.
Flutter Lifecycle Is Not a Reconnection Policy
Flutter provides several application lifecycle states:
resumedinactivehiddenpauseddetached
You can observe these states through WidgetsBindingObserver or AppLifecycleListener.
A few details matter when managing real-time connections on mobile devices.
Paused
The application is no longer visible or receiving input. Flutter's engine does not produce frame callbacks in this state.
Hidden
The application is not visible. On Android and iOS, Flutter synthesizes this state before paused.
Inactive
The application may still be visible, but it does not have input focus. A system dialog or temporary interruption can cause this state.
Detached
The application is detached from the host view.
One important detail: applications should not rely on receiving every lifecycle notification. If the user force-closes the app or the device loses power, there may be no final callback.
This is why simply stopping the connection in onPause and restarting it in onResume is incomplete.
When the application resumes:
- Inspect the connection state.
- Restart the connection if necessary.
- Rejoin required groups.
- Retrieve events missed during the interruption.
Also, avoid disconnecting the hub for every inactive event. Losing focus temporarily is not the same as logging out.
Riverpod: Dispose Is the Owner, KeepAlive Is a Decision
Riverpod provides automatic disposal, which can be useful when managing a connection that belongs to a particular screen.
When the last listener disappears and the provider is disposed, ref.onDispose gives you an opportunity to clean up resources.
For a SignalR connection, that cleanup should include:
- Stopping the hub connection.
- Cancelling event subscriptions.
- Disposing lifecycle listeners.
- Releasing other associated resources.
ref.keepAlive() is useful when a connection needs to survive the absence of listeners. But it should be an intentional architectural decision.
It should not be used simply because reconnecting is inconvenient.
A practical distinction:
Screen-scoped connection
A conversation screen that should stop receiving updates when its route is closed can use an auto-dispose provider watched by that screen.
Application-scoped connection
A notification connection that must remain available across multiple screens should be owned higher in the application tree.
Logout
When a user logs out, invalidate the relevant provider and ensure that its connection, credentials, and cached event state are cleaned up.
BLoC users should follow the same principle. Calling close() on a bloc that owns a hub connection should also stop that connection.
The important question is not which state management solution you use.
It is: Who owns the connection, and when should that ownership end?
An Illustrative Connection Ownership Pattern
The following example demonstrates the structure of a connection manager.
It is an illustrative abstraction rather than an implementation tied to a specific pub.dev package. Adapt the methods to the SignalR client you actually use.
abstract class RealtimeHub {
Future<void> start({
required Future<String> Function() accessToken,
});
Future<void> stop();
void onReconnected(
void Function(String? connectionId) handler,
);
Future<void> joinGroups(List<String> groups);
void dispose();
}
class EventCursor {
const EventCursor(this.lastEventId);
final String? lastEventId;
}
abstract class EventLog {
Future<EventCursor> loadSince(EventCursor cursor);
}
class HubSession {
HubSession({
required this.hub,
required this.eventLog,
required this.accessToken,
required this.groups,
required this.onEvents,
});
final RealtimeHub hub;
final EventLog eventLog;
final Future<String> Function() accessToken;
final List<String> groups;
final void Function(EventCursor cursor) onEvents;
EventCursor _cursor = const EventCursor(null);
AppLifecycleListener? _lifecycle;
bool _started = false;
Future<void> attach() async {
hub.onReconnected((_) {
// Restore subscriptions and missed events.
_restore();
});
_lifecycle = AppLifecycleListener(
onResume: () {
_ensureRunning();
},
);
await start();
}
Future<void> start() async {
await hub.start(accessToken: accessToken);
_started = true;
await _restore();
}
Future<void> _ensureRunning() async {
if (!_started) {
await start();
return;
}
try {
await _restore();
} catch (_) {
_started = false;
await start();
}
}
Future<void> _restore() async {
await hub.joinGroups(groups);
_cursor = await eventLog.loadSince(_cursor);
onEvents(_cursor);
}
Future<void> detach() async {
_lifecycle?.dispose();
_lifecycle = null;
await hub.stop();
hub.dispose();
_started = false;
}
}
The key idea is that the connection manager handles more than starting and stopping a socket.
It also restores group membership and retrieves missed events.
In a production implementation, you should additionally handle concurrent reconnect callbacks, start failures, event ordering, and duplicate event delivery.
Connecting the Session to Riverpod
A screen-scoped provider can own the session and clean it up when the provider is disposed.
Here is an illustrative example using the non-codegen auto-dispose provider API:
final hubSessionProvider = Provider<HubSession>(
isAutoDispose: true,
(ref) {
final session = HubSession(
hub: ref.watch(realtimeHubProvider),
eventLog: ref.watch(eventLogProvider),
accessToken: () => ref
.read(authRepositoryProvider)
.freshAccessToken(),
groups: const ['table-42'],
onEvents: (cursor) {
// Update application state with recovered events.
},
);
session.attach();
ref.onDispose(() {
session.detach();
});
return session;
},
);
The freshAccessToken() method should return a valid token or refresh it before returning.
Passing a token obtained only at login can cause problems when that token expires before the next reconnection.
One more architectural consideration:
If the feed must survive navigation to a detail screen, do not watch the provider only inside the route that gets popped.
Move ownership to a parent that remains mounted, or use an application-scoped provider with explicit invalidation on logout.
Catch-Up Is an API Problem
One of the most important details about real-time applications is that reconnecting does not guarantee that every event has been received.
If a connection drops and reconnects with a new connection ID, the client may have missed messages.
For applications where message delivery matters, I recommend maintaining an event cursor.
The basic approach:
- Assign every event a stable ID or sequence number.
- Store the last event ID successfully applied by the client.
- Reconnect and restore group membership.
- Ask the backend for events after the stored cursor.
- Continue processing live events.
There is another important detail: duplicate events.
A catch-up response and a live message may overlap. Make event processing idempotent using event IDs so the same event does not produce duplicate application changes.
The backend should be the source of truth for recovering missed events.
Common Mistakes to Avoid
Here are some mistakes worth watching for when implementing real-time functionality in Flutter:
1. Treating every lifecycle event as a logout
A temporary loss of focus should not necessarily terminate the connection.
2. Keeping connections alive without a clear owner
Using keepAlive everywhere can leave unnecessary connections, tokens, and event handlers running.
3. Assuming reconnect restores group membership
A new connection ID may require the client to rejoin its groups.
4. Using an expired access token
Always make sure your connection can obtain a fresh token when negotiating or reconnecting.
5. Assuming every missed message will be replayed
Use an application-level catch-up mechanism when reliable event delivery matters.
6. Forgetting cleanup
A connection that outlives its screen or authenticated session can create unnecessary resource consumption and unexpected behavior.
Limitations and Implementation Notes
A few limitations are important to keep in mind:
- Microsoft does not provide an official Dart SignalR client. Community packages have different APIs and capabilities.
- The code examples are illustrative and have not been compiled or production-tested.
- Stateful reconnect is a limited recovery mechanism, not a replacement for an application-level event store.
- Flutter lifecycle callbacks are not guaranteed when an application is terminated.
- This article does not cover hub-server scale-out, Azure SignalR unit capacity, or message protocol versions.
- The Riverpod provider example illustrates ownership. In production, expose an explicit startup path and handle startup failures visibly.
Always verify the implementation against the versions of Flutter, Dart, Riverpod, and your chosen SignalR package.
Key Takeaways
If there is one thing to remember from this discussion, it is that real-time connections need clear ownership.
Here are the main principles:
- Decide who owns the connection before calling
start(). - Use
ref.onDisposeor the appropriate disposal method to stop resources. - Treat
keepAliveas an architectural decision. - Rejoin groups after reconnection when required.
- Recover missed events through your own backend API.
- Observe Flutter lifecycle changes, but verify the connection when the app resumes.
- Fetch fresh access tokens when reconnecting.
- Explicitly clean up connections when users log out.
Good real-time application development is not just about establishing a connection. It is about managing that connection throughout the application's lifecycle.
How do you manage SignalR or other real-time connections in your Flutter applications?
Do you prefer a screen-scoped connection, an application-wide connection, or a combination of both?
I'd be interested to hear how other Flutter developers approach this.
References
- Understanding client disconnections and reconnection in Azure SignalR Service
- ASP.NET Core SignalR .NET client
- Manage users and groups in SignalR
- SignalR authentication and authorization
- Flutter AppLifecycleState
- WidgetsBindingObserver
- AppLifecycleListener
- Riverpod automatic disposal
Top comments (0)