Ghost locks: releasing everything when a user disconnects
Edit locks are easy to take and easy to forget. A closed tab, a sleeping laptop or a lost Wi-Fi connection can leave a configurator locked by someone who is gone. Here is how a socket reverse index, disconnect cleanup, TTLs and a separate viewer-presence layer keep locks honest.
The problem
Users who closed a tab or lost their connection left "ghost" locks behind, blocking everyone else from editing.
The result
Every lock is released the moment its socket disconnects, everyone is notified in real time, and viewers can see who is on a page without ever blocking it.
Stack
The problem
After introducing edit locks (see "Two users, one configurator"), a new kind of complaint appeared:
"It says Currently edited by Omar… but Omar went home two hours ago."
A lock system has two halves. Taking a lock is easy: the user opens a page. Releasing a lock is the hard part, because users don't leave politely. They:
- close the tab without clicking "Done",
- close the laptop lid,
- lose Wi-Fi on the train,
- have their browser crash,
- open the configurator, then go to lunch.
In all of those cases, no "unlock" message is ever sent. The lock stays in Redis, and everyone else is blocked by a person who is no longer there. A ghost lock.
The details
Why an "unlock on page leave" is not enough
The obvious idea is to send unlock when the user leaves the page (beforeunload, route change). It helps in the happy path, but it is unreliable by nature:
- the browser may kill the page before the message is sent,
- a crash sends nothing,
- a network drop means the message never arrives,
- a sleeping laptop doesn't "leave" anything, it just stops.
The client can't be trusted to clean up. The server has to know when the user is gone.
WebSockets give us that signal
This is the main reason locks are taken over a WebSocket and not plain HTTP. A Socket.IO connection has a heartbeat: the server and client exchange pings. When the client disappears, whether the tab closed or the network dropped, the server notices within a short timeout and fires a disconnect event.
So the server always gets a reliable signal: "this socket is gone". The remaining problem is: which locks belonged to that socket?
The naive answer is a scan
You could scan every lock key in Redis and delete the ones whose socketId matches. With many configurators and items, that means reading thousands of keys on every disconnect. KEYS * is dangerous in production because it blocks Redis, and even SCAN is wasteful for something that happens all day.
The challenges
- Disconnect cleanup must be fast, because it runs every time anyone closes a tab.
- One lock can mean many keys. A configurator lock also wrote an
item:{id}key for every item inside it, plus an index set. Releasing it must remove all of them, and nothing else. - Different lock types release differently. A configurator lock is a group of keys; an item sub-page lock is a single key.
- Everyone else must find out. Releasing in Redis is not enough: other users waiting on that page should see it unlock immediately.
- Viewing is not editing. People also wanted to see who else is looking at a page, without that ever blocking anyone.
The solution
1. A reverse index per socket
Every time a lock is acquired, its key is also added to a Redis set owned by the socket:
async trackSocketLock(socketId: string, lockKey: string) {
const key = `socket:${socketId}`;
await this.redis.sadd(key, lockKey);
await this.redis.expire(key, LOCK_TTL_SECONDS);
}
So at any moment, socket:Xk3… contains exactly the lock keys that connection owns, for example:
socket:Xk3… → { "configurator:12", "item:88:filters" }
It's a reverse index: instead of "for each lock, who owns it", it answers "for this socket, what does it own". On disconnect, that's one SMEMBERS instead of a scan.
2. Release everything on disconnect
The gateway's disconnect hook calls one method:
async handleDisconnect(client: Socket) {
const { releasedKeys } = await this.locks.releaseBySocket(client.id);
for (const key of releasedKeys) this.broadcastReleased(key);
const leftPages = await this.presence.leaveAllBySocket(client.id);
for (const page of leftPages) this.server.emit('viewer-left', page);
}
And the release logic knows the shape of each lock type:
async releaseBySocket(socketId: string) {
const lockKeys = await this.redis.smembers(`socket:${socketId}`);
for (const key of lockKeys) {
if (isConfiguratorLock(key)) { // e.g. "configurator:12"
const configuratorId = parseId(key);
const itemIds = await this.redis.getConfiguratorItems(configuratorId);
await this.redis.deleteMany([key, ...itemIds.map((id) => `item:${id}`)]);
await this.redis.deleteConfiguratorItems(configuratorId);
} else { // sub-page or variables lock
await this.redis.del(key);
}
}
await this.redis.del(`socket:${socketId}`);
return { releasedKeys: lockKeys };
}
Two details matter:
- The configurator's item list comes from the Redis index, not from PostgreSQL. Cleanup never touches the database, so it stays fast even if the database is busy, and it deletes exactly the keys that were written, even if items were added or removed in the meantime.
- The keys are deleted in one pipeline (
deleteMany), not one round trip per key.
3. Tell everyone, immediately
For every released key, the server broadcasts lock-released with the type and id. Every open client checks whether it is looking at that resource, and if so, switches the page from read-only to editable. Nobody needs to refresh.
4. Explicit unlock still exists
When users do leave politely ("Done editing", navigating away), the client sends unlock-configurator or unlock-item-subresource. The server deletes the keys, removes them from the socket's set, and broadcasts the release. Disconnect cleanup is the safety net, not the main path.
5. TTL as the last line of defense
Every lock and every socket index has a 30-minute TTL. If a server instance crashes before it can run its disconnect handler, Redis itself forgets the locks within 30 minutes. Three layers, from best to worst case:
| Layer | When it releases | Covers |
|---|---|---|
| Explicit unlock | instantly | user clicks Done / leaves the page |
| Disconnect cleanup | a few seconds | tab closed, crash, network loss |
| TTL expiry | ≤ 30 minutes | server crash, missed events, bugs |
6. Presence: seeing who's there, without blocking
Users also asked: "Can I see who else is on this page?" That is presence, and I kept it completely separate from locking:
- Opening a page emits
view-resource. The server adds the user to a viewer list for that page (with its own per-socket reverse index) and answers with a snapshot:
{
"viewers": [
{ "userId": 42, "userName": "Sara", "isEditing": true },
{ "userId": 7, "userName": "Omar", "isEditing": false }
],
"lockedBy": "Sara",
"canEdit": false
}
- Others receive
viewer-joined; leaving or disconnecting sendsviewer-left. - The presence layer only reads lock keys, to compute
isEditingandcanEdit. It never writes a lock and never blocks.
canEdit combines both systems: you can edit only if you're allowed to edit this configurator and nobody else holds the lock.
const canEdit = isAllowed && (!lock || lock.ownerUserId === currentUserId);
Keeping presence separate means a bug in "who's looking" can never freeze editing, and a page with ten viewers costs nothing in lock checks.
Lessons learned
- Never trust the client to clean up. Tabs close, laptops sleep, networks drop. The server must detect departure on its own.
- WebSocket disconnects are your best signal. The heartbeat turns "the user vanished" into an event you can handle.
- Use reverse indexes instead of scans. "What does this socket own?" should be one read, not a search across all keys.
- Release from what you wrote. Store the exact keys at lock time (the item index), and delete exactly those. Don't recompute them from a database that may have changed.
- Layer your safety nets. Explicit release, disconnect release and TTL expiry cover each other's failures.
- Broadcast releases. A lock that is free but still looks locked on screen is almost as bad as a ghost lock.
- Separate "looking" from "editing". Presence that can't block is safe to show everywhere.
FAQ
What is a ghost lock?
A lock that is still held even though the user who took it is gone, usually because they closed the tab, lost their connection or crashed without sending an unlock. It blocks other users for no reason.
How do you detect a user leaving with Socket.IO?
Socket.IO exchanges heartbeats between client and server. When the client stops responding, the server fires a disconnect event for that socket, which you can use to release the user's resources.
Why is KEYS dangerous in Redis?
KEYS scans the entire keyspace in one blocking operation. On a large database it can freeze Redis for every client. Use reverse indexes (sets) to find related keys directly, or SCAN when you truly need to iterate.
What is the difference between presence and locking?
Presence shows who is currently viewing a resource; it never prevents anything. Locking decides who may change a resource and blocks everyone else. Keeping them separate keeps viewing cheap and editing safe.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk