Chat Architecture
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A chat application looks simple from the outside: one person types, the others see the text. The difficulty is everything that surrounds that one hop: a person with two devices, a gateway that restarts in the middle of a send, a phone that loses its connection in a lift, two people typing in the same millisecond, a member who has been offline for a week. This page describes the components that a chat system is made of, the data model that makes ordering and catch-up cheap, and the delivery guarantees that hold the pieces together. The running example is Chatterbox, whose wire protocol is defined on Message Design and Serialization; this page does not repeat the frames, it shows what a server has to do to honour them.
The reference stack of the section is Socket.IO on Node.js with PostgreSQL and Redis, and the examples were written
against PostgreSQL 18.6, Redis 8.8.3, Node.js 24.21, pg 8.23.1, redis (node-redis) 6.3.0 and socket.io 4.8.4. They
were checked on 2026-10-24. The architecture itself does not depend on those products: every box below can be replaced
(see Choosing a WebSocket Stack), but the guarantees
(per-room order, idempotent writes, catch-up by sequence number) must survive the replacement.
Which scenarios use it
-
Chatterbox is what this page designs: rooms (public, private and direct messages), history, edits, reactions, receipts, presence and offline push.
-
Huddle embeds a small chat next to the video grid. It reuses the same gateway and storage, or, for a call that has no persistent room, a data channel (see Data Channels).
-
Pixel Arena needs only a lobby chat. It is usually a room on the same chat service rather than a feature of the game server, so that moderation and history come for free.
The components
Each component has one job, and the boundaries are where the failure modes live.
| Component | Responsibility | State it owns |
|---|---|---|
Reverse proxy or load balancer |
Terminates TLS, upgrades the HTTP request to a WebSocket and keeps idle connections open. Its idle timeout must be longer than the heartbeat (see Proxies, Load Balancers and Deployment). |
None. |
Gateway |
Owns the sockets: handshake with |
Open connections, per-connection rate bucket, the set of rooms each local socket joined. |
Chat service |
Validates and persists |
Nothing durable; it is a function over the database. |
History store |
The durable record: conversations, memberships, messages, events and remembered acks. Answers |
Everything that must survive a restart (PostgreSQL here). |
Fan-out layer |
Carries a committed event from the gateway that accepted it to every gateway that has a member of the room. Redis publish and subscribe in the reference stack. It is a delivery optimisation, not a source of truth. |
Nothing durable. |
Presence service |
Tracks who is online with short-lived keys that a heartbeat renews, and broadcasts changes with a grace period (see Presence, Typing and Receipts). |
Keys with a TTL in Redis. |
Push worker |
Sends Web Push messages to members who have no live connection, so that a closed tab still produces a notification (see History, Offline Delivery and Moderation). |
A queue of pending notifications, push subscriptions per device. |
Attachment storage |
Object storage reached with pre-signed URLs: the browser uploads the bytes directly, and the message carries only the URL, name, MIME type and size. The gateway never proxies file bytes. |
The objects. |
Two rules keep this tidy. The database is the only source of truth: every in-memory structure (a socket’s room set,
a Redis key, a subscriber list) can be rebuilt from PostgreSQL plus the clients' lastSeq values. And the gateway
never makes a decision that needs a transaction: it forwards to the chat service, which is the only code that writes.
The data model
A conversation is called a room in the protocol. The model needs five tables. The load-bearing idea is that
every room has its own counter (last_seq on the conversation row), and every event that changes what a member sees
takes the next value of it.
-- Chatterbox storage schema (PostgreSQL 18)
CREATE TABLE users (
id text PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE conversations (
name text PRIMARY KEY CHECK (name ~ '^[a-z0-9-]{1,64}$'),
kind text NOT NULL CHECK (kind IN ('public', 'private', 'dm')),
last_seq bigint NOT NULL DEFAULT 0, -- the per-room sequence counter
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE memberships (
room text NOT NULL REFERENCES conversations (name),
user_id text NOT NULL REFERENCES users (id),
role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'moderator', 'member')),
joined_at timestamptz NOT NULL DEFAULT now(),
delivered_seq bigint NOT NULL DEFAULT 0, -- cumulative delivered receipt
read_seq bigint NOT NULL DEFAULT 0, -- cumulative read receipt
PRIMARY KEY (room, user_id)
);
CREATE INDEX memberships_by_user ON memberships (user_id);
-- One row per message; edits update text and edited_at, deletes set deleted_at.
CREATE TABLE messages (
room text NOT NULL REFERENCES conversations (name),
seq bigint NOT NULL,
id text NOT NULL UNIQUE, -- the client-generated envelope id
sender_id text NOT NULL REFERENCES users (id),
text text NOT NULL CHECK (char_length(text) BETWEEN 1 AND 4000),
reply_to text REFERENCES messages (id),
attachments jsonb NOT NULL DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now(),
edited_at timestamptz,
deleted_at timestamptz,
PRIMARY KEY (room, seq)
);
-- Edits and reactions share the room's counter, so replay can follow one ordering key.
CREATE TABLE room_events (
room text NOT NULL REFERENCES conversations (name),
seq bigint NOT NULL,
kind text NOT NULL CHECK (kind IN ('edit', 'react')),
id text NOT NULL,
user_id text NOT NULL REFERENCES users (id),
body jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (room, seq)
);
-- Remembered acknowledgements: (user, envelope id) -> what the client was told.
CREATE TABLE acks (
user_id text NOT NULL REFERENCES users (id),
id text NOT NULL,
room text,
seq bigint,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, id)
);
CREATE INDEX acks_by_age ON acks (created_at); -- for the 24 h retention sweep
Design notes that are easy to get wrong:
-
The primary key of
messagesis(room, seq), which makes the two hot queries (history before a cursor, replay after a cursor) index range scans, and keeps one room’s rows physically close. The envelopeidis a separate unique column, because edits, reactions and receipts address a message by id for ever. -
editandreactare rows ofroom_events, not changes to history. They take aseqso that a client which was offline can replay them in order. The current text lives on themessagesrow so that a history page needs no join. -
Receipt markers are two integers per membership, not a row per message. Because receipts are cumulative (
upTo),delivered_seqandread_seqare enough, and the unread count of a room is simplylast_seq - read_seq. A table with one row per (message, reader) grows with room size times history and is almost never needed. -
Soft delete (
deleted_at) keeps theseqhole explicit and lets a moderator restore a message. Hard deletion for legal reasons is a separate job that rewrites the row. -
Identifiers are text. Client-generated UUIDv7 (RFC 9562) or ULID strings sort roughly by creation time and are safe to generate offline; the server never trusts them for ordering, only for identity.
-
A direct message is just a room named
dm-<userA>-<userB>with the two ids sorted, so there is exactly one DM room per pair and no special code path for history, receipts or push.
The life of a message
The sequence below follows one message from Ana’s browser to Ben’s, with the receipt coming back. Ana is connected to gateway A, Ben to gateway B, and the numbered steps are explained afterwards.
-
Frame in. The gateway receives one text frame, rejects it if it is larger than
maxFrameBytes, parses the JSON, validates the envelope and refuses anything beyond the connection’s rate bucket. Nothing is written yet. -
Persist. The chat service runs one transaction (shown in the next section): reserve the
(userId, id)pair, check membership, take the next sequence number, insert the message. If anything fails, the transaction rolls back and no sequence number is burnt. -
Ack. After
COMMIT, the sender is toldok: truewith the assignedseq. The ack means "durably stored", not "delivered to anyone". -
Fan-out. The same gateway publishes the committed envelope on a per-room channel. Every gateway, including this one, receives it from Redis and writes it to the local sockets of the room’s members. The sender receives its own copy, which is how it learns the
seqof an edit or a reaction. Clients must not rely on whether the ack or the broadcast arrives first. -
Receipt. Ben’s client reports
deliveredand laterreadwith a cumulativeupTo. The chat service advances the marker monotonically and fans the receipt out to the other members. -
Offline members. For each member without a live connection the push worker sends a Web Push notification, so that a service worker can show it. The next connection catches up by
seq(see "History and catch-up").
Publishing happens after the commit, never inside it. A gateway that crashes between the two leaves a stored message
that nobody was told about; the recipients notice a gap in seq on the next live event, or catch up on reconnect.
A transactional outbox (an outbox table written in the same transaction and drained by a publisher) closes that window
completely, at the cost of one more table and a poller.
Delivery semantics
A network connection can fail after the server has stored a message and before the client has seen the ack. The client
cannot tell "never arrived" from "arrived, ack lost", so it must resend. That gives at-least-once delivery on the
wire. To make a resend harmless, every client-to-server envelope carries a client-generated id, the server remembers
the answer it gave, and a repeat of the same (userId, id) returns the same answer with dup: true and changes
nothing. At-least-once delivery plus an idempotent receiver behaves as exactly-once processing, which is the property
users call "it never posts twice".
The rules, all part of the Chatterbox contract:
-
The client keeps an outbox of unacknowledged envelopes in order and resends them after reconnecting with the same ids (see Connection Lifecycle, Heartbeats and Reconnection).
-
The server remembers acks for at least 24 hours. The
ackstable has acreated_atindex so a nightly job can delete older rows. After the retention window a repeat of a very old id from the same sender hits the unique key ofmessages.idand is answered withconflictrather thandup. -
Errors are not remembered: a rejected envelope leaves no trace, so a resend is processed again.
-
Server-to-client broadcasts are also at-least-once (a replay after reconnect can repeat an envelope the client already holds). The client deduplicates by
seqand ignores aseqit has already applied.
The persist transaction below is the heart of the chat service. It is plain Node.js with pg; the module also contains
the fan-out subscriber and the history, unread-count and receipt queries used later on this page.
// Chatterbox storage layer: idempotent persist, fan-out publish, history and unread counts.
import pg from 'pg';
import { createClient } from 'redis';
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL ?? 'postgres://chatterbox:change-me@127.0.0.1:47000/chatterbox',
max: 10,
});
export class ChatError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}
/**
* Persist one "message" envelope. Returns the ack payload: { ok, seq, dup, result }.
* The whole function is one transaction, so a retry with the same envelope id can never
* create a second message or burn a second sequence number.
*/
export async function persistMessage(userId, env) {
const { id, room, payload } = env;
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Reserve the (user, id) pair. A concurrent duplicate waits here for the first
// transaction to finish, then sees the committed row and inserts nothing.
// Read the stored ack on the SAME client: asking the pool for a second connection while
// holding one deadlocks as soon as every pooled connection is a waiting duplicate.
const reserve = await client.query(
'INSERT INTO acks (user_id, id, room) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING id',
[userId, id, room],
);
if (reserve.rowCount === 0) {
await client.query('ROLLBACK');
const prior = await client.query('SELECT room, seq FROM acks WHERE user_id = $1 AND id = $2', [userId, id]);
return { ok: true, seq: Number(prior.rows[0].seq), dup: true, result: {} };
}
// 2. Authorisation: only members may post.
const member = await client.query('SELECT 1 FROM memberships WHERE room = $1 AND user_id = $2', [room, userId]);
if (member.rowCount === 0) throw new ChatError('forbidden', 'not a member of ' + room);
// 3. replyTo must be a message of the same room.
if (payload.replyTo) {
const parent = await client.query('SELECT 1 FROM messages WHERE id = $1 AND room = $2', [payload.replyTo, room]);
if (parent.rowCount === 0) throw new ChatError('not_found', 'replyTo is not a message of this room');
}
// 4. Take the next sequence number. The row lock serialises writers of one room only.
const next = await client.query(
'UPDATE conversations SET last_seq = last_seq + 1 WHERE name = $1 RETURNING last_seq',
[room],
);
if (next.rowCount === 0) throw new ChatError('not_found', 'unknown room');
const seq = Number(next.rows[0].last_seq);
// 5. Store the message and complete the remembered ack.
await client.query(
`INSERT INTO messages (room, seq, id, sender_id, text, reply_to, attachments)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[room, seq, id, userId, payload.text, payload.replyTo ?? null, JSON.stringify(payload.attachments ?? [])],
);
await client.query('UPDATE acks SET seq = $3 WHERE user_id = $1 AND id = $2', [userId, id, seq]);
await client.query('COMMIT');
return { ok: true, seq, dup: false, result: {} };
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
if (err.code === '23505') throw new ChatError('conflict', 'message id already used by another sender'); // unique_violation on messages.id
throw err;
} finally {
client.release();
}
}
/** Fan-out: publish the committed message to every gateway. Called after COMMIT. */
export async function publishMessage(pub, env, seq, from) {
const out = {
v: 1, type: 'message', id: env.id, room: env.room, ts: Date.now(),
payload: { from, text: env.payload.text, replyTo: env.payload.replyTo ?? null,
attachments: env.payload.attachments ?? [], seq, edited: false },
};
await pub.publish('cb:room:' + env.room, JSON.stringify(out));
return out;
}
/**
* Gateway side of the fan-out: one pattern subscription per gateway process, not one per room.
* deliver(room, envelope) writes to the sockets of that room that live in THIS process.
*/
export async function startFanout(sub, deliver) {
await sub.pSubscribe('cb:room:*', (raw, channel) => {
const room = channel.slice('cb:room:'.length);
deliver(room, JSON.parse(raw));
});
}
/** Cursor pagination on the sequence number, newest page first. */
export async function history(room, before = null, limit = 50) {
const { rows } = await pool.query(
`SELECT m.seq, m.id, m.sender_id, u.name, m.text, m.reply_to, m.edited_at IS NOT NULL AS edited
FROM messages m JOIN users u ON u.id = m.sender_id
WHERE m.room = $1 AND m.deleted_at IS NULL AND ($2::bigint IS NULL OR m.seq < $2)
ORDER BY m.seq DESC LIMIT $3`,
[room, before, limit + 1],
);
const hasMore = rows.length > limit;
const page = rows.slice(0, limit).reverse();
return { messages: page, hasMore, next: hasMore ? Number(page[0].seq) : null };
}
/** Unread count per room for a user: everything above the cumulative read marker. */
export async function unreadCounts(userId) {
const { rows } = await pool.query(
`SELECT mb.room, c.last_seq - mb.read_seq AS unread
FROM memberships mb JOIN conversations c ON c.name = mb.room
WHERE mb.user_id = $1 ORDER BY mb.room`,
[userId],
);
return rows.map((r) => ({ room: r.room, unread: Number(r.unread) }));
}
/** Cumulative receipt: GREATEST keeps the marker monotonic when receipts arrive out of order. */
export async function markRead(userId, room, upTo) {
const r = await pool.query(
`UPDATE memberships mb SET read_seq = GREATEST(mb.read_seq, $3), delivered_seq = GREATEST(mb.delivered_seq, $3)
FROM conversations c
WHERE mb.room = $2 AND mb.user_id = $1 AND c.name = mb.room AND $3 <= c.last_seq
RETURNING mb.read_seq`,
[userId, room, upTo],
);
if (r.rowCount === 0) throw new ChatError('bad_request', 'upTo is above the last seq or not a member');
return Number(r.rows[0].read_seq);
}
/**
* Alternative sequence source: a Redis counter. Seed it from the database once (SET ... NX), then INCR.
* Faster than a row lock, but values burnt by failed transactions leave gaps and a lost Redis needs a reseed.
*/
export async function nextSeqFromRedis(redis, room) {
const key = 'seq:' + room;
const seeded = await redis.set(key, '0', { NX: true });
if (seeded === 'OK') {
const { rows } = await pool.query('SELECT last_seq FROM conversations WHERE name = $1', [room]);
await redis.set(key, String(rows[0]?.last_seq ?? 0), { XX: true });
}
return redis.incr(key);
}
export function redisClient() {
return createClient({ url: process.env.REDIS_URL ?? 'redis://127.0.0.1:47001' });
}
Source: node-postgres: Transactions
Why it is written this way:
-
Reserve first.
INSERT ... ON CONFLICT DO NOTHINGonacksis the dedupe gate. When two copies of the same envelope arrive together, the second insert blocks on the first one’s uncommitted unique-index entry; once the first transaction commits, the second inserts nothing and returns the stored ack. This is how unique-index insertion behaves under the default Read Committed isolation level, so no explicit lock and noSERIALIZABLEretry loop is needed. The demo below fires twenty copies in parallel and gets exactly one new message. -
Everything else happens in the same transaction, so a failure after the reservation (not a member, unknown parent, constraint violation) rolls the reservation back too, and a later retry starts from scratch.
-
The sequence number is taken late, after all validation, to keep the row lock on the conversation as short as possible. The
UPDATEserialises writers of one room only; two busy rooms never wait for each other. -
Read the stored ack with the same client. Asking the pool for a second connection while the first one is still held deadlocks when every connection of the pool is a waiting duplicate; the demo fires twenty duplicates at a pool of ten, which is exactly the situation that exposes the mistake.
-
conflictfor a stolen id. The unique key onmessages.idfires when a different user reuses an existing message id (PostgreSQL error code23505), and the function maps it to the protocol’sconflict.
Ordering and sequence numbers
Order within a room is defined by seq, never by arrival time or by ts. There are three realistic ways to allocate it.
| Strategy | Strengths | Costs |
|---|---|---|
A counter column in the room row (used above) |
Gapless, transactional (a rollback gives the number back), no extra moving part, recoverable from the data. |
Writers of one room serialise on a row lock until commit; a very hot room is limited by commit latency. |
A Redis |
Very fast, no row lock, works when messages are stored in a system without transactions. |
A failed or retried write burns a number and leaves a gap, a lost or failed-over Redis can hand out numbers again until it is reseeded from the database, and the counter and the message are two writes that can disagree. |
One owner per room (consistent hashing or an actor per room) |
A single writer can assign numbers in memory and batch writes; the cheapest per message. |
Needs routing, ownership hand-over and fencing when an owner dies; far more code than the other two. |
The Redis variant, when you need it, must seed itself from the database and be treated as a hint: the stored last_seq
remains authoritative, and clients tolerate gaps (they already must, because a history page returns messages only while
edits and reactions consume numbers too). This is the function from store.mjs, which the demo calls twice and which
returns 53 and 54 after 52 messages:
export async function nextSeqFromRedis(redis, room) {
const key = 'seq:' + room;
const seeded = await redis.set(key, '0', { NX: true });
if (seeded === 'OK') {
const { rows } = await pool.query('SELECT last_seq FROM conversations WHERE name = $1', [room]);
await redis.set(key, String(rows[0]?.last_seq ?? 0), { XX: true });
}
return redis.incr(key);
}
Source: Redis documentation: INCR
Even this snippet has a window: a second caller can INCR between the seeding SET NX and the corrective SET XX.
A production version seeds inside a Lua script or a MULTI block (see
Redis pipelining, transactions and scripting). That is
the strongest argument for the counter column: its correctness does not depend on the order in which two processes
happen to run.
Clients treat seq as the only ordering key. An optimistic local echo is shown in a "sending" state at the bottom and
moves into place when the ack delivers its seq. A jump in live seq values (the client holds 41 and receives 44) is a
gap, and the client fills it with a history request or a join with since.
Fan-out
Fan-out answers one question: which gateways must hear about this event? The answer is every gateway with at least one local member of the room. Three mechanisms are common.
-
Redis publish and subscribe is the simplest and the one used here. It has at-most-once delivery: a subscriber that is disconnected misses events, and nothing is stored. That is acceptable because the database holds the truth and clients catch up by
seq. -
Redis Streams (or Kafka) keep a log. A gateway that restarts reads from its last position instead of missing events. The Socket.IO Redis Streams adapter is the packaged form (see Socket.IO (Node.js)), and it is what enables connection state recovery.
-
PostgreSQL
LISTENandNOTIFYremoves Redis from the picture and delivers notifications only when the transaction commits, which fixes the publish-after-commit window. Payloads are limited to under 8000 bytes, so the notification carries a(room, seq)pointer and the gateway reads the row.
A gateway subscribes once, with a pattern, rather than once per room, and filters on its local membership map. The
function below (also in store.mjs) is the whole gateway side; deliver is where Socket.IO’s io.to(room).emit(...)
goes.
export async function startFanout(sub, deliver) {
await sub.pSubscribe('cb:room:*', (raw, channel) => {
const room = channel.slice('cb:room:'.length);
deliver(room, JSON.parse(raw));
});
}
Source: Redis documentation: Pub/sub
Typing indicators and presence changes take the same route but are never stored and are allowed to be lost.
Receipts, unread counts and multi-device sync
A user’s devices are independent connections of one account. The model keeps them consistent with three rules:
-
Server-side markers are per user, not per device.
read_seqlives on the membership row, so a freshly connected device (or a new login) learns the unread count of every room from the server instead of guessing from its own cache. Live propagation of "I read this on my phone" to the same user’s other devices is not part of Chatterbox v1, whose receipts go to the other members only; an echo of the user’s own receipts to their other sockets is the natural extension. -
Markers only move forward.
GREATEST(old, new)makes a late or duplicated receipt harmless, and a receipt above the room’slast_seqis rejected withbad_request. -
The unread count is derived,
last_seq - read_seq, one subtraction per room, with no counter to maintain. The count includes edits and reactions because they consume sequence numbers. If the badge must count messages only, keep a second per-room counter, or count with a query bounded by a sensible cap (for example "99+").
Each device also keeps its own lastSeq per room and its own session. After a reconnect the client sends hello with
resume, and the server replays everything above each lastSeq (see
Connection Lifecycle, Heartbeats and Reconnection).
Drafts and mute settings, by contrast, are per-user data that a settings endpoint stores, not chat events.
History and catch-up
History is a keyset (cursor) pagination over seq: WHERE seq < $before ORDER BY seq DESC LIMIT n + 1, where the extra
row tells the server whether hasMore is true. It stays fast on a room with millions of rows because the primary key
(room, seq) is the sort order, and it is stable while new messages arrive, which OFFSET pagination is not
(see Pagination Strategies). Catch-up after an outage uses the same index in the
other direction: WHERE seq > $since ORDER BY seq. The full treatment of history, retention, offline push and
moderation is on History, Offline Delivery and Moderation.
Attachments and push
Attachment bytes never travel through the WebSocket. The client asks an HTTP endpoint for a pre-signed upload URL, uploads
the file to object storage directly, and sends a message whose attachments array holds the resulting https URL, the
name, the MIME type and the size. The server validates the URL against its own bucket and the size against a limit, and
hands out pre-signed download URLs that expire. For members with no live connection the push worker sends a Web Push
message (RFC 8030, with VAPID identification as in RFC 8292) that wakes a service worker; when the text is private the
notification should carry a room name and a seq, not the message text.
Choosing the protocol: custom, XMPP or hosted
| Option | Choose it when | Cost |
|---|---|---|
Custom WebSocket protocol (Chatterbox) |
The chat is part of a product, you control both clients, you need product-specific features and want one stack for the rest of the application. |
You own presence, history, receipts, moderation and every edge case this section describes. |
XMPP (Prosody, ejabberd; see XMPP Fundamentals) |
You want an open, federated standard with mature extensions: MUC rooms (XEP-0045), archive and catch-up (XEP-0313), receipts (XEP-0184), chat markers (XEP-0333) and carbons (XEP-0280), plus existing clients such as Converse.js. |
Protocol weight (XML stanzas), an extension zoo to select from, and less freedom to bend semantics. |
A hosted chat service |
Time to market matters more than control, and message volume, compliance and data location fit the provider. |
Recurring cost, vendor lock-in, and a data model you cannot change. |
The decision is also about people: a custom protocol is cheapest to start and most expensive to operate, XMPP shifts cost into learning the standard, and a hosted service shifts it into a contract. The comparison across all stacks is on Comparison & Decision Guide.
Running the storage example
The files above form a small project. compose.yaml starts PostgreSQL (which applies schema.sql on first boot) and Redis
on loopback ports, and demo.mjs drives the chat service the way a gateway would: persist and fan-out through two
subscribers, retry, twenty concurrent duplicates, fifty concurrent distinct messages, error cases, history, unread counts
and the Redis counter. Put schema.sql in a sql folder next to compose.yaml.
name: g6-chat-architecture
services:
postgres:
image: postgres:18-alpine
container_name: g6-chat-architecture-postgres
environment:
POSTGRES_USER: chatterbox
POSTGRES_PASSWORD: change-me
POSTGRES_DB: chatterbox
ports:
- "127.0.0.1:47000:5432"
volumes:
- ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U chatterbox"]
interval: 2s
timeout: 3s
retries: 30
redis:
image: redis:8.8.3-alpine
container_name: g6-chat-architecture-redis
ports:
- "127.0.0.1:47001:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 30
Source: Docker Official Image: postgres
{ "name": "chatterbox-storage-demo", "version": "1.0.0", "private": true, "type": "module",
"dependencies": { "pg": "8.23.1", "redis": "6.3.0" } }
import assert from 'node:assert/strict';
import { pool, persistMessage, publishMessage, startFanout, history, unreadCounts, markRead, nextSeqFromRedis, redisClient, ChatError } from './store.mjs';
const env = (id, room, text, extra = {}) => ({ v: 1, type: 'message', id, room, ts: 0, payload: { text, replyTo: null, attachments: [], ...extra } });
const ULID = (n) => 'MSG' + String(n).padStart(23, '0');
await pool.query("INSERT INTO users (id, name) VALUES ('u_ana','Ana'),('u_ben','Ben'),('u_eve','Eve') ON CONFLICT DO NOTHING");
await pool.query("INSERT INTO conversations (name, kind) VALUES ('general','public') ON CONFLICT DO NOTHING");
await pool.query("INSERT INTO memberships (room, user_id) VALUES ('general','u_ana'),('general','u_ben') ON CONFLICT DO NOTHING");
// 1. persist + fan-out through Redis with two "gateways"
const pub = redisClient(), subA = redisClient(), subB = redisClient();
await Promise.all([pub.connect(), subA.connect(), subB.connect()]);
const gotA = [], gotB = [];
await startFanout(subA, (room, e) => gotA.push({ room, ...e }));
await startFanout(subB, (room, e) => gotB.push({ room, ...e }));
const ack1 = await persistMessage('u_ana', env(ULID(1), 'general', 'Hello, Chatterbox'));
assert.deepEqual(ack1, { ok: true, seq: 1, dup: false, result: {} });
await publishMessage(pub, env(ULID(1), 'general', 'Hello, Chatterbox'), ack1.seq, { userId: 'u_ana', name: 'Ana' });
await new Promise((r) => setTimeout(r, 200));
assert.equal(gotA.length, 1); assert.equal(gotB.length, 1); assert.equal(gotA[0].payload.seq, 1); assert.equal(gotB[0].room, 'general');
console.log('1 persist+fanout ok', ack1);
// 2. retry with the same id -> dup:true, same seq, no new row
const ack2 = await persistMessage('u_ana', env(ULID(1), 'general', 'Hello, Chatterbox'));
assert.deepEqual(ack2, { ok: true, seq: 1, dup: true, result: {} });
console.log('2 retry ok', ack2);
// 3. 20 concurrent identical retries -> exactly one new row
const same = await Promise.all(Array.from({ length: 20 }, () => persistMessage('u_ana', env(ULID(2), 'general', 'racing'))));
assert.equal(same.filter((a) => !a.dup).length, 1);
assert.ok(same.every((a) => a.seq === 2));
console.log('3 concurrent duplicates ok: 1 new, 19 dup, seq', same[0].seq);
// 4. 50 concurrent distinct messages -> gapless seq 3..52
const many = await Promise.all(Array.from({ length: 50 }, (_, i) => persistMessage(i % 2 ? 'u_ana' : 'u_ben', env(ULID(100 + i), 'general', 'm' + i))));
const seqs = many.map((a) => a.seq).sort((a, b) => a - b);
assert.deepEqual(seqs, Array.from({ length: 50 }, (_, i) => i + 3));
console.log('4 concurrent distinct ok:', seqs[0], '..', seqs.at(-1));
// 5. errors
const expect = async (p, code) => { try { await p; } catch (e) { assert.ok(e instanceof ChatError, String(e)); assert.equal(e.code, code); return; } assert.fail('no error ' + code); };
await expect(persistMessage('u_eve', env(ULID(900), 'general', 'let me in')), 'forbidden');
await expect(persistMessage('u_ben', env(ULID(1), 'general', 'stolen id')), 'conflict');
await expect(persistMessage('u_ana', env(ULID(901), 'general', 'x', { replyTo: 'nope' })), 'not_found');
await expect(persistMessage('u_ana', env(ULID(902), 'nowhere', 'x')), 'forbidden');
const lost = await pool.query("SELECT count(*)::int AS n FROM acks WHERE id IN ($1,$2,$3)", [ULID(900), ULID(901), ULID(902)]);
assert.equal(lost.rows[0].n, 0); // failed transactions leave no ack behind
const last = await pool.query("SELECT last_seq FROM conversations WHERE name='general'");
assert.equal(Number(last.rows[0].last_seq), 52);
console.log('5 errors ok; failed attempts burnt no seq, last_seq =', last.rows[0].last_seq);
// 6. history, unread, receipts
const h1 = await history('general', null, 20);
assert.equal(h1.messages.length, 20); assert.equal(h1.hasMore, true); assert.equal(h1.messages.at(-1).seq, '52');
const h2 = await history('general', h1.next, 20);
assert.equal(h2.messages.at(-1).seq, String(h1.next - 1));
console.log('6a history ok next=', h1.next, 'then', h2.next);
console.log('6b unread', await unreadCounts('u_ben'));
assert.equal(await markRead('u_ben', 'general', 40), 40);
assert.equal(await markRead('u_ben', 'general', 10), 40); // older receipt cannot move marker back
console.log('6c unread after read 40', await unreadCounts('u_ben'));
await expect(markRead('u_ben', 'general', 999), 'bad_request');
// 7. redis counter
const r = redisClient(); await r.connect();
const a = await nextSeqFromRedis(r, 'general'); const b = await nextSeqFromRedis(r, 'general');
assert.equal(a, 53); assert.equal(b, 54);
console.log('7 redis seq ok', a, b);
await Promise.all([subA.quit(), subB.quit(), pub.quit(), r.quit()]); await pool.end();
console.log('ALL OK');
Run it with Node.js 24 or later:
docker compose up -d --wait
npm install
node demo.mjs
docker compose down -v
The expected output shows the guarantees at work: nineteen duplicates answered from the stored ack, fifty parallel writers receiving the gapless range 3 to 52, and rejected attempts burning no sequence number.
1 persist+fanout ok { ok: true, seq: 1, dup: false, result: {} }
2 retry ok { ok: true, seq: 1, dup: true, result: {} }
3 concurrent duplicates ok: 1 new, 19 dup, seq 2
4 concurrent distinct ok: 3 .. 52
5 errors ok; failed attempts burnt no seq, last_seq = 52
6a history ok next= 33 then 13
6b unread [ { room: 'general', unread: 52 } ]
6c unread after read 40 [ { room: 'general', unread: 12 } ]
7 redis seq ok 53 54
ALL OK
Run the demo against a fresh database (docker compose down -v first), because it asserts exact sequence numbers.
Security considerations
-
Authorise in the chat service, not in the gateway. Membership, authorship and DM access are checked inside the transaction, so that a bug in a gateway cannot post into a room. The handshake rules (Origin allow-list, one-time ticket) are on Authentication and Authorization and Security.
-
Never trust a client-supplied
ts,fromorseq. The server sets all three on every outbound envelope. -
Treat message text as data. Render it as text, never as HTML. Link previews and attachment URLs are the usual server-side request forgery and cross-site scripting entry points.
-
Redis is not a secure bus by default. Anyone who can publish on
cb:room:*can inject messages, so Redis needs a password, TLS where it crosses a network, and a network policy. Fan-out messages are unauthenticated: only gateways and the chat service may hold the credentials. -
Rate-limit before deduplicating. A flood of duplicate ids still costs a database round trip.
-
End-to-end encryption changes the architecture. The server then stores ciphertext and cannot search, preview or moderate; see End-to-End Encryption and OMEMO Encryption.
Common errors and pitfalls
-
Assigning
seqin the gateway from memory. Two gateways accept messages for the same room and both hand out 42. Allocate it in the database (or one owner), never per process. -
Using
tsto order messages. Two events in the same millisecond, or devices with skewed clocks, reorder the conversation. Order byseq. -
Acking before the commit. A crash after the ack loses a message the client believes was stored. Ack after
COMMIT. -
Publishing inside the transaction. Subscribers may query the database before the commit is visible, or see an event for a transaction that later rolls back. Publish after the commit, or use
NOTIFYor an outbox. -
Holding one pooled connection while asking for another. Under load every connection waits for a second one and the service stalls with no error. Do the whole transaction on one client and release it in
finally. -
Remembering errors as acks. A transient
internalerror would then replay as a failure for 24 hours. Remember only successes. -
Looping over members in the accepting gateway. Sending to each socket from the gateway that accepted the message does not scale past one node; fan out through the shared layer and deliver locally.
-
Counting unread with
COUNT(*)on every page load. Use the marker subtraction. -
Treating Redis pub/sub as reliable. It is not. Clients must be able to catch up by
seqwithout the bus.
Version and specification notes
-
PostgreSQL 18 is the baseline (18.6 on 2026-10-24).
INSERT ... ON CONFLICThas been available since 9.5 and everything else on this page uses long-stable SQL. -
Redis 8.8.3 was used. Pub/sub,
INCR,SETwithNXandXX, and Streams are the long-standing commands. -
In node-redis 6, the listener passed to
subscribeandpSubscribereceives the message first and the channel second. -
UUIDv7 is standardised in RFC 9562, which obsoletes RFC 4122. ULID is a community specification.
-
Web Push is RFC 8030 with VAPID in RFC 8292.
-
XEP-0313 (Message Archive Management), XEP-0184 (Message Delivery Receipts), XEP-0333 (Chat Markers) and XEP-0280 (Message Carbons) are the XMPP counterparts of history, delivered receipts, read markers and multi-device sync.
Related pages
-
Message Design and Serialization: the Chatterbox frames, ack, error and close-code tables that this architecture implements.
-
Presence, Typing and Receipts, History, Offline Delivery and Moderation and Building a Chat App: the next pages of the chat path.
-
Socket.IO (Node.js) and Scaling WebSockets: the reference server and the scaling options for the fan-out layer.
-
Messaging delivery guarantees: background for at-least-once semantics.
-
XMPP Chat Features and Converse.js: the same features on XMPP.