The Reconnect Bug Where Marking a User Online Erased the Data I Needed for Replay

Leader ●1 ●7 ●17
calendar_today • schedule2 min read
— Originally published at dev.to

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.

2 Comments

1 vote
0
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Optimizing the Clinical Interface: Data Management for Efficient Medical Outcomes

Huifer - Jan 26

Cisco's Amy Chang: A Model's "Passport" Doesn't Tell You Where It Actually Came From

Tom Smithverified - Aug 27

Calling a Service Inside prisma.$transaction Doesn't Make It Atomic — Here's the Bug That Taught Me

Mhamd Ghanoum - Oct 12

Breaking the AI Data Bottleneck: How Hammerspace's AI Data Platform Eliminates Migration Nightmares

Tom Smithverified - Mar 16

The Audit Trail of Things: Using Hashgraph as a Digital Caliper for Provenance

Ken W. Algerverified - Apr 28
chevron_left
1.2k Points • 25 Badges
5Posts
10Comments
11Connections
Hello everyone, I'm a Backend Engineer. My main stack is TS, NestJS and PostgreSQL, NoSQL, currently... Show more

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!