While testing a reconnect system for a deliverer-facing WebSocket gateway: when someone reconnects after losing their connection, they should get a snapshot of their current job plus any events they missed while offline. Two pieces, both of which I was confident were correct on their own.
Marking presence:
async markOnline(userId: number) {
await this.prisma.user.update({
where: { id: userId },
data: { isOnline: true, lastSeenAt: new Date() },
});
}
Computing what was missed:
async replayDelivererEvents(delivererId: number) {
const deliverer = await this.prisma.delivererProfile.findUnique({
where: { id: delivererId },
select: { user: { select: { lastSeenAt: true } }, activeJobId: true },
});
// ...fetch events since deliverer.user.lastSeenAt
}
And the connection handler that used both:
async handleConnection(client: Socket) {
const deliverer = await this.getDelivererByUserId(payload.sub);
await this.sessionService.markOnline(payload.sub); // step 1
const missedEvents = await this.replayService.replayForDeliverer(deliverer.id); // step 2
client.emit('events.replay', missedEvents);
}
I tested it: disconnect, trigger a job assignment while offline, reconnect. events.replay came back empty, every time. The event genuinely happened. The query genuinely should have found it.
Why
Walk through the actual timeline. A deliverer disconnects — markOffline correctly stamps lastSeenAt with the disconnect time. Later, they reconnect. handleConnection fires, and the first thing it does is call markOnline, which immediately overwrites lastSeenAt to right now. Only after that does it call replayForDeliverer, which reads lastSeenAt to compute "what happened since they were last seen" except by then lastSeenAt no longer holds the disconnect time. It holds the current moment, because the line above just wrote over it.
getEventsSince({ since: lastSeenAt }) effectively became since: right now. The replay feature couldn't fail more quietly — no error, no exception, just a correctly-shaped empty array every single time.
What makes this bug worth writing about isn't the fix — it's that neither piece of code was wrong in isolation. Marking someone online should update lastSeenAt. Replay should read lastSeenAt. The bug only exists in the order they ran in, and that kind of bug doesn't show up by staring at either function — only by tracing the actual sequence of operations across the whole request.
The fix
Read before you overwrite:
async handleConnection(client: Socket) {
const deliverer = await this.getDelivererByUserId(payload.sub);
// Replay FIRST, using the lastSeenAt from before this connection
const missedEvents = await this.replayService.replayForDeliverer(deliverer.id);
// THEN mark online, which overwrites lastSeenAt for next time
await this.sessionService.markOnline(payload.sub);
client.emit('events.replay', missedEvents);
}
No schema change needed. Reordering two already-correct operations was the entire fix.
The part worth remembering
One field, two different jobs — "when did we last hear a heartbeat" and "what's the anchor point for replay" happened to share the same column, and that's exactly what made the collision possible. The one-line reorder fixes today's bug, but it leaves the deeper coupling in place: anything else that later wants to read lastSeenAt for a different purpose can walk into the same trap. The more robust version splits them. a dedicated lastDisconnectedAt, set only on disconnect, read only by replay so two unrelated concerns stop sharing state that one of them mutates as a side effect. I haven't built that yet. It's on the list specifically because I know why it would help, not just that it might.
Top comments (0)