Skip to content

Case study · Real-time platform

TalkSpace

Chat is the easy half. The interesting half is that the state lives in three places at once — a social graph in MongoDB, the messaging and video session inside Stream’s infrastructure, and a cache of both in the browser. Almost every decision below is really the same question: when these three disagree, which one is telling the truth?

16
REST endpoints
4,337
Lines of source
61
Source files
6
Months of work

A friendship is
not a boolean

It is an edge with a lifecycle, and the lifecycle is what the product is made of — the notification badge, the pending state on a button, the difference between declining and cancelling. Four states, and two different records describing them.

01Strangersno document

Nothing exists. The absence of a row is the state — which is why every send has to prove the absence first, in both directions at once.

const existingRequest = await FriendRequest.findOne({
  $or: [
    { sender: myId,        recipient: recipientId },
    { sender: recipientId, recipient: myId        },
  ],
});

Checking only one direction is the bug that looks correct. Without the $or, two people who happen to add each other at the same time end up with two pending requests for one friendship, and accepting either leaves the other stranded forever.

02PendingFriendRequest { status: 'pending' }

One document, with a sender and a recipient. The direction matters for what each side is shown, and it is the only thing that distinguishes declining from cancelling.

03Friendsstatus: 'accepted' + both users' friends[]

Accepting writes twice: the request is marked accepted, and each user is pushed into the other's friends array. The row records how the edge came to exist; the arrays are the graph itself.

friendRequest.status = "accepted";
await friendRequest.save();

await User.findByIdAndUpdate(friendRequest.sender, {
  $addToSet: { friends: friendRequest.recipient },
});
await User.findByIdAndUpdate(friendRequest.recipient, {
  $addToSet: { friends: friendRequest.sender },
});

$addToSet rather than $push because the write has to survive being run twice. A double-tapped accept button, a retried request, a stale client — any of them replays this, and a set does not care.

04Withdrawndocument deleted

Declining and cancelling are the same delete, reached from opposite ends. Removing a friend is a different operation entirely — it pulls both arrays and leaves no request behind.

Four decisions
worth defending

The parts where the obvious implementation is wrong, and why.

The password is excluded twice, because one exclusion cannot see the other

The schema marks it select: false, so no ordinary query returns the hash even if someone forgets to exclude it. Login opts back in explicitly, at the one place that genuinely needs it — the exception is visible in the code rather than assumed.

// Models/User.js
password: { type: String, select: false }

// auth.controller.js — the one deliberate opt-in
const user = await User.findOne({ email }).select("+password -__v");

That protection has a hole, and it is not obvious. select is a Mongoose feature, and the recommendation query is not a Mongoose query — it is an aggregation pipeline handed straight to MongoDB. $sample returns whole documents, hash included, and the schema never gets a say. So the pipeline strips them by hand:

// aggregates don't trigger mongoose selects
const sanitizedUsers = recommendedUsers.map((user) => {
  const { password, ...safeUser } = user;
  return safeUser;
});

One friendship, two representations

The same relationship is stored as a FriendRequest document and as an entry in each user’s friends array. That is duplication, and it is deliberate.

They answer different questions. The document answers how did this edge come to be — who asked, who accepted, when — which is what the notifications page is built from. The array answers who are this user’s friends right now, which is asked on nearly every screen and wants to be one indexed lookup, not a scan over a request collection in two directions.

The cost is that they can drift, so every transition writes both, and both sides of every array.

Declining and cancelling are the same row

Accepting is asymmetric — only the recipient may do it, and the sender gets a 403. But deletion is not, because one document serves two features: the recipient declining, and the sender changing their mind.

// Both the sender (cancelling) and
// recipient (declining) may delete it
const isInvolved =
  friendRequest.recipient.toString() === req.user.id ||
  friendRequest.sender.toString()    === req.user.id;

Writing the recipient-only check here by symmetry with accept would have quietly removed the ability to withdraw a request.

One socket, one owner

Online dots need the same Stream connection the chat is already using. Opening a second one works in development and is wrong everywhere else — two sockets per tab, two presence streams, and a race over which one disconnects last. The presence hook attaches to the existing client instead, and its cleanup deliberately does less than it looks like it should:

client = StreamChat.getInstance(STREAM_API_KEY);
if (!client.userID) { /* connect only if nobody has */ }

return () => {
  mounted = false;
  handler?.unsubscribe?.();
  // Do NOT disconnect the client here — ChatPage owns the connection
};

A hook that borrows a resource has to unsubscribe from it without closing it. Ownership is a decision someone has to make explicitly, and the comment is there because the correct code looks like an omission.

The secret never
reaches the browser

The browser has to talk to Stream directly — that is the point of using it, and it is why messages and video do not route through my server. But the API secret that authorises those calls can never be shipped to it, because anything in a bundle is public.

So the server keeps the secret and mints a token scoped to a single user, behind the same auth middleware as everything else. The client asks for one and gets a credential that can only ever be itself.

// routes/chat.route.js
router.get("/token", protectRoute, getStreamToken);

// lib/stream.js — secret lives here, server-side only
export const generateStreamToken = (userId) =>
  streamClient.createToken(userId.toString());

What I’d change

Four things I know are wrong with it, which is a different list from the things I have fixed.

The sanitizer steps over arrays

sanitizeObject recurses into nested objects but its type check is object && !Array.isArray, so an array falls through to the untouched branch. A string inside an array reaches the database with its tags intact. Nothing currently posts one, which is exactly why it would be easy to miss later.

Rate limiting only covers /api/auth

20 requests per 15 minutes per IP, which is the right shape for login and signup. The friend-request endpoints have none, so an authenticated account can enumerate sends as fast as it likes.

Presence is seeded once, then trusted

One queryUsers call establishes who is online, and after that the UI follows user.presence.changed events. A dropped socket means the dots stay as they were until something remounts the hook.

Cold starts are the first impression

The API sleeps on a free instance, so the first request after an idle period takes about twenty seconds. That is a hosting tier, not an architecture — but it is what a visitor actually experiences.

Source

Read the code

React 19 · Node · Express · MongoDB · Stream SDK · Zod validation · JWT in an httpOnly, SameSite=Strict cookie · bcrypt · TanStack Query · installable PWA · GitHub Actions for CI.
Every excerpt above is copied from the repository, and every figure is counted from it.

© 2026 Divyansh Garg