Back to blog
Flutter

Keeping a Long-Lived gRPC Stream Alive on Flutter

July 5, 202611 min read

The Stream That Was Always "Connected"

Sync is a realtime super app. At the heart of it is a single bidirectional gRPC stream between the Flutter client and the server that stays open for the whole session. Messages, presence, typing indicators, call signaling, sync updates: everything rides that one pipe.

Then we shipped it. "Messages arrive 40 seconds late." "I have to kill the app and reopen it to get anything." "It says I'm online but nobody can reach me." Meanwhile the server dashboards were all green: thousands of streams open and healthy. The phones disagreed.

That gap turned out to be the whole story of running a persistent stream on mobile. Here's what a few weeks of chasing it taught me.

The Phone Is Not a Server

A mobile app does not get to decide when it runs. Unlike a server process, it stays alive only as long as the OS allows, and the moment it leaves the foreground it can be shut down.

On iOS, when the user swipes to the home screen, you get roughly 30 seconds of applicationDidEnterBackground grace before the app is suspended. Suspended means frozen: your Dart isolate stops executing, your timers don't fire, and the OS can reclaim your open sockets whenever it wants memory or the radio goes idle. Android is more generous and less predictable: Doze mode, background execution limits, and aggressive OEM battery managers (Xiaomi and Oppo were our worst offenders) will all suspend your process and drop your connection.

The nasty part is that when the OS tears down the socket underneath you, the gRPC channel often does not find out. There's no RST, no graceful GOAWAY, no error surfaced to your stream. The StreamController on the Dart side just goes quiet, and as far as your code is concerned the stream is still open.

So I could not trust "no error" to mean "connected." The client has to actively prove the stream is alive, and it has to tell the difference between "backgrounded on purpose" and "the network vanished."

When the Socket Dies Quietly

Backgrounding was only half of it. The other half was network churn.

Walk from Wi-Fi into the elevator and the phone drops to cellular. Walk back and it flips to Wi-Fi. Ride the subway and you cycle through no signal, one bar, and back a dozen times in ten minutes. Every one of those transitions can hand you a different IP address and silently orphan the TCP connection under your gRPC stream.

A handoff from Wi-Fi to cellular does not produce a clean close. The old socket is bound to an interface that no longer routes anywhere, so packets you send go nowhere. TCP retransmits for a long time before giving up, and gRPC's own defaults were even more patient. We had streams that were dead for two full minutes while the client sat there buffering outgoing messages that would never leave the phone.

I confirmed it with airplane mode, a stopwatch, and a proxy logging every frame. Turn the radio off, send three messages, turn it back on. On a healthy connection they delivered in under a second. On the orphaned socket they sat for 90+ seconds and then the whole stream collapsed at once. That test became my regression check for the rest of the project.

Detecting a Stream That Only Looks Alive

The fix is to stop believing the stream and start believing the clock. Two pieces did the work: heartbeats and a watchdog.

Heartbeats first. gRPC has HTTP/2 keepalive pings and we turned those on: a ping every 20 seconds with a timeout of 10 seconds. Keepalive at the transport layer only tells you the socket is reachable, not that the other end's stream handler is processing your frames. So on top of it we added our own heartbeat message to the stream's protobuf: the client sends a Ping with a sequence number, the server echoes a Pong. A few bytes, and it exercises the full path end to end.

The watchdog is what makes the heartbeat useful. Every time we receive anything from the server (a real message, a presence update, a Pong) we stamp a lastInboundAt timestamp. A repeating timer checks that stamp. If we've heard nothing for 30 seconds, comfortably more than two heartbeat intervals, we declare the stream dead regardless of what the StreamController believes, cancel it, and start reconnecting.

Treating silence as failure, instead of waiting for an error that never comes, cut our "phantom offline" reports by roughly 80% in the next release.

void _onInbound() {
  _lastInboundAt = DateTime.now();
}

void _tickWatchdog() {
  final silence = DateTime.now().difference(_lastInboundAt);
  if (silence > const Duration(seconds: 30)) {
    _stream.cancel();          // it's a corpse, stop trusting it
    _scheduleReconnect();      // backoff + jitter lives here
  }
}

Reconnecting Without a Thundering Herd

Once you accept that the stream will die often, reconnection becomes a core feature rather than an edge case. The naive version, retrying immediately in a tight loop, melts both the battery and the server.

We learned that during an early server deploy. The backend restarted, every client's stream dropped at the same instant, and every client reconnected at the same instant, then again, and again. We took down our own gateway for about 30 seconds.

The answer is exponential backoff with full jitter: start at roughly one second, double on each failed attempt, cap at around 30 seconds, and pick the actual delay at random between zero and the current ceiling rather than adding a small nudge. That smears attempts across time so ten thousand phones don't all knock in the same tick.

Two details mattered on the client. We reset the backoff to its floor as soon as a connection stayed healthy for more than a few seconds, so a brief blip didn't leave you waiting 30 seconds. And we made "the network is genuinely down" a distinct state from "the stream failed." If Connectivity reports no interface at all, there's no point burning battery on doomed attempts: we park, wait for a connectivity change event, and only then start the backoff loop.

Pause on Background, Resume on Foreground

The other shift was to cooperate with the OS. If iOS is going to suspend me anyway, I'd rather tear the stream down on my own terms than have the socket ripped out from under me.

Flutter surfaces lifecycle transitions through WidgetsBindingObserver and AppLifecycleState. On paused (or hidden on newer Flutter) we cancel the stream and flush any pending local writes, because we're about to be frozen and the socket won't survive it. We don't try to keep it open in the background: the execution budget is too small to justify the battery cost. Background delivery is push notifications' job.

On resumed we start over: open a fresh stream, run the resume handshake, restart the watchdog. Foregrounding is fast, and it's the moment the user is looking at the screen, so reconnecting in under a second is what makes the app feel "always on".

The subtle bug here was opening two streams at once. During a quick app switch, background then foreground within a second, the old teardown hadn't finished before the new connect fired, and we ended up with two streams and duplicate presence flapping. A state machine (disconnected, connecting, connected, reconnecting) that refuses to start a connect unless we're cleanly in disconnected fixed it.

Replaying What We Missed

Reconnecting the socket is not the same as being caught up. Every drop leaves a window where the client sent things the server never got, and the server sent things the client never received.

We solved it with sequence numbers in both directions and an acknowledgment cursor. Every outbound message carries a monotonically increasing client sequence id, and the server acknowledges the highest id it has durably stored. The client keeps unacknowledged messages in an outbox on the device, in the local database rather than memory, so a crash doesn't lose them. On reconnect, the resume handshake does two things: the client replays every outbound message above the last acknowledged id, and it tells the server the highest inbound sequence it has seen so the server can push everything after that.

Idempotency keeps replay safe. Because every message has a stable id, replaying one the server already has changes nothing on either end: the server deduplicates on the id, and the client does the same for incoming ones. That let us resend aggressively rather than risk losing a message, without spraying duplicates into the chat. My first version deduped only in memory, so a reconnect right after an app relaunch created duplicates; moving the check to the persisted sequence cursor closed that hole.

The Battery Bill

None of this is free. The radio is the expensive part: keeping it lit for frequent heartbeats drains the battery and burns cellular data even when nothing is happening.

We tuned three knobs. We widened the heartbeat interval on cellular, since keeping the mobile radio awake costs far more than pinging over Wi-Fi. We leaned harder on pausing in the background so we weren't holding the radio open when the user wasn't looking. And we kept the heartbeat to a couple of bytes, so the cost was waking the radio, not the payload. Idle background battery draw dropped by more than half once we stopped holding the stream open and let push notifications cover that gap.

A stream that stays open on a phone is not a connection you make once. You keep rebuilding it, against an OS that wants to suspend you, a network that keeps disappearing, and a battery that pays for both.

Key Takeaways

  • Silence is failure: Never trust "no error" to mean "connected"; use heartbeats plus a watchdog timer so the client actively proves the stream is alive.
  • Cooperate with the OS: Tear the stream down on background and open a fresh one on foreground instead of letting the system orphan your socket.
  • Backoff with full jitter: Synchronized reconnects will take down your own server; randomize delays across the whole backoff window and reset on a stable connection.
  • Reconnecting isn't resuming: Use sequence numbers, an outbox on the device, and idempotent replay to recover unacknowledged sends and request missed updates without duplicates.
  • Guard the battery: Holding a stream open is expensive; widen heartbeats on cellular, keep payloads tiny, and hand off background delivery to push notifications.

Read next

Offline-First Messaging: Reconciling Local SQLite With the Server

Building offline-first chat meant our on-device SQLite store and the server had to agree on reality after a reconnect, and the first version got that agreement spectacularly wrong.