All articles
Guides

Building In-App Chat That Holds Up at Group Size: Notes From Trainr

Chat is never the week it looks like. Our notes from building Trainr's chat: a badge that ran 20 queries per message, presence that expires on its own, the paused iPhone, a listener that unsubscribed the whole app, and the test list we now run.

Surya Kumar Muni5 November 20268 min read
Building In-App Chat That Holds Up at Group Size: Notes From Trainr

Quick answer

Chat that holds up at group size keeps per-member work off the message path. In Trainr, one message in a 20-person group once ran 20 unread-count aggregations; a Redis counter per user and room, seeded once from MongoDB, now updates in about a millisecond. Presence expires after 300 seconds unless the app's heartbeat renews it, because a closed app cannot say goodbye, and paused iPhone apps get their messages through push and catch up on reopening.

Chat is the feature every client thinks is a week of work, and it never is. The message list is the easy part. What takes the time is everything that only goes wrong once real people use it: a group that grows from three members to twenty, a phone that pauses the app in someone's pocket, a person who swipes the app away and stays "online" for the rest of the afternoon.

We built one-to-one, group and community chat into Trainr, a fitness marketplace where trainers and clients talk every day. Underneath it are three systems that have to agree with each other: MongoDB holds every room and message, Redis holds whatever has to be fast or shared between server processes, and Socket.IO carries the live events. Most of the work, honestly, was keeping those three in agreement.

These are our notes on where it broke, roughly in the order we met each problem, and what we changed.

The badge that ran twenty queries

The first version worked out unread counts the obvious way. When someone sent a message, the server ran an aggregation for every other participant to recount their unread badge. In a three-person test group that cost nothing anyone could see. In a 20-person group it meant 20 aggregations for every message, including every "ok" and every thumbs-up.

Nothing was broken in the sense that a test would catch. The numbers were right. They were just expensive in a way that grows with group size multiplied by message volume, which is exactly the shape of cost you don't notice until a busy community shows up.

The fix was to stop recounting. Each user and room now has a counter in Redis. It is seeded once from MongoDB, incremented on each new message and cleared when the room is read, so the next message re-seeds it from the database. An update takes about a millisecond whatever the size of the group. If Redis fails, the code falls back to the old aggregation: slower, but correct, which is the trade we wanted.

While we were in there, a few more per-message costs came out. Marking a room read had run one query per message; it is now a single updateMany. Sending a message went from five database queries to three, with notifications sent in parallel. Cached room lists and unread totals are deleted on every send and read, so nobody's chat list shows yesterday's preview.

If you take one thing from this post: find every piece of work your chat does once per member per message, and test with a group of twenty or fifty, not three. A cache or a counter is usually the answer, but you have to find the path first.

Two server processes, one conversation

The moment you run more than one server process, a message sent to one process has to reach sockets held by another. Trainr's Socket.IO runs on the Redis adapter for exactly this reason, so rooms are shared across processes. It is a small piece of configuration and a very confusing bug if you forget it: messages arrive for some people in a room and not others, depending on which process their phone happened to connect to.

If you are choosing between a live connection and asking the server "anything new?" every few seconds, our WebSocket glossary entry covers the difference. Polling is fine for a prototype. At any real size most of those requests return nothing and messages still arrive late.

People who never said goodbye

A closed app can't announce that it has gone. A phone that loses signal, runs out of battery or gets swiped away just stops talking. So if presence is a flag that turns on when the app connects and off when it says goodbye, people stay green long after they've put the phone down.

Trainr's presence expires by itself. Online users sit in a Redis set, and each user's presence record lasts 300 seconds unless the app's heartbeat renews it. A closed app stops renewing and the user drops off when the time runs out. Nobody has to detect the departure, which is good, because nobody can.

Typing indicators get the same treatment on a shorter clock, and the server rate-limits typing events per user. Without the limit, one enthusiastic typist generates a surprising amount of traffic for a three-dot animation.

The paused phone

iPhones pause apps in the background. The connection goes quiet, timers stop, heartbeats stop. You will not see this at your desk with the app in the foreground, and your users will see it constantly.

We learned this one on Pakka, our delivery platform, not in a chat, but it transfers directly. Pakka's server marked drivers offline after five minutes of silence, and iPhone drivers who glanced at a message mid-trip came back to find themselves offline because the phone had paused the app. The window for iPhone drivers is now 30 minutes.

For chat, a paused app means three things. Messages have to arrive through push notifications while the app is asleep, and the conversation has to catch up from the server when it wakes. Read receipts must not fire for messages nobody has looked at. And presence should fade on its timer rather than flip. Our Firebase Cloud Messaging page describes how we send pushes to Android and iPhone from the same server code.

The reconnect deserves its own test. Walk into a lift with the app open, send a message from another phone, walk out. The missed message should arrive once, not zero times and not twice.

A typing indicator that redrew the whole screen

Some of the slowness was on the phone, not the server. A peer's typing used to re-render Trainr's entire chat screen, which had grown to about 1,500 lines. On a recent phone you'd barely notice. On an older Android phone, redrawing that much every time the other person starts or stops typing is exactly how you get a stutter. The typing indicator now subscribes to its own piece of state and nothing else redraws.

The chat list had the same problem at a different scale. It re-rendered on any of twenty-plus socket event types. Its selector now reads nine fields, so it only redraws when something it actually shows has changed.

The listener that unsubscribed everyone

This was our favourite bug, in the sense that a bug can be a favourite. Removing a socket listener with .off(event) and no handler doesn't remove your listener. It removes every listener for that event across the whole app. One screen tidying up after itself was quietly deafening the others. Handlers are now stored and removed one by one, by reference.

Bugs like this take far longer to find than to fix. If chat events stop arriving on one screen after you've visited another, look here first.

Storage choices that matter more than the database

Every message is stored once, with its room, sender, time and delivery state. Beyond that, three choices did more for us than anything about the database itself:

  1. Load history in pages. A long conversation should open on the latest messages and fetch older ones as the user scrolls up. Loading everything is the most common reason chat screens crawl on older phones.
  2. Index the two questions that run all day: latest messages in this room, and this user's rooms, newest first. See database index.
  3. Store times in UTC and show them in each person's zone. A trainer in Dubai and a client in Bengaluru should both read the same conversation correctly. We had a separate scare with this elsewhere in Trainr, where sessions and classes once used different clocks, and every date-sensitive path now works in UTC days. The glossary has more on UTC and time zones.

Photos and files go to object storage behind short-lived links, never into the database.

Groups need rules, not just a message box

One-to-one chat between a trainer and a client mostly needs report and block. Groups and communities need more, because strangers end up talking to strangers. Trainr's groups have member roles, alongside muting, blocking and pinning, and the superadmin console has chat rooms, communities and moderation, so the people running the product can see and act on what gets reported. When a member is removed from a group, they keep the conversation up to that point, read-only. To us that is fairer than making their history vanish.

Start moderation manual, with good tools for the humans doing it, and automate only the things you see repeated. Our content moderation entry covers the options. Whatever you decide about privacy and deletion, say it plainly in your store forms; the guide to Google Play data safety and account deletion covers what Google expects.

Renting a chat service instead

Hosted chat services exist, priced per active user or per message, and they're a sensible choice when chat is a side feature or there is no backend team. We built Trainr's chat in because it is tied to bookings and to the trainer-client relationship, and the backend already ran a socket. Your answer depends on how central chat is and whether per-user fees would grow faster than revenue.

If you are adding an AI assistant to a chat rather than chat between people, the cost model changes completely, because every reply has a price. We wrote up how we meter that in what an AI plant scan costs.

The test list we now run

Before a chat ships, on real phones:

  • Twenty or more members sending at once, watching server load and unread counts.
  • The lift test: offline messages arrive once, nothing duplicates on reconnect.
  • An iPhone left in the background for half an hour: pushes arrive and the room catches up on reopening.
  • A swiped-away app: presence drops within the expiry time.
  • A room with thousands of messages, opened on an older Android phone.
  • A report reaches the admin view; a blocked user can't message again.
  • Two time zones reading the same thread.

Our QA and testing page describes how these fit into a release.

If we were starting Trainr's chat again, we'd write the unread counter and the expiring presence on day one instead of discovering why we needed them, and we'd test with a big group before anyone asked us to. Chat fits into most of the products we build, from fitness apps to marketplaces, and it is always the feature where the demo lies the most.

Should a removed group member still see old chat messages?

That is a product decision, and it is worth making on purpose. In Trainr a member removed from a group keeps the conversation up to the point they were removed, read-only, and sees nothing after it. The alternative, deleting their copy of the history, is tidier but surprises people who relied on what was said.

Is polling ever good enough for in-app messaging?

For a prototype, or for something like a support inbox where a delay of a minute is fine, yes. For conversation it is not: most polls return nothing, the server pays for them anyway and messages still arrive seconds late. Once a product already runs a socket for something else, such as live tracking, chat on the same connection is the cheaper path.

Should in-app chat be end-to-end encrypted?

Marketplaces and communities usually keep messages readable by the server so they can moderate reports and sync history to a new phone, with encryption in transit and at rest. End-to-end encryption protects privacy far more strongly but makes moderation, search and multi-device history much harder. It suits products whose whole promise is private conversation. Say which you chose in the privacy policy.

Your project

Adding chat to your app, or fixing one that slows down?

Tell us what you are building and the one journey that must work. We reply with the questions that shape the plan, and every project starts with a two-week Discovery Sprint.

Start a project →

Part ofMarketplace apps: every page on the topic, in one place