A production-grade, horizontally-scaled real-time chat platform.
Built to prove β and to stress-test β the Valkey adapter for Socket.IO.
π§ Real regressions fixed β before β after, genuinely in this repo
| ββ Read receipts | π§© Re-renders | π History |
|---|---|---|
| O(NΒ²) socket frames per message β 1 write / burst | every bubble re-rendered β only what changed | oldest 100, rest unreachable β cursor-paged, all reachable |
π By design β the naive implementation, deliberately avoided
| π A message arrives | π "Is there more?" | π¦ The login screen |
|---|---|---|
| refetch the list β 0 HTTP requests | a count() query β one extra row |
ship the app β β29 kB gzip |
This project exists because of this package. Zentro is its reference implementation β the place where the adapter is integrated, exercised across multiple nodes, and proven correct by an automated suite that boots two independent Node processes and makes them talk to each other through Valkey.
The Socket.IO Valkey adapter β broadcast events between several Socket.IO servers.
| π¦ npm | npmjs.com/package/socket.io-valkey-adapter |
| π» Source | github.com/webdevelopersrinu/socket.io-valkey-adapter |
| π Docs | valkey.srinudesetti.in |
| π·οΈ Version | 0.1.1 Β· MIT |
npm install socket.io-valkey-adapter iovalkeyA single Socket.IO server keeps its rooms in memory. The moment you put a second server behind a load balancer, that memory is no longer shared:
β WITHOUT AN ADAPTER
Alice βββΆ Server A [rooms in RAM] βββΆ βοΈ emitted into the void
Server B never hears it
Bob βββΆ Server B [rooms in RAM] π Bob sees nothing
Alice and Bob are in the same room β but on different machines, so they cannot talk to each other. Scaling out silently breaks the app.
The adapter fixes this by making Valkey the message bus between every node:
β
WITH socket.io-valkey-adapter
Alice βββΆ Server A ββββ publish βββββ
βΌ
π΄ V A L K E Y (pub/sub)
β
Bob βββ Server B ββββ subscribe βββ
Server B's subClient receives A's publish and
delivers the message to Bob. βοΈ β π
Each server opens two connections to the same Valkey β one to publish, one to subscribe. A subscribed connection cannot issue ordinary commands, which is exactly why it is duplicated rather than shared.
// backend/src/config/valkey.js
import Valkey from "iovalkey";
import { createAdapter } from "socket.io-valkey-adapter";
export async function attachValkeyAdapter(io, valkeyUrl) {
const pubClient = new Valkey(valkeyUrl); // publishes out to Valkey
const subClient = pubClient.duplicate(); // receives from other servers
io.adapter(createAdapter(pubClient, subClient));
return { pubClient, subClient };
}That is the entire integration. From this line on, io.to(roomId).emit(...) reaches every member of that room on every server in the cluster β and the application code never has to know there is more than one machine.
The adapter is not merely used here. It is verified by a suite that boots two real Node processes (:4101 and :4102) sharing one MongoDB and one Valkey, and asserts that events genuinely cross the process boundary.
If Valkey is stopped, these tests fail. That is the point of them.
backend/e2e/realtime/multi-server.spec.js ββ 12 cross-server tests
| Cross-server behaviour proven | |
|---|---|
Both servers are genuinely different processes (asserts pidA !== pidB) |
β |
| A message sent on server A reaches a member on server B | β |
| Messages flow in both directions | β |
| A room created after the sockets connect still syncs | β |
| Typing indicators cross servers | β |
| Presence β leaving server A notifies a user on server B | β |
| A join request on server A notifies the admin on server B | β |
| A read receipt on server B turns the author's ticks blue on server A | β |
| A non-member on server B never receives the room's messages π | β |
| Message history written on A is readable from B | β |
cd backend && npm run test:e2e # 27 passed β two servers, one ValkeyEvery claim below is verifiable in the source, and is stated as one of two things β never blurred:
- π§ Fixed β a real regression that existed in this repository.
- π By design β a property of how it is built, contrasted with the naive implementation it deliberately avoids.
The units are the ones that actually cost a user milliseconds: queries issued, socket frames emitted, components re-rendered, bytes shipped. There are no invented latency figures here β this project ships no benchmark harness, and a fabricated "800 ms β 120 ms" is the fastest way to lose a reviewer who checks.
| Before (genuinely, in this repo) | After | |
|---|---|---|
| ββ Read-receipt storm | Each incoming message triggered a /read write per viewer, and the server broadcasts every receipt to every member β O(membersΒ²) socket frames per chat message. Past ~20 msg/min it exhausted the rate limiter and 429'd the user out of their own session. |
One write per burst (2 s trailing debounce) |
| π§© Wasted re-renders | MessageBubble is memo'd, but the memo never hit: the whole receipts array was passed to every bubble (new identity whenever anyone read anything), and inline arrows were recreated each render. One person opening a room re-rendered every bubble in it. |
Reader counts computed once into a Map; each bubble takes a plain number; callbacks hoisted to stable useCallback β only what changed re-renders |
| π Unreachable history | .limit(100) sorted ascending β it returned the oldest 100 messages, and everything newer was silently unreachable. A correctness bug that grows with use. |
Cursor pagination β 50 rows/page, newest first, all history reachable |
| The obvious way | What Zentro does | |
|---|---|---|
| π "Is there more?" | A second count() query per page |
One extra row (.limit(size + 1)) β no second query, ever |
| π A message arrives | Invalidate β refetch the whole list (an N+1 storm) | The socket already handed us the data β patch the cache. 0 HTTP requests. |
| π¬ "3 replies" / unread dots | A count() per row while rendering |
Denormalised replyCount / lastMessageAt β no extra query |
| π¦ The login screen | Ship the whole app | Chat is lazy() β 29 kB gzip never downloaded |
| π Expired tokens / OTPs | A cron job | MongoDB TTL indexes β the database expires them |
The original query was not merely un-paginated. It was wrong:
// BEFORE β returns the OLDEST 100. Everything newer was unreachable.
Message.find({ room }).sort({ createdAt: 1 }).limit(100);// AFTER β backend/src/services/room.service.js
const page = await Message.find({ room, parent: null, ...(before && { _id: { $lt: before } }) })
.sort({ _id: -1 })
.limit(size + 1); // β ONE extra row answers "hasMore"
const hasMore = page.length > size;
if (hasMore) page.pop();- 50 messages per page (hard cap 100). Older pages load on scroll-to-top.
hasMorecosts one extra row β never a secondcount()query. π- The cursor is
_id, notcreatedAtβ_idis unique, so two messages written in the same millisecond can never make the cursor skip one. π
Each index exists to serve one specific access path β not sprinkled on afterwards.
| Index | Serves |
|---|---|
{ room, parent, _id: -1 } |
Message history β equality on room+parent, then a descending walk from the cursor |
{ parent, _id: 1 } |
A whole thread, oldest first |
{ members, updatedAt: -1 } |
"My rooms" β the sidebar, on every load |
{ room, user } (unique) |
Read receipts β and it enforces one row per member |
{ provider, providerId } (unique) |
OAuth identity lookup |
{ expiresAt } (TTL Γ 2) |
Refresh tokens & OTPs β MongoDB expires them for us; no cron job |
π Two known scans β documented, not hidden
A reviewer will find these, so here they are, stated plainly:
GET /rooms/discoverruns{ members: { $ne: userId } }.$neis not selective and cannot use the index β this is a collection scan. Acceptable while room count is in the dozens; it needs pagination (or a materialised "public rooms" projection) before it is not.- Message search uses an escaped, unanchored regex. It is bounded to one room by the compound index, but scans that room's messages. Chosen deliberately over a
$textindex because users expect"stand"to find"standup"β which word-based text search cannot do. It carries aponytail:note in the source naming the ceiling and the upgrade path.Both are capped and bounded. Neither is index-served, and claiming otherwise would be false.
The naive approach is an N+1 disaster: a message arrives β invalidate the query β refetch the entire list. Ten people chatting = ten full history downloads.
// The socket already handed us the data. Write it straight into the cache.
queryClient.setQueryData(key, (page) => appendMessage(page, message));A message arriving triggers 0 network requests. Not a smaller one β none.
// frontend/src/hooks/useChatState.js
const READ_TRAIL_MS = 2000; // one read per burst of chatter, not one per messageRead receipts fired a write per incoming message, per viewer β and the server broadcasts every receipt to every member. In a room of N people, a single chat message cost NΒ² socket frames; past ~20 messages/minute it burned through the rate limiter and 429'd the user out of their own session.
O(NΒ²) per message β one write per burst. The receipt is just as accurate. It simply stops shouting.
Every bubble is memo'd β but a memo only works if its props are stable. Two leaks were silently defeating it:
| The leak | The fix |
|---|---|
The whole receipts array was handed to every bubble β and it gets a new identity whenever anyone reads anything |
Reader counts computed once for the list into a Map; each bubble receives a plain number |
Fresh inline arrows (onRetry, onOpenThread, β¦) on every render = a changed prop on every bubble |
Hoisted to stable useCallback identities |
Before: one person opening a room re-rendered every bubble in it. After: only the bubbles that actually changed.
Real figures, straight from npm run build:
dist/assets/react-β¦.js 57.19 kB gzip β vendor, cached across deploys
dist/assets/data-β¦.js 29.84 kB gzip β TanStack Query + axios
dist/assets/realtime-β¦.js 12.86 kB gzip β socket.io
dist/assets/Chat-β¦.js 24.14 kB gzip β π LAZY β not on the login path
dist/assets/Chat-β¦.css 4.76 kB gzip β π LAZY
- The chat bundle is
lazy()-loaded β someone sitting on the login screen never downloads 29 kB gzip of an app they cannot yet see. - Vendor is split by change frequency (
reactΒ·dataΒ·realtime), so shipping your own app code does not bust React's cache in every user's browser.
| Limit | Value | Stops |
|---|---|---|
| Request body | 10 KB | Payload DoS |
| Messages per page | 50 (max 100) | Unbounded reads |
| Search results | 25 | Unbounded reads |
| Socket events | 20 / 10 s per socket | Flood |
| Search term | Regex-escaped + length-capped | ReDoS β a CPU pinned by (a+)+$ |
A real-time chat platform with public and private rooms, threads, reactions, read receipts, search and role-based moderation β engineered to the standard you would ship to production, not to a tutorial.
| π Auth | Google Β· GitHub OAuth Β· passwordless email OTP β no passwords, ever |
| π Rooms | Public β join instantly Β· Private β request, an admin approves |
| βοΈ Invites | The invitee accepts β nobody joins a room without their own consent |
| π¬ Messaging | Threads Β· reactions (any emoji) Β· edit (1 h window) Β· delete (tombstone) |
| ββ Receipts | WhatsApp-style ticks β grey β blue once everyone has read it |
| π Search | Per-room Β· ReDoS-safe Β· never surfaces deleted messages |
| π Moderation | The creator grants admin; admins approve, invite and rename |
| β‘ Real-time | Messages Β· typing Β· presence Β· unread β across every server |
βοΈ Cloudflare β DNS + TLS
β
βββββββββββββββββΌβββββββββββββββββ
β AWS Application Load Balancer β
β (sticky sessions for OAuth) β
βββββββββ¬βββββββββββββββββ¬ββββββββ
β β
ββββββββββββΌβββββββ ββββββββΌβββββββββββ
β EC2 #1 β β EC2 #2 β
β Node Β· Socket β β Node Β· Socket β
β .IO β β .IO β
ββββββ¬ββββββββ¬βββββ ββββββ¬ββββββββ¬βββββ
β β β β
ββββββββββββββΌββββββββ΄βββββββββββββ΄ββββββββΌβββββββββββββ
β β
ββββββΌβββββββββββββββββββ ββββββββββββββββββββΌββββ
β π΄ V A L K E Y β β π M O N G O D B β
β THE WIRE β β THE TRUTH β
β β β β
β pub/sub only. β β users Β· rooms β
β Transient. β β messages Β· threads β
β Nothing is stored. β β reactions Β· receiptsβ
β β β refresh tokens β
β Losing it costs β β β
β a broadcast β β β Losing it costs β
β never data. β β everything. β
βββββββββββββββββββββββββ ββββββββββββββββββββββββ
Every durable fact β a message, a membership, a refresh token β lives in MongoDB. Valkey carries only transient pub/sub traffic. This is deliberate, and it is load-bearing:
- A
FLUSHALLon Valkey costs you one broadcast, not one byte of user data. - Valkey can be restarted, resized or replaced with zero data loss.
- Refresh tokens were deliberately migrated out of Valkey into MongoDB β a cache eviction must never silently log out every user in the system.
This is not a tutorial codebase. Every item below is enforced in the source and covered by tests.
request β route β middleware β validator β controller β service β model
β β
HTTP only βββ βββ business rules
no logic + authorization
| Layer | Responsibility | The rule it enforces |
|---|---|---|
| routes/ | URL β handler wiring | Nothing else. No logic. |
| middleware/ | auth Β· validation Β· security Β· rate limiting | Cross-cutting concerns only |
| validators/ | zod schemas | Input is coerced & trimmed before a controller ever sees it |
| controllers/ | HTTP in, HTTP out | Zero business logic. Zero authorization. |
| services/ | Business rules and authorization | Never trusts the client. The one place a rule can live. |
| models/ | Mongoose schemas + indexes | Indexes are shaped to the query that uses them (the two known scans are documented, not hidden) |
| socket/ | Real-time handlers | Re-checks membership on every event |
| utils/ Β· lib/ | Pure helpers Β· infrastructure seams | Testable in isolation |
Authorization lives in services, never in controllers. A rule written in a controller is a rule the socket layer does not have. Every mutation re-checks membership server-side β leaving a room ends your ability to touch anything in it, including a message you wrote while you were still a member.
| Concern | Owner | Why |
|---|---|---|
| Server state | TanStack Query | One cache. One source of truth. |
| Client state | React state / context | Which room is open, which drawer is up |
| Real-time | Socket events write into the Query cache | Never a refetch β the socket already handed us the data |
| Network | One axios instance | Single-flight 401 β refresh β retry, in exactly one place |
| Styling | CSS Modules + design tokens | No raw hex/px where a token exists; correct in both themes |
Socket events patch the cache β they never trigger a refetch. Refetching an entire message list on every arriving message would be an N+1 disaster.
Fourteen primitives. Every screen is assembled from them; nothing is styled ad-hoc.
components/ui/
Avatar Β· Badge Β· BrandIcons Β· Button Β· ConfirmDialog Β· EmptyState
ErrorBoundary Β· IconButton Β· Input Β· Logo Β· Modal Β· PresenceDot
Skeleton Β· Spinner
Modalis built on the native<dialog>β focus trap,Esc, top layer, inert background and focus restoration, all for free and all correct. Hand-rolled modals get every one of those wrong.Inputpasses native constraints (required,pattern,maxLength) straight through β the browser validates for free, with correct accessibility. State-based validation is reserved for what the browser cannot know.IconButtonguarantees a 44 Γ 44 px hit area on touch (WCAG 2.5.8) without changing a single pixel of the desktop layout.
- β Every action is reachable by keyboard β focus is trapped in drawers and modals, and restored to the trigger on close
- β
Overlay drawers are
inertβ no invisible tab stops hiding behind a scrim - β No meaning is carried by colour alone β unread, presence, ticks and admin each have a text/ARIA equivalent
- β Live regions announce new messages and toasts
- β
prefers-reduced-motionis honoured - β Both themes are built from the same design tokens, so contrast is a property of the token set rather than of individual components
β¨οΈ Known gap β documented, not hidden
There is no roving
tabindexacross the message list. Every action is keyboard-reachable, but crossing a 50-message room costs ~150Tabpresses. That is a re-architecture of the list's keyboard model, and it is deliberately deferred rather than quietly claimed as done.
// The window is measured from `createdAt`, not from the previous edit β
// otherwise editing every 59 minutes would keep a message editable forever.The code already says what it does. A comment exists only to record a constraint the code cannot show.
725 tests. Five layers. Each testing the thing it is genuinely good at.
| Layer | Tooling | Location | What it drives | Count |
|---|---|---|---|---|
| Unit | Jest | backend/tests/unit |
Pure functions, guards | 163 |
| Integration | Jest + Supertest | backend/tests/integration |
Real API + real MongoDB | 284 |
| Component | Vitest Β· RTL Β· MSW | frontend/tests |
Rendered React, real axios | 245 |
| API E2E | Playwright | backend/e2e |
Two live servers + Valkey β‘ | 27 |
| Browser E2E | Playwright | frontend/e2e |
Real Chromium, the real app | 6 |
- MSW mocks the network, not axios β so the interceptors, the
401βrefreshβretry, and the error normalisation all genuinely execute. Mocking axios would skip the very code most worth testing. - Browser E2E drives the real UI β a real Chromium signs in through the real email-OTP screens, creates a room, sends a message, and a second browser watches it arrive live.
- Tests are centralised, mirroring
src/.src/contains source and nothing else.
# backend # frontend
npm run test:unit npm test # Vitest
npm run test:integration npm run test:e2e # real browser
npm run test:e2e npm run build
β οΈ Run the E2E suites as their own CI step. Chaining them behind the unit suites causes database contention and false failures.
Every defence below is implemented and covered by a test.
| Threat | Defence |
|---|---|
| XSS | Markup stripped at the source (stripHtml) + React escaping. Zero dangerouslySetInnerHTML. |
| NoSQL injection | Mongo operators ($gt, $ne, dotted paths) stripped from body, params and query |
| ReDoS | Every user-supplied search term is regex-escaped and length-capped before it reaches the database |
| CSRF | SameSite=Lax plus a server-side Origin check that fails closed in production |
| Token theft | 15-min JWT held in memory (never localStorage); 30-day opaque refresh token in an httpOnly, path-scoped cookie |
| Token replay | Refresh tokens rotate; a replayed token revokes the whole family. Only a SHA-256 hash is ever stored. |
| Brute force | Rate limiting Β· a 5-attempt OTP burn Β· and no account enumeration |
| Misconfiguration | The server refuses to boot in production with a missing/weak JWT_SECRET or a wildcard CLIENT_ORIGIN |
| Algorithm confusion | JWT algorithm pinned to HS256 on both sign and verify |
| Payload DoS | 10 KB body limit Β· per-socket flood control |
| Headers | helmet β nosniff, frame-options, no x-powered-by |
| Dependencies | npm audit β 0 vulnerabilities, both projects |
zentro/
βββ backend/
β βββ src/
β β βββ app.js Express only β mountable by supertest, no listener
β β βββ server.js Bootstrap: env β db β valkey β‘ β io β listen
β β βββ config/ env Β· db Β· valkey β‘ Β· passport
β β βββ constants/ Every magic value, in one place
β β βββ controllers/ HTTP in, HTTP out. No logic.
β β βββ services/ Business rules + authorization
β β βββ models/ Mongoose schemas + indexes
β β βββ middleware/ auth Β· validate Β· security Β· rateLimit Β· errorHandler
β β βββ routes/ URL β handler
β β βββ socket/ Real-time handlers (membership re-checked per event)
β β βββ validators/ zod schemas
β β βββ lib/ io Β· logger Β· tokenStore Β· mailer
β β βββ utils/ AppError Β· sanitize Β· serializers Β· notify
β βββ tests/{unit,integration}
β βββ e2e/ Playwright β TWO servers over Valkey β‘
β
βββ frontend/
β βββ src/
β β βββ components/{ui,chat,modals,auth}
β β βββ context/ Auth Β· Socket Β· Theme Β· Toast
β β βββ hooks/ Server-state + real-time bridges
β β βββ lib/ apiClient (ONE axios) Β· socket Β· tokenStore Β· queryKeys
β β βββ services/ API calls, grouped by resource
β β βββ pages/ Login Β· AuthCallback Β· Chat
β β βββ styles/ tokens.css Β· global.css
β βββ tests/ Vitest Β· RTL Β· MSW
β βββ e2e/ Playwright β a real browser
β
βββ DEPLOYMENT.md βοΈ The full AWS runbook
βββ README.md
Prerequisites β Node 20+ Β· MongoDB Β· Valkey (or Redis)
docker run -d --name valkey -p 6379:6379 valkey/valkey# 1 β Backend
cd backend
npm install
cp .env.example .env # fill in MONGO_URI, JWT_SECRET, OAuth keys
npm run dev # β :4000
# 2 β Frontend
cd frontend
npm install
npm run dev # β :5173# terminal 1
PORT=4000 npm start
# terminal 2 β same MONGO_URI, same VALKEY_URL
PORT=4001 npm startOpen the app in two browsers, point each at a different port, and chat between them.
The messages cross because Valkey is carrying them. Stop Valkey, and the two servers immediately go deaf to one another β which is the clearest possible demonstration of what the adapter does.
Zentro runs across two EC2 instances behind an Application Load Balancer, with a self-hosted Valkey node, MongoDB Atlas, an ACM certificate and Cloudflare DNS.
It is a genuine step-by-step production runbook, not a summary:
| π System design | Security groups, VPC, the complete topology |
| π΄ The Valkey node | Docker, locked to the app security group β never exposed to 0.0.0.0/0 |
| π₯ Golden AMI | Build one app server, image it, launch the second from that image |
| π TLS | ACM certificate + DNS validation β and the Cloudflare proxy trap that silently breaks it |
| βοΈ Load balancer | Target groups, health checks, sticky sessions for the OAuth round-trip |
| π Cloudflare | DNS records and the grey-cloud requirement |
| π Troubleshooting | Every real bug hit during the deployment β and its fix |
The proof it works:
curlthe health endpoint repeatedly and the returnedpidalternates between the two EC2 instances β while a chat message sent on one still lands instantly on the other, because Valkey is carrying it.
HTTP endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/auth/google Β· /api/auth/github |
Begin OAuth |
GET |
/api/auth/callback/:provider |
OAuth callback |
POST |
/api/auth/email/request |
Email a one-time code |
POST |
/api/auth/email/verify |
Exchange the code for a session |
POST |
/api/auth/refresh |
Rotate the refresh token |
POST |
/api/auth/logout |
Revoke the session |
GET |
/api/auth/me |
The current user |
| Method | Path | Purpose |
|---|---|---|
GET |
/api/rooms |
My rooms (with unread flags) |
GET |
/api/rooms/discover |
Rooms I am not in |
POST |
/api/rooms |
Create |
PATCH DELETE |
/api/rooms/:id |
Update Β· delete (creator) |
POST |
/api/rooms/:id/join Β· /leave |
Join (or request) Β· leave |
POST |
/api/rooms/:id/invite Β· /invite/decline |
Invite Β· decline |
GET POST |
/api/rooms/:id/requests[/:userId/approve|reject] |
Moderate join requests (admin) |
POST DELETE |
/api/rooms/:id/admins/:userId |
Promote Β· demote (creator) |
| Method | Path | Purpose |
|---|---|---|
GET |
/api/rooms/:id/messages?before=&limit= |
History (cursor-paginated) |
GET |
/api/rooms/:id/messages/search?q= |
Search |
GET |
/api/rooms/:id/messages/:messageId/replies |
A thread |
GET |
/api/rooms/:id/members Β· /receipts |
Roster Β· read receipts |
POST |
/api/rooms/:id/read |
Mark read |
Socket.IO events β every one of these crosses servers via Valkey β‘
Client β Server
room:join Β· room:leave Β· message:send Β· message:edit Β· message:delete Β· message:react Β· typing
Server β Client
ready Β· message:new Β· message:updated Β· message:deleted Β· presence:joined Β· presence:left Β· request:new Β· request:approved Β· request:rejected Β· room:invited Β· invite:declined Β· room:deleted Β· room:read Β· room:admin
Client contract: wait for
ready, notconnect. Handlers are registered synchronously, but the server finishes joining you to your rooms asynchronously βreadyis the signal that it is safe to emit.
| Real-time | Socket.IO 4 Β· socket.io-valkey-adapter β‘ Β· iovalkey |
| Backend | Node 20 (ESM) Β· Express 4 Β· Mongoose 8 Β· Passport Β· zod Β· helmet |
| Frontend | React 19 Β· Vite 8 Β· TanStack Query Β· axios Β· CSS Modules |
| Data | MongoDB β the truth Β· Valkey β the wire |
| Testing | Jest Β· Supertest Β· Vitest Β· React Testing Library Β· MSW Β· Playwright |
| Infrastructure | AWS EC2 Β· ALB Β· ACM Β· MongoDB Atlas Β· Cloudflare Β· Docker |