DEV Community

Cover image for Why your app still says connected after the network is gone
Ibrahim Hajjaj
Ibrahim Hajjaj

Posted on

Why your app still says connected after the network is gone

Two phones, one shared list. I add an item on the first phone. The second phone does not get it. Both phones show a green pill that says synced.

That is worse than an error. An error tells you to try something. A confident green pill tells you the other person is ignoring you.

Why the socket does not know

The app is offline-first on PowerSync, and the pill was reading PowerSync's own connection state, which comes from the socket.

A socket learns it is dead in one of two ways: the other end closes it cleanly, or a keepalive eventually fails to come back.

The clean close is the case everyone tests. Kill the server, watch the client flip to offline, done.

The other case is the one phones live in:

  • mobile data toggled off while the radio stays registered
  • a handoff to a cell that never completes
  • a socket that comes back from a long background period holding a file descriptor pointing at nothing

Nothing closes. No FIN arrives. From the client's side the connection is still open, and it stays open until the library's own heartbeat times out, which is measured in tens of seconds because a shorter one would burn battery reconnecting every time somebody walks into a lift.

For that whole window the indicator says synced and nothing syncs. And because the reconnect logic only fired once the engine admitted it was disconnected, no reconnect was kicked either. The app sat there, sure of itself, doing nothing.

The fix I had already shipped, which made this worse

Two weeks earlier I had fixed a different, real complaint. Bringing the app back to the foreground briefly drops and re-establishes the socket, so the pill flashed offline for a moment on every app switch. That flicker is exactly the jank you never see in apps people like, so I debounced it: hold the current state and only admit offline if the socket is still down after a grace window, with a reconnect inside the window cancelling the pending flip. Connections still reflect immediately, only disconnections wait.

Good fix. It also means the pill is now structurally slow to deliver bad news, sitting on top of a signal that was already slow to deliver bad news.

The two requirements pull opposite ways:

  • do not say offline for a blip that is about to resolve
  • do say offline the instant the connection is actually gone

You cannot satisfy both from one signal. You need a second one that is faster and independent.

The second signal is a boolean that is allowed to be null

The OS knows about the network before the socket does. expo-network exposes isConnected and isInternetReachable, and on the transports that populate them, they flip well before a heartbeat gives up.

The trap is that both are boolean | null, and null does not mean false. Some transports never populate them at all. Treat unknown as offline and you have built a new bug: an app that shows offline forever on hardware you do not own.

So the whole decision comes down to one function that refuses to guess:

// Only an EXPLICIT false means the OS is sure there is no internet.
// null/undefined stay "unknown", so an unknown reading never triggers
// a false offline.
export function osOffline(i: Pick<ConnInputs, 'osReachable' | 'osConnected'>): boolean {
  return i.osReachable === false || i.osConnected === false;
}
Enter fullscreen mode Exit fullscreen mode

Everything else falls out of that:

export interface ConnInputs {
  psConnected: boolean;         // PowerSync believes its socket is up
  psFlowing: boolean;           // an upload or download is in flight right now
  osReachable: boolean | null;  // null = unknown on this transport
  osConnected: boolean | null;
}

export function resolveSyncPill(i: ConnInputs): SyncPill {
  if (osOffline(i)) return 'offline';   // the OS wins; don't trust a lagging socket
  if (!i.psConnected) return 'offline';
  return i.psFlowing ? 'syncing' : 'synced';
}

// A socket PowerSync still calls "connected" while the OS says there is no
// internet is wedged. It needs a forced bounce, not a wait on the heartbeat.
export function needsForcedReconnect(i: ConnInputs): boolean {
  return i.psConnected && osOffline(i);
}
Enter fullscreen mode Exit fullscreen mode

Note what needsForcedReconnect does not fire on. If PowerSync already admits it is disconnected, ordinary retry handles it and forcing a bounce would just fight the backoff. The forced reconnect exists for one situation only: the two signals disagreeing in the specific direction where the socket is the one that is wrong.

Two things about the bounce itself, both learned the hard way:

Rate-limit it. A flapping link will otherwise storm reconnects. Ten seconds between forced bounces was enough.

Never clear the local database to fix a connection. It is a tempting big hammer and it is the wrong shape entirely: a wedged socket is recovered by re-establishing the socket. Wiping the replica costs the user a full resync to fix a problem that was never in the data.

Pulling this out into a module with no imports is what made it testable at all. The provider it came from cannot be instantiated outside a device. The decision can:

it('socket "connected" but OS says unreachable -> offline (was wrongly "synced")', () => {
  expect(resolveSyncPill({ ...base, osReachable: false })).toBe('offline');
});

it('unknown OS reachability (null) follows PowerSync (no false offline)', () => {
  expect(resolveSyncPill({ ...base, osReachable: null, osConnected: null })).toBe('synced');
});
Enter fullscreen mode Exit fullscreen mode

The regression that was already there

Forty-four minutes after the fix landed, the pill went back to lying, because of a line nobody thought was about connections at all. It was the oldest line in the repository.

The data layer refreshes a snapshot of everything the UI needs. That refresh had also been stamping the connection state in passing:

- sync: db.connected ? 'synced' : 'offline',
+ // The pill is owned solely by applyStatus/recompute (PowerSync status + OS
+ // reachability). A data refresh must NOT restamp it from db.connected alone,
+ // that would clobber an OS-driven "offline" back to "synced" while the socket
+ // is still wedged.
Enter fullscreen mode Exit fullscreen mode

That line was present in the repository's first commit, and it had been correct in every commit since. It is not stale code and whoever wrote it was not careless. When the socket was the only input, stamping the pill from the socket was simply the truth written down twice.

It became wrong the moment the same derived value acquired a second input. That happened in a different file, on the same afternoon, and every test stayed green.

Derived state needs exactly one owner. Not "one place that mostly sets it".

The same commit closed a second hole. The forced reconnect is await disconnectSync() then await connectSync(), and a sign-out or a component teardown can land in the gap between them, so the reconnect resurrects a connection something else just tore down:

await disconnectSync();
// Cleanup or a sign-out can land during the await; don't resurrect a
// connection the teardown/auth-change just tore down.
if (disposed.current || !currentUserId()) return;
await connectSync();
Enter fullscreen mode Exit fullscreen mode

Every await in a lifecycle-bound async function is a place the world is allowed to change underneath you. Re-check your preconditions after it, not just before.

The three parts worth stealing

  1. When a signal is structurally slow, add a faster independent one rather than tuning the slow one. No timeout value would have fixed the heartbeat, because the heartbeat is long for a good reason.
  2. null is not false. A tri-state input needs a tri-state decision. Only explicit evidence of absence counts as evidence; unknown stays unknown and defers to whatever else you have.
  3. One owner per piece of derived state. Adding an input to a derived value silently promotes every existing writer of that value into a potential bug. The writer did not change and was not wrong when it was written; the meaning of what it was writing changed underneath it. Nothing in the type system or the tests notices, because a two-state read of a now-tri-state world still typechecks and still passes.

The app is Psst, shared lists on iOS and Android. The indicator is honest now, which mostly means it tells you nothing is happening at the moment nothing is happening, instead of a minute later.

Top comments (1)

Collapse
 
christiaan_landman_f7d1ff profile image
Christiaan Landman

PowerSync team here. Good writeup, and your diagnosis is correct.

The service sends a periodic keepalive and the client should consider the connection broken when one doesn't arrive in time, but that handling isn't applied consistently across every connection path yet. Even once it is, immediate detection isn't something the SDK can do on its own: that needs platform connectivity APIs, which is exactly the second signal you built.

One thing worth trying: call disconnect() as soon as the OS reports no connectivity and connect() when it returns.

Happy to see this written up in public!