Skip to main content
Version: Latest

Mobile Chat Sockets — Integration Guide

The dedicated real-time reference: how the RN client connects, which events it emits, which events it listens for, the delivery/read receipt lifecycle, and a drop-in client wrapper. Companion to mobile-chat-guide.md (read that first for the HTTP endpoints and the message object). Nothing here needs a backend change on your side.

  • WebSocket URL: same host as the API, Socket.IO v4 client.
  • Auth: JWT in the handshake auth.token field (same access token as HTTP).
  • Transport: hybrid push/pull — the socket pushes live deltas; HTTP pulls history, the conversation list, and reconnect gap-fill. Never rely on the socket alone for correctness — it can miss events across a drop. HTTP is the source of truth; the socket is the fast path.

What's new​

  • 2026-10-07 — Socket payloads are now masked per recipient when a block is involved: a user who blocked you arrives as { id, username: null, isUnavailable: true, … "Clustr user" }, a user you blocked carries blockedByMe: true. It applies live the moment the block or unblock happens; there's nothing to subscribe to.
  • 2026-10-07 — chat_list_update can now carry viewerLeft: null (explicit null, not absent): that's an unblock re-opening a DM — show the composer again. See §5.1.
  • 2026-10-07 — Every user object here now also carries grade and school (both string | null, from the user's current school) next to isStudentVerified — show them under the name. null = no current school on file. Only sent to student-verified viewers: an unverified viewer gets neither key on anyone but themselves, so treat missing as "hidden". Additive for verified users. Details: Student verification guide.
  • 2026-10-05 — Every user object in socket payloads (new_message, message_edited, participant/list updates) now carries isStudentVerified: boolean — show the verified badge when true. Additive: nothing breaks if you ignore it. Details: Student verification guide.
  • 2026-10-01 — chat_list_update can now carry viewerLeft (present when the update is about your own departure — left/removed from a group, or newly blocked on a DM). And this guide was wrong about removed_from_conversation: it said "drop it from the list" since before the group-removal fix shipped, but a removed member's row stays, read-only. Corrected — see §5.1.

1 — The room model (read this first)​

Every event is scoped to a room. There are exactly two kinds, and knowing which room an event targets tells you when you'll receive it.

RoomWho's in itJoined howCarries
Personal user:<yourId>just your sockets (all your devices)auto on connectcross-conversation deltas: sidebar bumps, your own receipts, tray moves, mutes, group membership, notifications
Conversation conversation:<id>everyone with that chat screen openyou emit join_conversationin-thread deltas: new messages, read receipts, typing, edits/deletes, reactions

Consequences:

  • You get sidebar / badge / notification events without joining any conversation room — the personal room is automatic.
  • You only receive new_message, messages_read, typing, etc. for a conversation while you're joined to its room. Miss a window (backgrounded, dropped) → those events never replay. Recover with HTTP gap-fill (§7).
  • You only need one conversation room joined at a time (the open chat).

2 — Connect + authenticate​

import { io } from "socket.io-client";

const socket = io("http://localhost:3001", {
auth: { token: accessToken }, // JWT — verified server-side; userId is derived from it

// NO `transports` override. The default ["polling", "websocket"] is deliberate —
// read §2.1. Forcing websocket-only breaks the first connect after an idle period.

reconnection: true,
reconnectionAttempts: Infinity, // never stop trying; your UI decides what to show
reconnectionDelay: 1000, // first retry after ~1s
reconnectionDelayMax: 10000, // cap — a cold start takes ~8s, 5s wastes attempts
randomizationFactor: 0.5, // jitter, so every device doesn't retry in lockstep
timeout: 20000, // per-attempt connect deadline. Do NOT lower it.
});

socket.on("connect", () => {
// You're already in your personal room (user:<id>).
// The server immediately emits `unread_counts_init` (§5) and sweeps every
// conversation to mark undelivered messages delivered (fires `message_delivered`
// to the senders). Re-join the open conversation room here (see §7).
});

socket.on("connect_error", async (err) => {
// TWO unrelated failures arrive on this one event. `socket.active` separates them.
// Getting this branch wrong is the difference between "reconnects by itself" and
// "silently dead until the user force-quits the app".

if (socket.active) return;
// → transport-level failure: cold start, flaky network, deploy, server restart.
// socket.io has ALREADY scheduled the next attempt. Do nothing. Do not call
// socket.connect() here — you'd stack a second attempt on top of the backoff.

// → socket.active === false: the server's auth middleware REJECTED the handshake.
// There is no automatic retry for this. Setting socket.auth alone does nothing:
// nothing is going to reconnect and pick it up. You must reconnect yourself.
if (err.message === "Invalid token" || err.message === "Authentication required") {
socket.auth = { token: await refreshAccessToken() };
socket.connect(); // required — without this the client never comes back
}
// Any other message with socket.active === false: do NOT loop on socket.connect().
// Surface it and let the user retry, or you'll spin a tight reconnect loop.
});

socket.on("disconnect", (reason) => {
// "io server disconnect" → server kicked us (often auth expiry, §3). Won't auto-reconnect.
// "transport close" / "ping timeout" → transient; socket.io reconnects on its own.
// On the next `connect`, run gap-fill (§7).
});

The userId is always taken from the verified token, never from the handshake — you cannot impersonate another user by tweaking the connection.

2.1 — Cold start: why the first connect can fail, and why polling is the default​

The API runs on Cloud Run capped at one instance, and that instance is shut down after a few idle minutes. The next user to open the app wakes it: container start + boot ≈ 8 seconds before anything is listening.

An ordinary HTTP request survives this — the platform holds it in a queue until the server is up, so it just feels slow. A websocket upgrade does not. It is not held, and it comes back as:

GET /socket.io/?EIO=4&transport=websocket → 500 (after ~6–8s)

This is not an auth failure and there is no error body worth parsing — the server it was aimed at did not exist yet.

Two things make it a non-event:

  1. Leave transports at the default. socket.io then opens the session with an HTTP long-poll — a normal request, so it is queued through the cold start — and upgrades to a websocket about 100ms later. One extra round trip, once per connect. That is the whole cost, and it buys you a connect that works on a cold server.
  2. Let the backoff run. With the §2 options the retries land at roughly 1s, 2s, 4s, 8s, 10s, 10s… so an 8s cold start is covered by the 4th attempt, ~15s worst case.

Two rules that follow:

  • Do not lower timeout below ~15000. The observed failures came back at 6–8s, already inside the 20s default. A shorter deadline would abort connections that were about to succeed.
  • Do not show "Connection failed" before ~15 seconds. For the first ten seconds a cold start is indistinguishable from an outage, and it isn't one. Show a quiet "Connecting…" and let the backoff work.

While the socket is down, chat is not down — it is HTTP-only. History, sending, and the conversation list all work (see mobile-chat-guide.md); you lose live deltas until it reconnects, and §7 gap-fill recovers them. Don't block the chat screen on socket.connected.

The same reasoning applies to plain HTTP calls after an idle period — what is retryable, what is not, and the backoff to use, for every endpoint: Request Reliability.


3 — Token lifecycle (refresh while connected)​

The handshake JWT expires (15 min default). The server tracks each socket's expiry and drives a two-stage timer so a live socket refreshes without a full reconnect.

// ~60s BEFORE expiry — refresh now and hand the new token back.
socket.on("auth_expiring", ({ expiresAt }) => {
refreshAccessToken().then((newToken) => socket.emit("refresh_auth", newToken));
});

// AT expiry (never refreshed, or the new token was rejected). Server
// disconnects immediately after this — no further events arrive.
socket.on("auth_expired", ({ reason }) => {
// "token_expired" → ran out (also the at-expiry timer): refresh + reconnect.
// "invalid_token" → never valid (bad signature/malformed): bounce to login.
// "identity_mismatch" → token is for another user: reconnect as them.
// "no_exp_claim" → token has no exp: get one from the auth endpoints.
if (reason === "token_expired") return refreshAndReconnect();
goToLogin();
});

Rules:

  • Send the new access token (not the refresh token) on refresh_auth.
  • The identity in the new token must match the socket's original user — switching users requires a full reconnect.
  • Prefer refresh_auth over reconnecting: reconnect is heavy (re-seeds unread_counts_init, re-runs the delivered sweep, needs a room re-join).

4 — Client → Server (events you emit)​

⚠️ Payload-shape gotcha. Room/typing events take a bare string; the receipt events take an object. Mixing them up = a silently ignored emit.

EventPayloadEmit whenServer effect
join_conversationconversationId (string)chat screen opens / regains focus, and on every reconnectjoin the conv room; auto-marks unread read → broadcasts messages_read
leave_conversationconversationId (string)chat screen closes / loses focusleave the conv room
typing_startconversationId (string)first keystroke (debounce 200–500ms)broadcast user_typing to the room (excl. you)
typing_stopconversationId (string)on send, 3s idle, or blurbroadcast user_stopped_typing
mark_read{ conversationId } (object)a message is read while already in the room (see §6)mark your unread receipts read → broadcast messages_read
mark_delivered{ conversationId } (object)rarely — you receive but can't yet render (backgrounded)mark your undelivered receipts delivered → message_delivered to each sender
refresh_authtoken (string)on auth_expiringre-verify the socket, reset the expiry timer

Notes:

  • join_conversation is a read trigger — you usually don't need a separate mark_read on open. Use mark_read only for the "already open, new message lands" case.
  • mark_delivered is mostly redundant: the server already marks delivered on connect (the sweep), on join_conversation, and on every history GET. Reach for it only when your client received a new_message it can't render yet.
  • A non-participant emitting join_conversation is silently ignored (no room join, no error) — the server checks membership first.

5 — Server → Client (events you listen for)​

Grouped by the room that carries them (§1).

5.1 — Personal room (user:<id>) — always on, no join needed​

EventPayloadHandle by
unread_counts_init{ counts, inbox, requests }seed sidebar badges on connect. counts == inbox (back-compat). requests keys = distinct pending convs
message_delivered{ messageId, conversationId, userId, deliveredAt }sender view — one row per recipient. Bump deliveredCount on that message (§6)
message_status_update{ messageId, conversationId, status }sender view — status: "sent" confirms persistence
chat_list_update{ conversationId, status, requesterId, pendingPostRequest, tray, viewerLeft?, lastMessage, unreadIncrement }bump the sidebar row + unread, and route by tray — "inbox" or "requests", computed by the server for you (the event is per-recipient). Don't derive it from status: a post request the requester hasn't written into yet is PENDING but belongs in neither chat tray. pendingPostRequest ({ id, postId, selectedOptions } | null) drives the "awaiting a response to your post" banner and carries the options the requester ticked, so an incoming row needs no fetch to render. unreadIncrement is 0 for a pending recipient. viewerLeft ({ at, reason } | null, since 2026-10-01) is present exactly when this update is about YOUR OWN departure — leaving/being removed from a group, or a DM you're newly blocked out of — so you can hide the composer without a refetch; explicit null (since 2026-10-07) when an unblock re-opens a DM — show the composer again; absent on every ordinary update, same meaning as the REST field (main guide §2, mobile-chat-groups-guide.md §8)
(any event carrying a user)—masked per recipient (since 2026-10-07): a user who blocked you arrives as the dummy { id, username: null, isStudentVerified: false, isUnavailable: true, displayName/profile.displayName: "Clustr user", avatarUrl: null }, a user you blocked as themselves plus blockedByMe: true. Takes effect live on block / unblock — no reconnect. Contract: mobile-report-block-guide.md §6.1
conversation_status_changed{ conversationId, status: "ACCEPTED" }move the row requests → inbox locally
conversation_deleted{ conversationId }a group was hard-deleted (admin, or last member left) — drop the row from every cache; kill any open room sub. Never fires for a DM or a request
conversation_muted{ conversationId, mutedUntil, isMuted }fired to your own devices — sync mute state across devices
conversation_cleared{ conversationId, clearedUpToSequence }fired to your own devices — you deleted a chat, or dismissed a message request, elsewhere; drop the row. It returns on the next new_message for that conversation. The other participant is never told
added_to_conversation{ conversationId }you were added to a group — refetch the list
removed_from_conversation{ conversationId }you were removed from a group — drop every admin affordance, but NOT the row itself: it stays, read-only (mobile-chat-groups-guide.md §8). A chat_list_update carrying viewerLeft lands separately and is what actually tells you to hide the composer
notificationnotification objectin-app notification / badge (see main guide §12)

5.2 — Conversation room (conversation:<id>) — only while joined​

EventPayloadHandle by
new_messagefull message + statusappend to the thread. POST_SHARE rows carry sharedPostId only → resolve the card (main guide §5.10). If it's your own echo, merge by clientMessageId, don't replace (§6)
messages_read{ conversationId, readByUserId, readAt, isGroupChat, messageReadStatuses? }sender view — flip your sent messages to read. DM: all messages ≤ readAt. Group: per-message via messageReadStatuses[]
user_typing{ userId, conversationId }show the indicator (auto-hide after ~5s as a safety net)
user_stopped_typing{ userId, conversationId }hide the indicator
message_edited{ messageId, conversationId, content, editedAt, mediaRemovedId? }update content + "edited" label; splice out mediaRemovedId if present
message_deleted{ messageId, conversationId }swap to "This message was deleted" + dim. Bulk delete fires N of these
message_reactionreaction summaryupdate counts, preserve your own mine (reactions guide)
conversation_updatedfull conversationapply new name / restriction
participants_added{ conversationId, participants }add members to the roster
participant_removed{ conversationId, userId }remove from roster
participant_updated{ conversationId, participant }apply role change — dormant, promote/demote is disabled (main guide §8.3)

5.3 — Socket-level (Socket.IO built-ins + auth)​

connect, connect_error, disconnect (§2); auth_expiring, auth_expired (§3).


6 — The receipt lifecycle end-to-end​

This is where "read receipts don't update for files" bugs live. The server fires receipts identically for text and media — the difference is purely client-side timing. Walk the whole path:

Sent → Delivered → Read​

  1. Sent. You POST a message. Sender receives message_status_update (status: "sent").
  2. Delivered — the sender receives message_delivered (one per recipient) whenever a recipient becomes reachable. That happens on any of:
    • the message is sent while the recipient is already in the room,
    • the recipient's socket connects (server sweeps all convs),
    • the recipient GETs history for the conversation,
    • the recipient emits mark_delivered.
  3. Read — the recipient triggers a read (join_conversation, mark_read, or POST /read). Server broadcasts messages_read to the conversation room.
    • DM: payload has no per-message id — apply "all my sent messages with time ≤ readAt are read."
    • Group: messageReadStatuses[] gives { messageId, readCount, totalRecipients, allRead, readByUserIds } per message.

Every messages_read — whether triggered over the socket or via HTTP POST /read — carries the same shape, keyed on readByUserId. (Older builds leaked a userId field from the HTTP path; unified now. Key strictly on readByUserId.)

The merge rule — don't clobber a receipt you already applied​

Media messages upload their bytes before the send round-trip, so the send response (and its new_message echo) can arrive after a message_delivered / messages_read for that same message has already advanced its status. If you reconcile your own message by replacing the cached row with the freshly-sent object (whose status still reads deliveredCount: 0, readCount: 0), you erase the receipt — and it only "comes back" on the next history refetch. That's the classic "ticks only update on refresh, and only for image/file messages" report.

Fix: merge, never replace, when reconciling your own message (and again on the async image-variant refetch). Keep the more-advanced status:

function mergeOwn(cached: Message | undefined, incoming: Message): Message {
if (!cached?.status || cached.status.type !== "sent") return incoming;
const inc = incoming.status;
if (!inc || inc.type !== "sent") return { ...incoming, status: cached.status };
return {
...incoming,
status: {
...inc,
deliveredCount: Math.max(inc.deliveredCount, cached.status.deliveredCount),
readCount: Math.max(inc.readCount, cached.status.readCount),
allDelivered: inc.allDelivered || cached.status.allDelivered,
allRead: inc.allRead || cached.status.allRead,
},
};
}

Full context in the main guide §5.5.1 and the media guide "Thumbnails / variants".

PENDING DM caveat​

In a PENDING DM (a message request), the recipient generates no receipts — the sender's deliveredCount/readCount stay 0 so you can't tell whether a stranger has seen your request. On POST /accept the server backfills every prior receipt with deliveredAt = acceptTime; the sender's next message_delivered reflects it. Surface pending sends as "Request pending", not "Sent".


7 — Reconnect: what the socket does NOT replay​

A dropped socket misses every event fired while it was gone. On reconnect the server re-seeds unread_counts_init and re-runs the delivered sweep, but it does not replay new_message / messages_read for the gap. You recover those over HTTP:

  1. Re-join_conversation the open chat (rejoins the room; also marks read).

  2. Gap-fill each open conversation from the highest sequence you've seen:

    GET /api/chat/conversations/:id/messages?afterSequence=<highestSeqSeen>

    Returns messages with sequenceNumber > N, ascending. Walk nextCursor until null. Store the highest sequenceNumber per conversation locally.

  3. For the sidebar you don't need to gap-fill — unread_counts_init + chat_list_update keep badges current.

Full pagination detail: main guide §7.


8 — Drop-in RN client wrapper​

A thin singleton: connect, auto-refresh, typed emit helpers, and a single place to register listeners. Keep your cache-mutation logic (merge rules from §6) in the handlers you pass to onServerEvent.

import { io, Socket } from "socket.io-client";

let socket: Socket | null = null;

export function connectChatSocket(getToken: () => string, refresh: () => Promise<string>) {
if (socket) return socket;
socket = io(API_URL, {
auth: { token: getToken() },
// no `transports` override — see §2.1
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
randomizationFactor: 0.5,
timeout: 20000,
});

socket.on("connect_error", async (err) => {
if (socket!.active) return; // transport error — retry already scheduled (§2.1)
// middleware rejection — nothing retries this for you
if (err.message === "Invalid token" || err.message === "Authentication required") {
socket!.auth = { token: await refresh() };
socket!.connect();
}
});
socket.on("auth_expiring", async () => socket!.emit("refresh_auth", await refresh()));
socket.on("auth_expired", () => {/* bounce to your re-auth flow */});

// The OS suspends the socket in the background and the backoff timer may be
// asleep mid-wait when you come back. Reconnect on resume instead of waiting
// for a timer that might be 10s out.
AppState.addEventListener("change", (state) => {
if (state === "active" && !socket?.connected) socket?.connect();
});

return socket;
}

// Room + typing events take a BARE STRING; receipt events take an OBJECT.
export const joinConversation = (id: string) => socket?.emit("join_conversation", id);
export const leaveConversation = (id: string) => socket?.emit("leave_conversation", id);
export const startTyping = (id: string) => socket?.emit("typing_start", id);
export const stopTyping = (id: string) => socket?.emit("typing_stop", id);
export const markRead = (conversationId: string) => socket?.emit("mark_read", { conversationId });
export const markDelivered = (conversationId: string) => socket?.emit("mark_delivered", { conversationId });

// Register every listener once (e.g. in your root layout). Handlers own the
// cache writes — apply the merge rule (§6) for your own new_message echoes.
export function onServerEvent(handlers: {
onNewMessage?: (m: any) => void;
onMessagesRead?: (e: any) => void;
onMessageDelivered?: (e: any) => void;
onTyping?: (e: any) => void;
onStoppedTyping?: (e: any) => void;
onEdited?: (e: any) => void;
onDeleted?: (e: any) => void;
onChatListUpdate?: (e: any) => void;
onUnreadInit?: (e: any) => void;
// …add the rest from §5 as your screens need them
}) {
if (!socket) return;
handlers.onNewMessage && socket.on("new_message", handlers.onNewMessage);
handlers.onMessagesRead && socket.on("messages_read", handlers.onMessagesRead);
handlers.onMessageDelivered&& socket.on("message_delivered", handlers.onMessageDelivered);
handlers.onTyping && socket.on("user_typing", handlers.onTyping);
handlers.onStoppedTyping && socket.on("user_stopped_typing", handlers.onStoppedTyping);
handlers.onEdited && socket.on("message_edited", handlers.onEdited);
handlers.onDeleted && socket.on("message_deleted", handlers.onDeleted);
handlers.onChatListUpdate && socket.on("chat_list_update", handlers.onChatListUpdate);
handlers.onUnreadInit && socket.on("unread_counts_init", handlers.onUnreadInit);
}

export function disconnectChatSocket() {
socket?.disconnect();
socket = null;
}

Wire join_conversation / leave_conversation to the chat screen's focus/blur, and re-join_conversation on every connect (then gap-fill, §7).


9 — Checklist​

  1. Connect on foreground/login; disconnect on logout. Handle connect_error (refresh token, don't spam).
  2. Auth lifecycle — listen for auth_expiring → refresh_auth; handle auth_expired.
  3. Personal-room listeners (always): unread_counts_init, chat_list_update (place the row by its tray field — never re-derive it), conversation_status_changed, conversation_deleted, conversation_cleared, conversation_muted, added_/removed_from_conversation, message_delivered, message_status_update, notification.
  4. Chat screen: join_conversation on open, leave_conversation on close. Listen for new_message, messages_read, user_typing/user_stopped_typing, message_edited, message_deleted, message_reaction.
  5. Receipts: apply message_delivered / messages_read to the sender view; key messages_read on readByUserId; merge, don't replace on your own new_message echo and on variant refetch (§6).
  6. Reconnect: on every connect, re-join_conversation the open chat and gap-fill via ?afterSequence= (§7).
  7. Payload shapes: bare string for room/typing, object for mark_read / mark_delivered.
⤓ Download .md