All parts · Chat at Scale
Real-Time Chat at Scale — Part 06Real-time

Presence and Typing Indicators with Redis

Track who is online across tabs and devices with Redis heartbeats, survive crashed nodes, debounce flapping, and ship typing indicators that never touch the database.

Syed Wajeeh Haider
Syed Wajeeh Haider
Full Stack Software Engineer
Sep 28, 20269 min read
#Presence#Redis#Socket.IO#Real-time

In part 5, Relay learned to never lose a message: a client outbox, acknowledgements, retries and catch-up from the last seen ID. Messages are durable state, and we treated them that way. This part is about the opposite kind of state: the green dot next to a name and the three bouncing dots while someone types. Get these wrong and the app feels dead or, worse, lies to people. Get them right and they cost almost nothing, because they never touch Postgres.

0
Database writes per typing event
30s
Presence heartbeat interval
5s
Grace period before showing offline

Ephemeral state is a different animal

A message must survive a server restart, a deploy and a network partition. A typing indicator is worthless two seconds after it was sent. Presence sits in between: it matters right now, it should heal itself when things break, and nobody will ever audit last Tuesday's online status. Mixing these two kinds of state is the most common mistake in chat backends, and it usually shows up as a presence table being hammered with updates on every connect and disconnect.

  • —Durable state (messages, memberships, read cursors) lives in Postgres, is written once, and is read back with pagination.
  • —Ephemeral state (online status, typing, which socket is where) lives in Redis or in memory, expires on its own, and is safe to lose.
  • —Derived state (last seen at) is written to Postgres rarely, only when a user actually goes offline, not on every heartbeat.

One user, many connections

The naive presence model is a boolean per user: set it on connect, clear it on disconnect. It breaks the first time someone opens Relay in two tabs. Close one tab and the disconnect handler marks them offline while the other tab is still open. The same happens with a phone and a laptop, or with a reconnect that lands before the old socket has timed out.

The fix is to track connections, not users. A user is online when they have at least one live connection. In Redis, a sorted set per user works well: each member is a socket ID and the score is the time that connection expires unless it sends another heartbeat. Counting live connections is a single ZCOUNT from now to infinity, and stale entries from a crashed node simply age out.

ts
import type { Redis } from 'ioredis';

const TTL_MS = 60_000; // two missed heartbeats and the connection is considered dead
const key = (userId: string) => `presence:${userId}`;

// Atomically: prune stale sockets, add this one, report whether the user was offline before.
const CONNECT = `
  local now = tonumber(ARGV[1])
  redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
  local before = redis.call('ZCARD', KEYS[1])
  redis.call('ZADD', KEYS[1], now + tonumber(ARGV[2]), ARGV[3])
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
  redis.call('ZADD', KEYS[2], now + tonumber(ARGV[2]), ARGV[4])
  return before
`;

export async function markConnected(redis: Redis, userId: string, socketId: string) {
  const before = (await redis.eval(
    CONNECT, 2, key(userId), 'presence:users', Date.now(), TTL_MS, socketId, userId,
  )) as number;
  return before === 0; // true: offline -> online transition
}

export async function heartbeat(redis: Redis, userId: string, socketId: string) {
  const expires = Date.now() + TTL_MS;
  await redis
    .multi()
    .zadd(key(userId), expires, socketId)
    .pexpire(key(userId), TTL_MS)
    .zadd('presence:users', expires, userId)
    .exec();
}

export async function markDisconnected(redis: Redis, userId: string, socketId: string) {
  await redis.zrem(key(userId), socketId);
}

export async function liveConnections(redis: Redis, userId: string) {
  return redis.zcount(key(userId), Date.now(), '+inf');
}
server/src/presence.ts: connection-level presence in a sorted set

Two details are doing a lot of work here. The Lua script makes "prune, count, add" atomic, so two tabs connecting at the same instant cannot both decide they are the first one. And the global presence:users sorted set records the latest expiry per user, which we will use to clean up after crashes.

Heartbeats and surviving a crashed node

Socket.IO already pings every connection (every 25 seconds by default), but those pings live inside one process. If a node is killed with SIGKILL or loses power, no disconnect handler runs and Redis still believes thousands of sockets are alive. Expiry scores fix the data: without heartbeats, the entries fall out of the window after 60 seconds. They do not fix the notifications: nobody tells contacts that those users went offline.

So each connection refreshes its score on an interval, and a small sweeper periodically looks for users whose newest heartbeat has expired. Because the sweeper might run on every node, it claims each user with ZREM first: only the node that actually removed the entry announces the change.

ts
const HEARTBEAT_MS = 30_000;
const OFFLINE_GRACE_MS = 5_000;

io.on('connection', async (socket) => {
  const { id: userId } = socket.data.user;

  if (await markConnected(redis, userId, socket.id)) {
    pendingOffline.get(userId)?.close();
    await announce(userId, { online: true });
  }

  const beat = setInterval(() => heartbeat(redis, userId, socket.id), HEARTBEAT_MS);

  socket.on('disconnect', async () => {
    clearInterval(beat);
    await markDisconnected(redis, userId, socket.id);
    scheduleOfflineCheck(userId);
  });
});

// Every 15s: find users whose newest connection has expired and announce them offline.
setInterval(async () => {
  const stale = await redis.zrangebyscore('presence:users', '-inf', Date.now(), 'LIMIT', 0, 500);
  for (const userId of stale) {
    const claimed = await redis.zrem('presence:users', userId); // only one node wins
    if (claimed && (await liveConnections(redis, userId)) === 0) {
      await announce(userId, { online: false, lastSeenAt: new Date().toISOString() });
    }
  }
}, 15_000);
Wiring presence into the connection lifecycle

Debounce the flapping

Mobile networks drop and recover constantly. A phone switching from Wi-Fi to cellular disconnects and reconnects within a couple of seconds, and a page refresh does the same. If every disconnect immediately broadcasts "offline", contacts see the dot flicker. Worse, each flicker is a fan-out to every contact.

The cure is a grace period. When the last connection closes, wait a few seconds and check again. If the user reconnected in the meantime, nothing is announced at all. Because the check reads Redis rather than local memory, it works even when the reconnect lands on a different node.

ts
const pendingOffline = new Map<string, NodeJS.Timeout>();

function scheduleOfflineCheck(userId: string) {
  clearTimeout(pendingOffline.get(userId));
  pendingOffline.set(
    userId,
    setTimeout(async () => {
      pendingOffline.delete(userId);
      if ((await liveConnections(redis, userId)) > 0) return; // came back
      const lastSeenAt = new Date();
      await pool.query('UPDATE users SET last_seen_at = $1 WHERE id = $2', [lastSeenAt, userId]);
      await announce(userId, { online: false, lastSeenAt: lastSeenAt.toISOString() });
    }, OFFLINE_GRACE_MS),
  );
}
Announce offline only if the user stays gone

Only tell the people who care

Broadcasting every presence change to every connected user is O(users squared) and leaks information. Relay only notifies people who share a conversation with the user. We already give every socket a personal user:<userId> room from part 3, so announcing is a matter of emitting to each contact's room. The list of contacts changes slowly, so cache it in Redis with a short TTL instead of querying Postgres on every change.

ts
async function contactsOf(userId: string): Promise<string[]> {
  const cacheKey = `contacts:${userId}`;
  const cached = await redis.smembers(cacheKey);
  if (cached.length) return cached;

  const { rows } = await pool.query<{ user_id: string }>(
    `SELECT DISTINCT m2.user_id
       FROM conversation_members m1
       JOIN conversation_members m2 ON m2.conversation_id = m1.conversation_id
      WHERE m1.user_id = $1 AND m2.user_id <> $1`,
    [userId],
  );
  const ids = rows.map((r) => r.user_id);
  if (ids.length) await redis.multi().sadd(cacheKey, ...ids).expire(cacheKey, 300).exec();
  return ids;
}

async function announce(userId: string, status: { online: boolean; lastSeenAt?: string }) {
  const rooms = (await contactsOf(userId)).map((id) => `user:${id}`);
  if (rooms.length) io.to(rooms).emit('presence:update', { userId, ...status });
}
Fan presence out to contacts only

For users with thousands of contacts, flip the model: instead of pushing to everyone, let clients subscribe to the presence of the people currently on screen, usually the open conversation and the visible part of the inbox. The client sends the IDs it cares about, the server answers with a snapshot and joins it to a presence:<userId> room for updates. Fan-out then scales with attention, not with the size of the address book.

Typing indicators: throttle, expire, never persist

A typing indicator is the most chatty event in a chat app. Sent naively on every keystroke, one fast typist produces five to ten events per second, each fanned out to every member of the conversation. The protocol needs three rules:

  1. 01The client throttles. Send typing:start at most once every three seconds while the user keeps typing, and typing:stop when they send, clear the input or go idle.
  2. 02The receiver expires. If no new typing:start arrives within about six seconds, hide the indicator. A closed laptop lid or a dropped connection will never send typing:stop, and the UI must not wait for it.
  3. 03Nothing is stored. Typing events are relayed straight to the room and forgotten. They are sent as volatile, so if a client is not ready to receive, the event is dropped instead of buffered.
ts
socket.on('typing:start', ({ conversationId }) => {
  const room = `conv:${conversationId}`;
  if (!socket.rooms.has(room)) return; // not a member, ignore silently
  socket.volatile.to(room).emit('typing:start', { conversationId, userId: socket.data.user.id });
});

socket.on('typing:stop', ({ conversationId }) => {
  const room = `conv:${conversationId}`;
  if (!socket.rooms.has(room)) return;
  socket.volatile.to(room).emit('typing:stop', { conversationId, userId: socket.data.user.id });
});
server/src/index.ts: relay typing events, volatile and membership-checked

socket.to(room) excludes the sender, so you never see your own indicator, and volatile tells Socket.IO it is fine to drop the packet if the underlying transport is not writable. On the client, the throttle and the expiry live in the same hook that already manages messages:

ts
const THROTTLE_MS = 3_000;
const IDLE_MS = 4_000;
const EXPIRE_MS = 6_000;

// Sending side: call onInput() from the composer's onChange.
const lastSent = useRef(0);
const idleTimer = useRef<ReturnType<typeof setTimeout>>();

function onInput() {
  const now = Date.now();
  if (now - lastSent.current > THROTTLE_MS) {
    socket.emit('typing:start', { conversationId });
    lastSent.current = now;
  }
  clearTimeout(idleTimer.current);
  idleTimer.current = setTimeout(stopTyping, IDLE_MS);
}

function stopTyping() {
  if (!lastSent.current) return;
  socket.emit('typing:stop', { conversationId });
  lastSent.current = 0;
}

// Receiving side: each typist expires on their own timer.
const [typing, setTyping] = useState<Record<string, number>>({});

useEffect(() => {
  const start = ({ userId }: { userId: string }) =>
    setTyping((t) => ({ ...t, [userId]: Date.now() + EXPIRE_MS }));
  const stop = ({ userId }: { userId: string }) =>
    setTyping(({ [userId]: _, ...rest }) => rest);

  socket.on('typing:start', start);
  socket.on('typing:stop', stop);
  socket.on('message:new', (m) => stop({ userId: m.senderId })); // a message ends typing

  const sweep = setInterval(() => {
    const now = Date.now();
    setTyping((t) => Object.fromEntries(Object.entries(t).filter(([, exp]) => exp > now)));
  }, 1_000);

  return () => {
    socket.off('typing:start', start);
    socket.off('typing:stop', stop);
    clearInterval(sweep);
  };
}, [socket]);
client/src/useChat.ts: throttled sending, self-expiring display

Testing ephemeral state

Presence bugs hide in timing, so test the timing directly. Open two sockets for the same user and close one: contacts must see no change. Close both and reconnect within the grace period: contacts must see no change. Kill a server process with kill -9 while users are connected: within about a minute and a sweep, those users must appear offline. Each of these is a short integration test against a real Redis, and they catch the regressions that unit tests never will.

Key takeaways

  • —Keep ephemeral state in Redis with expiry, and write only real offline transitions to Postgres.
  • —Track connections, not users: a user is online while they have at least one live, heartbeating connection.
  • —Expiry scores plus a sweeper make presence heal itself after a node crashes without running disconnect handlers.
  • —Use a grace period before announcing offline so reconnects and refreshes never flicker.
  • —Throttle typing on the client, expire it on the receiver, send it volatile and never persist it.

Everything so far has quietly assumed one server. The moment a second node joins, io.to(room).emit() only reaches the sockets on the node that emitted. Next up, part 7: Scaling Out: Multiple Nodes with the Redis Adapter, where we make broadcasts cross server boundaries.

The series — Real-Time Chat at Scale
(10)Let's talk

Ready to scaleyour application?

Stop worrying about technical debt. I can help you build a reliable, high-performance product from the ground up, tailored exactly to your business needs.

Actively open for new opportunities
Get in touch
Local time
--:-- PKT
© 2026 Syed Wajeeh Haider. All rights reserved.