Skip to main content
Back to Blog
React NativeSocket.ioTypeScriptWebSocketsReal-TimeExpress.js

Implementing Real-Time Presence in React Native with Socket.io and TypeScript

Learn how to build a lightweight, production-ready real-time presence system (online/offline indicators) in React Native using Socket.io, Express.js, and strict TypeScript types.

August 31, 202613 min readNiraj Kumar

Introduction

If you've ever used WhatsApp, Slack, or Discord, you've seen that small green dot next to a contact's name — the one that tells you whether they're online, away, or offline right now. It looks trivial. It is not.

Real-time presence is one of those features that seems simple on the surface but quietly touches almost every layer of your stack: networking, state management, mobile lifecycle events, and even UX psychology (users trust apps more when they can see who's actually there).

In this tutorial, we're going to build a lightweight, production-grade presence system for a cross-platform React Native app. We'll use:

  • Socket.io for the WebSocket transport layer (with automatic reconnection handling)
  • Express.js as our backend HTTP + Socket.io server
  • TypeScript, in strict mode, to define bulletproof event contracts between client and server
  • React Native's AppState API to detect foreground/background transitions accurately

By the end, you'll have a working online/offline indicator that behaves correctly even when users lock their phones, lose signal in an elevator, or force-quit the app.

Let's get into it.


Why Presence Is Harder Than It Looks

Before writing any code, it's worth understanding why naive implementations break in production.

A common first attempt looks like this: "When a socket connects, mark the user online. When it disconnects, mark them offline." That's a reasonable starting point, but it falls apart quickly because:

  • Mobile networks are flaky. A brief tunnel, elevator, or Wi-Fi-to-cellular handoff can cause a disconnect/reconnect cycle every few seconds, making your UI flicker between "online" and "offline."
  • Backgrounding isn't disconnecting. On iOS and Android, an app moving to the background doesn't necessarily close the socket immediately — the OS may keep it alive for a short grace period, giving you a false "online" signal.
  • Multiple devices/tabs. A user might be logged in on their phone and a tablet simultaneously. Naively toggling a boolean breaks the moment one device disconnects while the other is still active.
  • Server crashes and restarts. If your Node process crashes, in-memory socket state disappears, and you need a strategy to reconcile presence on restart.

A robust presence system needs a heartbeat mechanism, a grace period before marking someone offline, and device-aware reference counting. We'll build all three.


Architecture Overview

Here's the high-level flow we're implementing:

  1. The React Native client establishes a Socket.io connection when the app is authenticated and in the foreground.
  2. On connect, the client emits a presence:online event with the user's ID.
  3. The server tracks connected sockets per user in an in-memory map (or Redis, for multi-instance deployments).
  4. The client sends a periodic heartbeat (presence:heartbeat) every 20–30 seconds.
  5. If the server doesn't receive a heartbeat or a socket disconnect signal within a grace window, it marks the user offline and broadcasts the change.
  6. The client listens for AppState changes and manually disconnects the socket (or pauses heartbeats) when backgrounded, then reconnects on foreground.
  7. All other connected clients subscribed to that user's presence receive a presence:update event and re-render their UI.

This gives us resilience against flaky networks while staying accurate about true foreground activity.


Setting Up the Backend

Project Initialization

Start with a fresh Node.js + TypeScript project for the server:

mkdir presence-server && cd presence-server
npm init -y
npm install express socket.io cors
npm install -D typescript ts-node-dev @types/node @types/express @types/cors
npx tsc --init

Update your tsconfig.json to enforce strict typing — this matters a lot once we start defining socket event payloads:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Defining Shared Event Types

One of the biggest wins of using TypeScript with Socket.io is defining typed event maps for both client-to-server and server-to-client events. This eliminates an entire class of "I forgot what payload shape this event expects" bugs.

Create src/types/events.ts:

export type UserId = string;

export interface PresenceUpdatePayload {
  userId: UserId;
  status: "online" | "offline";
  lastSeen: number; // Unix timestamp in ms
}

export interface HeartbeatPayload {
  userId: UserId;
}

export interface OnlinePayload {
  userId: UserId;
  deviceId: string;
}

// Events the CLIENT sends to the SERVER
export interface ClientToServerEvents {
  "presence:online": (payload: OnlinePayload) => void;
  "presence:heartbeat": (payload: HeartbeatPayload) => void;
  "presence:subscribe": (userIds: UserId[]) => void;
}

// Events the SERVER sends to the CLIENT
export interface ServerToClientEvents {
  "presence:update": (payload: PresenceUpdatePayload) => void;
  "presence:snapshot": (payload: PresenceUpdatePayload[]) => void;
}

export interface InterServerEvents {
  ping: () => void;
}

export interface SocketData {
  userId: UserId;
  deviceId: string;
}

Socket.io lets you pass these four generics directly into the Server and Socket types, so every .emit() and .on() call is fully type-checked at compile time — no more guessing payload shapes.

Building the Presence Server

Create src/server.ts:

import express from "express";
import cors from "cors";
import { createServer } from "http";
import { Server } from "socket.io";
import type {
  ClientToServerEvents,
  ServerToClientEvents,
  InterServerEvents,
  SocketData,
  PresenceUpdatePayload,
  UserId,
} from "./types/events";

const app = express();
app.use(cors());

const httpServer = createServer(app);

const io = new Server<
  ClientToServerEvents,
  ServerToClientEvents,
  InterServerEvents,
  SocketData
>(httpServer, {
  cors: { origin: "*" },
});

// Tracks how many active sockets each user currently has open.
// A user can have multiple devices/tabs connected simultaneously.
const activeConnections = new Map<UserId, Set<string>>();
const lastHeartbeat = new Map<UserId, number>();

const HEARTBEAT_TIMEOUT_MS = 45_000; // grace period before marking offline

function broadcastPresence(userId: UserId, status: "online" | "offline") {
  const payload: PresenceUpdatePayload = {
    userId,
    status,
    lastSeen: Date.now(),
  };
  io.emit("presence:update", payload);
}

io.on("connection", (socket) => {
  socket.on("presence:online", ({ userId, deviceId }) => {
    socket.data.userId = userId;
    socket.data.deviceId = deviceId;

    const devices = activeConnections.get(userId) ?? new Set<string>();
    const wasOffline = devices.size === 0;
    devices.add(socket.id);
    activeConnections.set(userId, devices);
    lastHeartbeat.set(userId, Date.now());

    if (wasOffline) {
      broadcastPresence(userId, "online");
    }
  });

  socket.on("presence:heartbeat", ({ userId }) => {
    lastHeartbeat.set(userId, Date.now());
  });

  socket.on("presence:subscribe", (userIds) => {
    const snapshot: PresenceUpdatePayload[] = userIds.map((userId) => ({
      userId,
      status: activeConnections.get(userId)?.size ? "online" : "offline",
      lastSeen: lastHeartbeat.get(userId) ?? 0,
    }));
    socket.emit("presence:snapshot", snapshot);
  });

  socket.on("disconnect", () => {
    const { userId } = socket.data;
    if (!userId) return;

    const devices = activeConnections.get(userId);
    devices?.delete(socket.id);

    if (devices && devices.size === 0) {
      broadcastPresence(userId, "offline");
    }
  });
});

// Sweep for stale connections whose heartbeat has expired,
// covering cases where the socket never emitted a clean "disconnect".
setInterval(() => {
  const now = Date.now();
  for (const [userId, last] of lastHeartbeat.entries()) {
    const devices = activeConnections.get(userId);
    if (devices?.size && now - last > HEARTBEAT_TIMEOUT_MS) {
      activeConnections.delete(userId);
      broadcastPresence(userId, "offline");
    }
  }
}, 15_000);

httpServer.listen(4000, () => {
  console.log("Presence server running on port 4000");
});

Notice the activeConnections map uses a Set<string> of socket IDs per user, rather than a boolean. This is what correctly handles the multi-device case — a user only goes "offline" when every one of their sockets has disconnected.


Setting Up the React Native Client

Installing Dependencies

npx expo install socket.io-client
# If using bare React Native (no Expo):
npm install socket.io-client

Creating a Typed Socket Client

Reuse the same event type definitions on the client so both sides of the wire agree on payload shapes. Create src/lib/socket.ts:

import { io, Socket } from "socket.io-client";
import type {
  ClientToServerEvents,
  ServerToClientEvents,
} from "../types/events";

const SOCKET_URL = "https://your-api-domain.com";

export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
  SOCKET_URL,
  {
    autoConnect: false,
    transports: ["websocket"],
    reconnectionAttempts: 5,
    reconnectionDelay: 2000,
  }
);

Setting autoConnect: false is intentional — we want full manual control over when the socket connects, tied to authentication state and app foreground/background transitions.

Building the Presence Hook

This is the core piece: a custom hook that manages connection lifecycle, heartbeats, and AppState transitions.

import { useEffect, useRef } from "react";
import { AppState, AppStateStatus } from "react-native";
import { socket } from "../lib/socket";

const HEARTBEAT_INTERVAL_MS = 25_000;

export function usePresence(userId: string | null, deviceId: string) {
  const heartbeatTimer = useRef<ReturnType<typeof setInterval> | null>(null);
  const appState = useRef<AppStateStatus>(AppState.currentState);

  useEffect(() => {
    if (!userId) return;

    function startPresence() {
      if (!socket.connected) {
        socket.connect();
      }
      socket.emit("presence:online", { userId: userId as string, deviceId });

      heartbeatTimer.current = setInterval(() => {
        socket.emit("presence:heartbeat", { userId: userId as string });
      }, HEARTBEAT_INTERVAL_MS);
    }

    function stopPresence() {
      if (heartbeatTimer.current) {
        clearInterval(heartbeatTimer.current);
        heartbeatTimer.current = null;
      }
      socket.disconnect();
    }

    startPresence();

    const subscription = AppState.addEventListener("change", (nextState) => {
      const goingToBackground =
        appState.current === "active" && nextState.match(/inactive|background/);
      const comingToForeground =
        appState.current.match(/inactive|background/) && nextState === "active";

      if (goingToBackground) {
        stopPresence();
      } else if (comingToForeground) {
        startPresence();
      }

      appState.current = nextState;
    });

    return () => {
      subscription.remove();
      stopPresence();
    };
  }, [userId, deviceId]);
}

Call this hook once, near the root of your authenticated app — typically inside a top-level provider component right after login succeeds.

Displaying Presence in the UI

Now let's build a small hook + component to subscribe to a specific set of users' presence and render an online indicator.

import { useEffect, useState } from "react";
import { socket } from "../lib/socket";
import type { PresenceUpdatePayload } from "../types/events";

export function usePresenceStatus(userIds: string[]) {
  const [statusMap, setStatusMap] = useState<Record<string, "online" | "offline">>({});

  useEffect(() => {
    if (userIds.length === 0) return;

    socket.emit("presence:subscribe", userIds);

    function handleSnapshot(snapshot: PresenceUpdatePayload[]) {
      const next: Record<string, "online" | "offline"> = {};
      snapshot.forEach((entry) => {
        next[entry.userId] = entry.status;
      });
      setStatusMap((prev) => ({ ...prev, ...next }));
    }

    function handleUpdate(payload: PresenceUpdatePayload) {
      setStatusMap((prev) => ({ ...prev, [payload.userId]: payload.status }));
    }

    socket.on("presence:snapshot", handleSnapshot);
    socket.on("presence:update", handleUpdate);

    return () => {
      socket.off("presence:snapshot", handleSnapshot);
      socket.off("presence:update", handleUpdate);
    };
  }, [userIds.join(",")]);

  return statusMap;
}

And a minimal indicator component:

import React from "react";
import { View, StyleSheet } from "react-native";

interface Props {
  status: "online" | "offline";
}

export function PresenceDot({ status }: Props) {
  return (
    <View
      style={[
        styles.dot,
        { backgroundColor: status === "online" ? "#22c55e" : "#9ca3af" },
      ]}
    />
  );
}

const styles = StyleSheet.create({
  dot: {
    width: 10,
    height: 10,
    borderRadius: 5,
    borderWidth: 1.5,
    borderColor: "#fff",
  },
});

Usage inside a contact list item:

function ContactRow({ userId, name }: { userId: string; name: string }) {
  const statusMap = usePresenceStatus([userId]);
  return (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
      <PresenceDot status={statusMap[userId] ?? "offline"} />
      <Text>{name}</Text>
    </View>
  );
}

Scaling Beyond a Single Server Instance

The in-memory Map approach works great for a single Node process, but the moment you deploy multiple server instances behind a load balancer, each instance has its own isolated view of who's connected. User A's socket might be on instance 1, while User B — checking A's presence — is connected to instance 2, which has no idea A exists.

The standard fix is the Socket.io Redis adapter, which broadcasts events across all instances via Redis pub/sub:

npm install @socket.io/redis-adapter redis
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));

For presence state itself (not just event broadcasting), it's also worth moving activeConnections out of a local Map and into Redis using a data structure like a hash or sorted set, so any instance can query the true connection count for a user rather than relying on broadcast events alone.


Best Practices

  • Always use a grace period before marking someone offline. A 30–45 second heartbeat timeout absorbs normal network blips without flickering the UI.
  • Debounce presence UI updates on the client if you're rendering large lists, so a burst of presence:update events doesn't cause excessive re-renders.
  • Reference-count connections per user, not per socket, to correctly support multiple simultaneous devices.
  • Authenticate the socket connection, ideally via a short-lived token passed during the handshake, not just trusting a userId sent in the payload.
  • Use noUncheckedIndexedAccess in TypeScript to catch cases where you assume a map entry exists but it might not — a very common bug source in presence maps.
  • Throttle heartbeats sensibly. Too frequent, and you burn battery and bandwidth; too infrequent, and your offline detection becomes sluggish.
  • Persist a "last seen" timestamp, not just a boolean, so your UI can show "Active 5m ago" even when a user is technically offline.

Common Mistakes

  • Trusting socket.on("disconnect") alone. Mobile OSes can suspend apps without firing a clean disconnect event — you need the heartbeat sweep as a backstop.
  • Not handling AppState at all. Without it, a backgrounded app still holds its socket open in some cases, showing users as "online" when they're not actively looking at the app.
  • Broadcasting presence changes to everyone. For apps with many users, emit presence:update only to sockets that have explicitly subscribed to that user's status, not via a global io.emit().
  • Skipping shared TypeScript types between client and server. Duplicating event interfaces independently on both sides inevitably drifts out of sync.
  • Forgetting reconnection storms. If your server restarts, thousands of clients may reconnect simultaneously. Add jittered backoff (reconnectionDelay with randomization) to avoid a thundering herd.
  • Using a plain boolean for online status instead of a device/socket count, which breaks multi-device scenarios.

🚀 Pro Tips

  • Combine presence with a lightweight "typing..." indicator using the same socket connection — it's a natural extension of the infrastructure you've already built.
  • Use exponential backoff with jitter for reconnection attempts on the client to avoid overwhelming your server after an outage.
  • In production, put your Socket.io server behind a sticky-session-aware load balancer (or fully rely on the Redis adapter) since WebSocket connections aren't stateless like HTTP requests.
  • Consider batching presence snapshot requests — if a screen renders 50 contacts, subscribe once with an array of IDs rather than firing 50 separate presence:subscribe calls.
  • Add unit tests for your heartbeat sweep logic specifically; it's the part most likely to have off-by-one timing bugs that only show up under load.
  • Log presence transitions (onlineoffline) with timestamps server-side — this data is invaluable for debugging flaky connections reported by users.

📌 Key Takeaways

  • Real-time presence needs more than a connect/disconnect listener — it requires heartbeats, grace periods, and device-aware counting to be reliable.
  • React Native's AppState API is critical for distinguishing "backgrounded" from "truly disconnected."
  • Shared, strict TypeScript event types between your Express server and React Native client eliminate a huge class of integration bugs.
  • Horizontal scaling requires a shared adapter like Redis so presence state stays consistent across multiple server instances.
  • Small UX details — like showing "last seen" instead of just a binary dot — make presence features feel much more polished.

Conclusion

Presence indicators feel like a small UI detail, but as we've seen, building one that's actually reliable touches networking resilience, mobile lifecycle quirks, and careful state management. By combining Socket.io's reconnection handling, a heartbeat-based liveness check, React Native's AppState API, and strict TypeScript contracts between client and server, you get a presence system that holds up under real-world conditions — flaky networks, backgrounded apps, and multi-device users included.

From here, natural next steps include layering in typing indicators, "last active" timestamps, or even richer presence states like "away" and "do not disturb," all built on top of the same foundation you've established in this tutorial.


References

Discussion

All Articles
React NativeSocket.ioTypeScriptWebSocketsReal-TimeExpress.js

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.