Mélange is a talent network for musical, digital and visual artists, spanning continents and skill sets. A producer looking for a vocalist, an illustrator looking for a label, a photographer looking for both.
I built the backend, the real-time layer, and the deployment.
The product's entire value is in who can find and reach whom. That makes discovery and messaging the system itself, not features layered on top of it - and it puts a hard constraint at the centre of the design: the two people in a conversation are almost never online at the same time.
The constraint that shapes everything
A network spanning continents has no useful notion of "both users present". A producer in Berlin messages a vocalist in Lagos at 11pm their time. The vocalist reads it fourteen hours later on a phone that has changed networks twice since.
Messaging that only works while two sockets are simultaneously open is not messaging. It is a chat demo.
So the design question was never "how do we push messages fast". It was: what is the source of truth, and what is the socket allowed to be?
Persist, then emit
The answer is one ordering rule, and everything else follows from it.
// The message is the database write. The emit is a notification about it.
const message = await messages.insertOne({
conversationId,
senderId,
body,
createdAt: new Date(),
});
io.to(`user:${recipientId}`).emit("message", message);
Reversed - emit first, write second - every failure between the two lines produces clients and server disagreeing about reality. A validation failure means connected users saw a message that will never exist. A process crash means the same, permanently. A user who reconnects and fetches history finds the message missing, which reads as deletion.
Persisting first makes delivery a query against durable state. Whether a recipient was connected at send time stops being a correctness concern. If they were, the socket saved them a round trip. If they were not, they read it on reconnect from the same record the live listener used.
That is the whole trick, and it is worth being explicit that it is not a performance optimisation - it is what makes the offline case not a special case at all.
The client reconciles, it does not resume
Persisting first fixes the server. The client still has a gap: everything emitted while it was disconnected.
Mobile connections drop constantly - tunnels, lifts, carrier handovers, the OS suspending the app to save battery. Socket.IO reconnects transparently, and a reconnected client will happily carry on rendering with a permanent hole in its history.
So reconnection is treated as a sync event, not a transport event:
socket.on("connect", async () => {
const since = lastMessageAt ?? conversationOpenedAt;
const missed = await api.getMessages({ conversationId, since });
applyMessages(missed);
});
The socket is an optimisation over polling. It is never the only path by which data reaches the client. Once that is true, a dropped connection is a latency problem rather than a correctness one - a much better class of problem.
Rooms, not socket IDs
Storing socket.id against a user is the bug that appears the moment someone
opens a second tab, or a phone reconnects and gets a new socket ID while the old
mapping is still in memory.
Joining a room keyed on the stable user ID pushes that bookkeeping into Socket.IO where it belongs:
io.on("connection", (socket) => {
const userId = authenticate(socket); // verify the token - never trust a query param
socket.join(`user:${userId}`);
});
// Reaches every device and tab, with no connection registry of my own.
io.to(`user:${userId}`).emit("message", message);
Artists browse on a laptop and reply on a phone. Room-per-user made both surfaces correct for free.
Modelling artists who are not alike
A vocalist, a 3D artist and a photographer do not share a profile shape. Credits, showreels, discographies, portfolio galleries, technical riders - the fields diverge by discipline and new disciplines arrive without warning.
MongoDB was chosen for exactly this: a document model lets each discipline carry its own fields without a schema migration per category. Flattening them into one relational table would have produced either a wide table of mostly-null columns or an entity-attribute-value structure, and both are worse.
The honest cost is that validation moves into the application layer, where it is easier to get wrong and easier to forget. That is an acceptable trade for profile documents. It would not be for anything with money attached.
Two structural decisions that held up:
- Conversations and messages as separate collections, with messages indexed
on
(conversationId, createdAt). Every read path - open a thread, fetch since a timestamp, paginate backwards - is served by that one compound index. - Denormalising the last message onto the conversation. The inbox is the most requested view in the product; making it a single query rather than a fan-out per thread was the difference between a fast inbox and a slow one.
Email as the offline fallback
If someone messages you and you do not open the app for two days, the platform has to reach you elsewhere. Notification emails go through an Agenda-backed queue, decoupled from the request that triggered them.
The design detail worth keeping: notifications are debounced rather than immediate. Five messages in a conversation over ten minutes should produce one email, not five. A scheduled job collects unread activity per user and sends a single digest, which is both cheaper and considerably less irritating.
Sending inline would have tied message latency to a mail provider and produced exactly the notification spam that makes people mute a product.
Deployment
Express services and the Next.js frontend on DigitalOcean, behind Nginx as reverse proxy handling TLS and static assets. Domain via Namecheap with DNS configured across surfaces.
RTK Query on the client keeps repeated profile and listing views cheap - in a discovery product, users open the same profiles repeatedly, and cache-aware fetching removes most of that load without any server-side work.
What I would do differently
A Redis adapter before scaling horizontally, not during. io.to(room).emit()
only reaches sockets on the current instance. The moment there is more than one,
you need pub/sub fan-out. Worth noting that this fixes delivery across instances
but not delivery guarantees - Redis pub/sub is fire-and-forget, so the
persist-then-emit rule and reconnect reconciliation still do the real work.
Full-text search sooner. Discovery started with filters and regex queries. That is fine at hundreds of profiles and wrong at tens of thousands; regex queries do not use indexes usefully and degrade exactly as the network becomes valuable. A dedicated search index should have been in the first design.
Read receipts modelled from the start. Retrofitting per-recipient read state onto an existing message schema is more invasive than including it initially, even if the first version does not surface it.
The transferable part
Three rules, and together they make connectivity stop being a correctness concern:
- Persist, then emit. The write is the event; the emit is a notification.
- Reconcile on connect. The socket is an optimisation, never the only path.
- Address identity, not connections. Users are stable; sockets are not.
None of this makes real-time harder to build. It makes it less code - no reconnection buffer, no socket registry to clean up, no ordering logic. You have a database, and a way of telling people to look at it sooner.
