Profile
Back to NewsBack
Dev.to 8 min
Reader Mode
Live data in React Native without polling: SSE, background handling and an honest status bar

Live data in React Native without polling: SSE, background handling and an honest status bar

18 hours ago

A wind turbine dashboard showed a value that looked perfectly fresh. It wasn't. The connection had dropped, the display had frozen, and nobody could tell.

In monitoring, that's the worst kind of bug. It doesn't crash. It just quietly lies.

This post shows how we built the live layer of the Windhunde app so that can't happen again: no polling, Server-Sent Events (SSE) end to end, and a status bar that always tells the truth.

TL;DR

  • Send a full snapshot first, then only deltas. Reconnecting becomes trivial.
  • In a native app, SSE is nicer than in the browser: you can send auth headers.
  • Keep the live logic in a platform-free TypeScript package and inject the transport.
  • When the app goes to the background, close the stream yourself. On return, show loading, never the last value.
  • Make freshness visible. A green dot says nothing about how old a value is.

The snippets are simplified to show the patterns. They are not copied from the production code.

Table of contents

  1. The setup
  2. The pipeline: from turbine to phone
  3. Why SSE fits a native app better than the browser
  4. Share the hard part between web and app
  5. The OS kills your connection and won't tell you
  6. An honest status bar
  7. Charts that don't lie
  8. What we left off the stream

The setup

Windhunde operates and services wind turbines. The platform has a web dashboard and a React Native app (Expo, Expo Router, Hermes, New Architecture) for iPhone, iPad and Android.

Three screens of the Windhunde app on iPhone: live dashboard, turbine detail with measured values and a mirror of the controller display, and the alarm list

The brief was "1:1 with the web". Wrapping the dashboard in a WebView was ruled out. So was rewriting every formula for mobile, because that produces two versions of the truth within months.

The pipeline: from turbine to phone

The turbine controllers are old. They don't have a modern API. Each one exposes a ~600-byte XML file that mirrors its four-line text display.

Diagram of the live pipeline: turbine controller XML polled every 2 seconds with If-None-Match, a poller that writes only on change, PostgreSQL with TimescaleDB firing NOTIFY, a stream service that LISTENs and sends SSE snapshot then deltas to the web dashboard via cookie and to the mobile app via Authorization header

Here's the flow:

  1. A poller asks each controller for its state every two seconds. It sends a conditional GET with the last ETag. If nothing changed, the controller answers 304 with no body, and nothing is written.
  2. On a real change, the importer writes a row to PostgreSQL (with TimescaleDB) and fires a NOTIFY.
  3. A stream service LISTENs for that signal.
  4. Every connected client gets a full snapshot of all turbines when it connects, then only deltas.

There is no polling in the web dashboard, and none in the app.

That snapshot-then-deltas contract is the backbone of everything that follows. It makes reconnecting trivial: you just connect again and start from a fresh snapshot.

Why SSE fits a native app better than the browser

In the browser, EventSource can't send custom headers. So you can't pass a bearer token, and the web dashboard has to use cookies.

A native app doesn't have that limitation. With react-native-sse, the app sends its token and tenant as normal headers:

import EventSource from "react-native-sse";

export function openStream(token: string, tenantId: string) {
  return new EventSource(`${API_URL}/stream/live`, {
    headers: {
      Authorization: `Bearer ${token}`,
      "X-Tenant": tenantId,
    },
  });
}

Same endpoint as the web. Cleaner transport.

Share the hard part between web and app

We moved the shared logic into one TypeScript package that both clients import:

  • number and unit formatting
  • freshness rules
  • mapping turbine states to colours
  • API schemas and the API client with retry logic
  • the core of the live connection: merging snapshot and deltas, reconnect rules, stall detection

The package has one strict rule: no browser globals and no hard-coded network calls. No window, no document, no localStorage. Anything that depends on the device is passed in from outside.

// shared/live.ts: runs in the browser and in React Native
export interface StreamTransport {
  // opens the stream and returns a function that closes it
  open(onEvent: (type: string, data: string) => void): () => void;
}

export function createLiveStore(transport: StreamTransport, notify: (s: State) => void) {
  let state: State = {};
  let close: (() => void) | null = null;

  function handle(type: string, data: string) {
    const payload = JSON.parse(data);
    if (type === "snapshot") state = indexById(payload.turbines);
    if (type === "delta") state = { ...state, [payload.id]: { ...state[payload.id], ...payload } };
    notify(state);
  }

  return {
    connect() { close?.(); close = transport.open(handle); },
    disconnect() { close?.(); close = null; },
  };
}

The web passes in a wrapper around the browser's EventSource. The app passes in react-native-sse.

One test loads the package under plain Node, with no DOM at all. If it's green, the code runs in both worlds. The extraction only counted as done once the web dashboard ran unchanged afterwards, without a single new warning.

The same idea applies to design. The web defines around a hundred design tokens. A script generates a TypeScript file from the web stylesheet, and the app reads its colours from there through NativeWind. Change a colour on the web, run the script, and the app follows.

The OS kills your connection and won't tell you

This is the part the browser hides from you.

iOS and Android drop network connections when an app goes to the background. They don't ask, and they don't send an error. If you ignore that, the app comes back showing frozen values that look exactly like live ones.

Diagram of the app lifecycle: active with stream open, close the stream when going to background, show loading when coming back, reconnect and get a fresh snapshot; reconnect on Wi-Fi to cellular switch; mark values stale after 60 seconds without data

So the app handles it explicitly:

import { AppState } from "react-native";
import NetInfo from "@react-native-community/netinfo";

useEffect(() => {
  live.connect();

  const appSub = AppState.addEventListener("change", (s) => {
    if (s === "active") {
      markLoading();   // show loading, NOT the last known value
      live.connect();  // fresh snapshot, then deltas
    } else {
      live.disconnect(); // close it ourselves before the OS does
    }
  });

  // Wi-Fi <-> cellular: reconnect right away
  const netSub = NetInfo.addEventListener((s) => {
    if (s.isConnected) live.connect();
  });

  return () => { appSub.remove(); netSub(); live.disconnect(); };
}, []);

The key line is markLoading(). Until the new snapshot arrives, the screen shows a loading state. It never shows the last known value as if it were current.

An honest status bar

Every live screen has a bar at the top, for example "Live · updated 1 s ago".

Windhunde dashboard on iPhone with the status bar reading Live, updated 1 s ago, key figures for turbines online and critical alarms, and a live power trend chart

A one-second tick updates the age of each value. After 60 seconds without new data, the value counts as stale and the bar says so.

const STALE_AFTER_MS = 60_000;

export function freshness(lastUpdate: number, now = Date.now()) {
  const age = now - lastUpdate;
  return { ageSeconds: Math.floor(age / 1000), stale: age > STALE_AFTER_MS };
}

This bar isn't decoration. It exists because of the incident at the top of this post. Since then one rule applies across the whole project: no silently swallowed errors, not on the server and not in the UI. If something is wrong, you can see it.

The turbine list follows the same idea. Each card shows "last contact 0 s ago" prominently, because a green marker alone tells you nothing about whether the value is one second or one hour old.

Charts that don't lie

The power chart starts with real history and then keeps growing from the live stream. Three rules keep it honest:

  • Gaps stay gaps. If measurements are missing, the line is not drawn across the hole. An outage must not look like a smooth curve.
  • The time axis ends at the last real data point, not at the device clock. Otherwise a stalled connection would look like a drop in power.
  • "Server error" and "no data in this range" are two different states, because they mean two different things.

What we left off the stream

Alarms deliberately don't hang off the live stream.

They are discrete events, not a continuous signal. And an alarm you can only see while the app is open in the foreground is useless for a control room. So alarms are fetched like normal data, and push delivery for them is already built into the backend.

How we knew it was done

A feature only counted as done after a verification loop on real devices against the production instance:

  • App and web show the same live value in the same second (two screenshots side by side).
  • After returning from the background, the app fetches a fresh snapshot.
  • After airplane mode on and off, it reconnects without a restart.
  • It runs for 30 minutes without a single error in the server log or the app console.

These checks are what produced the status bar and the clean background disconnect in the first place.


How do you handle stale data in your realtime apps? Polling, WebSockets, SSE, something else? Tell me in the comments, I'm curious what works for you.

I'm Andy Staudinger, a freelance developer in Germany building websites and mobile apps for iOS and Android. The full Windhunde case study (in German) is here. Previous post in this series: what two production React Native apps taught us in 2026.

Chat with me