Curated Work / chat-application
Chat Application
Real-time one-to-one and group messaging, done right.


Overview
A real-time messaging platform supporting 1:1 and group conversations with live presence, typing indicators, and per-message delivery status (sent → delivered → read). The React/Redux client talks to a Node.js + Express + Socket.io backend over a persistent WebSocket connection, with MongoDB backing durable message history and a JWT-secured auth layer that gates both the REST API and the socket handshake. The hard parts were not "send a message" — they were reconnection, state reconciliation after a dropped connection, and keeping a normalized client store in sync with an event stream without triggering re-render storms. I designed the socket layer to sit behind a Redux middleware boundary so the entire real-time surface has a single source of truth.
A modern, full-stack chat application built for real-time communication with secure authentication, responsive design, and a smooth, user-friendly messaging experience.
Key Features
- Real-time one-to-one and group messaging with instant updates.
- Secure user authentication for protected conversations.
- Message delivery status and conversation history persistence.
- Clean, responsive UI optimized for desktop and mobile.
- Scalable architecture designed for high-concurrency usage.
Why I Built This
I wanted to build the one class of app that punishes shortcuts: real-time chat. A CRUD app forgives you — a stale render is a refresh away. A chat app does not. If a delivery tick is wrong, if a message shows up twice after a reconnect, or if presence says someone is online three minutes after they closed the tab, the product feels broken even when every request technically succeeded. I built this to force myself to reason about connection lifecycle, event ordering, and optimistic UI as first-class concerns rather than afterthoughts.
The second reason was state architecture. Most tutorials wire socket listeners directly into components, and it works right up until you have three tabs open, a flaky network, and a Redux store that disagrees with the server. I wanted to prove out a pattern where every socket event and every outbound emit flows through one controlled boundary, so the client is always reconstructable from the store and reconnection is a reconciliation problem, not a guessing game.
Architecture Overview
The system is a two-tier app: a React SPA and a Node.js service that runs Express (REST) and Socket.io (real-time) on the same HTTP server, sharing one JWT auth layer. The client keeps a normalized Redux store; a custom socket middleware is the only code that ever touches the Socket.io instance, translating dispatched actions into emits and inbound socket events into dispatched actions. MongoDB persists users, conversations, and messages, and an outbound webhook service fans significant events (new message, mention) out to external integrations and the offline push pipeline.
client/
src/
app/store.js # RTK store + socketMiddleware
features/
auth/authSlice.js
chat/
chatSlice.js # messages normalized by id, per-convo
presenceSlice.js # online / typing / lastSeen
socketMiddleware.js # THE Socket.io <-> Redux boundary
hooks/useSocket.js
components/ # MUI widgets + Tailwind layout
server/
src/
index.js # Express + http.Server + Socket.io
sockets/
index.js # io auth middleware, connection
handlers/
message.handler.js # send, ack, delivered, read
presence.handler.js # heartbeat, typing, disconnect
models/
User.js Conversation.js Message.js
routes/ # auth, conversations, history (REST)
services/webhook.js # outbound event dispatch
middleware/auth.js # JWT verify: HTTP + WS handshake- Frontend — React with Redux Toolkit for state, Tailwind for layout/spacing utilities and Material UI for the accessible complex widgets (menus, dialogs, avatars, snackbars). All real-time I/O is isolated in one middleware file.
- Real-time transport — Socket.io over a single persistent connection, using rooms per conversation and ACK callbacks for delivery confirmation, with automatic reconnection and long-polling fallback.
- Data layer — MongoDB via Mongoose with three core collections (User, Conversation, Message) and a compound index on { conversationId, createdAt } powering cursor-based history pagination.
- Auth — JWT access + refresh tokens in httpOnly cookies; the same verification middleware guards REST routes and runs inside the Socket.io handshake so an unauthenticated socket never connects.
- Integrations — An outbound webhook service dispatches message/mention events to external consumers and drives offline notifications, decoupled from the request/socket path.
Real-Time Transport, Presence & Delivery Status
Every authenticated socket joins a personal room (keyed by user id) and a room per active conversation. Sending is optimistic: the client generates a temporary client-side message id, renders it immediately in a 'sending' state, and emits it with an ACK callback. When the server persists the message it responds through the ACK with the canonical MongoDB _id and server timestamp, and the client reconciles the temp message in place — no duplicate, no flicker. If the ACK doesn't return within a timeout, the message flips to a 'failed' state with a retry affordance.
Delivery status is a three-stage lifecycle — sent, delivered, read — modeled as explicit events rather than inferred. 'Delivered' fires when a recipient's socket receives the message and emits a delivery receipt; 'read' fires when the conversation is focused and visible. Both update the message document and broadcast back to the sender's room so the tick state stays consistent across all of that user's open tabs. Presence and typing indicators ride the same connection: a lightweight heartbeat plus the socket disconnect event drive online/offline, and typing events are debounced client-side and broadcast only to the relevant conversation room to avoid noise.
- Rooms, not broadcasts — Events target conversation rooms and per-user rooms, so a typing or delivery event only reaches the handful of sockets that care, not the whole server.
- ACK-based confirmation — Socket.io acknowledgement callbacks give me a request/response shape over the socket, which is what makes reliable temp-id reconciliation possible.
- Multi-tab consistency — Because status broadcasts go to the sender's user room (all their sockets), read/delivered ticks never disagree between a phone and a laptop tab.
Reconnection & State Reconciliation
Reconnection is where naive chat apps break — a mobile client that backgrounds for thirty seconds and comes back must not miss messages or double-render them. I treat every reconnect as a sync operation. The socket middleware tracks the last received message id (or timestamp) per conversation in the Redux store; on Socket.io's reconnect event it emits a resync request carrying those cursors, and the server streams back only the messages created after each cursor, plus any queued delivery/read receipts that the client missed while offline.
The client store is fully normalized — messages are held by id under each conversation — so applying a replayed batch is an idempotent merge: known ids are skipped, new ids are inserted in order. That normalization is also what makes status updates O(1): marking a message 'read' patches one entry by id instead of scanning and rewriting an array. Presence is re-derived on reconnect too, since a client that was offline can't trust its cached online/offline map.
- Cursor-based resync — On reconnect the client sends per-conversation cursors; the server replays the delta, so bandwidth scales with what was missed, not with history size.
- Idempotent merges — Normalized-by-id storage means replaying an overlapping batch is safe — duplicates are impossible by construction.
- Queued receipts — Delivery and read receipts generated while a peer was offline are persisted and flushed on their return, so ticks eventually converge to the truth.
Backend Systems
The backend is a single Node.js/Express service that also owns the Socket.io server bound to the same HTTP listener, which lets REST and WebSocket share the exact same JWT verification code. REST handles the request/response surface — login, refresh, conversation lists, and paginated history — while the socket layer handles everything live. History uses cursor-based pagination (createdAt + _id) rather than offset/skip, so deep scroll-back stays cheap and correct even as new messages arrive during paging.
Persistence is three Mongoose models with deliberate indexing. Messages carry conversationId, sender, body, a status field, and timestamps, indexed on { conversationId, createdAt } to serve both the newest-first initial load and cursor paging. The webhook service is intentionally decoupled: message and mention events are dispatched to registered outbound webhooks and the push pipeline asynchronously, so a slow external consumer can never block message delivery on the socket.
- Shared auth — One middleware verifies JWTs for both HTTP requests and the Socket.io handshake; a socket that fails verification is rejected before 'connection' ever fires.
- Cursor pagination — History pages on { createdAt, _id } cursors instead of skip/limit, avoiding the well-known offset drift when new rows are inserted mid-scroll.
- Indexed reads — The compound conversationId+createdAt index keeps both the initial fetch and infinite scroll off collection scans.
- Decoupled webhooks — Outbound event dispatch runs off the hot path so external integrations and offline push never add latency to real-time delivery.
Key Decisions
- Socket.io over raw WebSocket — I gave up a little protocol purity for automatic reconnection, transport fallback, rooms, and ACK callbacks — all of which I'd otherwise have reimplemented by hand, and worse.
- A Redux middleware as the only socket boundary — Instead of scattering socket.on listeners across components, all real-time I/O lives in one middleware. Components stay declarative, and the store is the single source of truth the UI renders from.
- Normalized message state over arrays — Storing messages by id per conversation makes status patches O(1) and reconnect merges idempotent — the two operations that happen most and hurt most if they're O(n).
- Optimistic sends with client temp ids — The UI updates instantly and reconciles against the server's canonical id on ACK, with a timeout-driven 'failed + retry' path. Perceived latency is near zero without lying about delivery.
- Tailwind and Material UI together — An unusual pairing on purpose: MUI for battle-tested accessible widgets (dialogs, menus, avatars), Tailwind for fast, consistent layout. I scoped them so they don't fight over the same concerns.
- JWT in httpOnly cookies over localStorage — Tokens in httpOnly cookies keep them out of reach of XSS, and the handshake reads the same cookie, so the socket authenticates with zero extra token plumbing on the client.
What I Learned
- Reconnection is the product — The interesting engineering isn't the happy path; it's the ten seconds after the network blips. Designing every reconnect as an explicit cursor-based resync fixed a whole class of duplicate/missing-message bugs at the root.
- One boundary beats many listeners — Funneling all socket traffic through a single Redux middleware made the real-time layer testable and debuggable — I can reason about the entire live surface by reading one file.
- Delivery status must be modeled, not inferred — Treating sent/delivered/read as explicit, broadcast events (rather than guessing from timing) is what keeps ticks honest across multiple tabs and devices.
- Data shape drives performance — Normalizing client state and adding the right compound index did more for responsiveness than any micro-optimization — the correct shape made the expensive operations cheap by default.
Tech Stack
Like what you see?
Explore more work or get in touch.
