CodX Editor has live collaboration rooms. Somebody starts one, gets a link, sends it to a friend, and they write code together.
The rooms lived in a Map in memory. Which means every
deploy, every crash, and every restart silently destroyed every
room on the server. Somebody's link stopped working and they had no
idea why.
Fixing it turned out to be less about persistence and more about deciding what a session actually is.
Half of a session is worth nothing
Here is roughly what one held:
{
files, activeFileName,
hostName, hostDeviceId, pin, permissions, bans, chat,
participants: [ { socketId, name, role, ... } ],
hostSocketId,
pendingJoins: [ ... ]
}
The first group is durable. The files exist independently of whether anyone is connected. So do the permissions, the ban list and the chat history.
The second group is not. A socket id names a TCP connection. When the process dies, every connection dies with it, and those ids refer to nothing at all. Writing them to a database and reading them back gives you a room full of ghosts: names in the participant list that no longer belong to anybody, and a host seat held by a socket that cannot be messaged.
So the restored session deliberately comes back empty:
{
...durable fields,
participants: [],
hostSocketId: null,
pendingJoins: [],
}
The state already existed
An empty room with nobody in the host seat sounds like a strange
thing to construct. It is not. It is exactly what our disconnect
handler already produces when a host closes their tab: the seat is
vacated, and hostName and hostDeviceId are
left pointing at whoever owns the room so they can reclaim it.
That mattered more than anything else in the change. Restoring into a state the system already understood meant no new branches, no "was this restored?" checks scattered around, and the reclaim logic worked on day one because it was not new.
If restoring had produced a shape nothing else knew how to handle, that would have been a sign the persistence was wrong rather than the system.
Writing without hammering the database
The tempting design is to save on every change. In a collaborative editor a change is a keystroke, so that is a write per character per person.
Instead a timer runs every ten seconds and writes only what has actually changed, compared against the last payload written:
const payload = JSON.stringify(serialize(session));
if (fingerprints.get(id) === payload) continue;
await pool.query(upsert, [id, payload, Date.now()]);
fingerprints.set(id, payload);
An idle room costs one string comparison every ten seconds. A busy one costs a single upsert in the same window.
The cost is honest and worth stating: up to ten seconds of the most recent work can be lost in a crash. For a room whose whole point is that everyone is connected and watching, that is acceptable.
Two things that bit
Do not delete rows you do not recognise. The first version cleared out any stored room not present in memory. On a host that runs several Node processes — and shared hosting happily does — each has its own memory, so one process would cheerfully delete rooms that were alive in another. Rows are now only removed when a room is deliberately ended.
A failed restore must not stop the boot. The load is wrapped so a database problem logs and moves on. Losing saved rooms is a small problem. A site that will not start is a large one.
What people actually notice
Nothing, which is the point. Deploy mid-session and everyone is disconnected — that part is unavoidable, a restart drops every socket. But the link still works. They rejoin, the files are there, the chat is there, and the host gets their controls back.
The session survives. The connections do not. Being clear about which is which was the entire job.