



PRA Chat App
A real-time messaging application with WebSocket-based delivery, secure authentication, and a performance-tuned frontend.
WebSocketsReactExpress.jsNode.jsMongoDB
The Problem
Pacific Rim Athletics needed an internal chat tool that a generic group chat couldn't give them: channels organized under channel groups, private channels that require approval to join, and an admin layer that could actually govern the workspace instead of just watching messages scroll by. You can read the real requirement straight out of the schema — a granular PermissionEnums model instead of a binary admin/user flag, a report/moderation flow, an admin panel for managing users and roles. Nobody adds those for fun. They're there because someone running this needs oversight, not just a firehose.
So the actual brief was "build a chat app an organization can govern" — channels that can be public or gated, permissions that aren't just admin-or-not, and messaging that feels instant whether it's a public channel or a one-on-one.
How It Works
Express + TypeScript over MongoDB on the backend, Socket.IO for real-time delivery, Redis underneath so delivery doesn't depend on one running process.
WebSocket delivery across processes. A naive Socket.IO setup only delivers messages to sockets connected to the same server process — run more than one instance behind a load balancer and two users on different instances stop seeing each other's messages. The fix is a Redis-backed adapter, both a pub and sub client connected before the adapter attaches:
const pubClient = createClient({ url: process.env.REDIS_URL ?? '' })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient))
})One honest detail: socket connections currently authenticate off a raw userId query param, not a verified JWT. There's a socket-auth middleware already written
// io.use(authorizeSocket)
io.on('connection', (socket) => {
const userId = socket.handshake.query.userId
...it's just commented out. Anyone who can guess or intercept a valid user ID can open a socket as that user today. Re-enabling it and verifying the JWT on handshake is on my list.
Auth flow. bcrypt for password hashes, JWT signed on login and attached to both REST calls and the socket handshake. New accounts verify by email (JWT-encoded token via SES); forgot-password reuses the same short-lived-token pattern. One user-visible quirk: an unverified user who tries to log in doesn't just get rejected — the handler re-checks their password and re-sends the verification email instead.
Performance-tuned frontend. Route-based code-splitting is real in the production build — Login, Register, Chat, and admin pages each compile to their own chunk. Workspace appearance (accent color, gradient, font) lives as a Mongo document instead of being hardcoded, fed straight into Ant Design's ConfigProvider. Rebranding is an admin editing a record, not a deploy.
A Hard Part
Direct messages aren't a separate system — a DM is a private channel between two people, created lazily on first contact instead of upfront for every pair. That reuse means read receipts, permissions, and history all work for DMs for free. But lazy creation means the first message has to answer a real question at request time: does a channel for this pair already exist? The actual handler does it in two steps:
const channel = await channelModel.exists({
directMessageUser: true,
addedUsers: { $all: [userId, user2Exist._id] },
})
if (!channel) {
newChannel = await channelModel.create({ values: directMessageChannelParams })
}That's a check followed by a separate write, not one atomic operation. Two people messaging each other for the first time at nearly the same moment can both hit exists(), both get null back, and both proceed to create() — leaving the pair with two DM channels and their history silently split across both.
Compare that to the channel-group lookup two lines later, which is safe, because it's one atomic upsert instead of check-then-write:
const channelGroup = await channelGroupModel.findOneAndUpdate({
filter: { name: 'Direct Message' },
values: {
$setOnInsert: { name: 'Direct Message', createdByAdmin: ... },
$addToSet: { channels: channel?._id ?? newChannel },
},
options: { new: true, upsert: true },
})MongoDB guarantees findOneAndUpdate with upsert: true never creates two "Direct Message" groups, no matter how many requests land at once. The channel schema has no equivalent — no unique compound index on (directMessageUser, addedUsers). I traded that guarantee away to keep DMs as plain channels instead of standing up a second data model for one path, and it paid for itself everywhere else. The fix is small and known: a unique compound index on the two participant IDs, so the second insert fails instead of silently succeeding.
What I Learned
Reusing the channel model for DMs paid off more than it looked like on day one — every feature built for channels afterward worked for direct messages for free. But it doesn't automatically inherit the guarantees a purpose-built model would have had by construction. Simpler design with a known, documented sharp edge beats more machinery everywhere to guard against a case I invented to feel thorough.