---
title: "Sechs Socket-Events, und kein einziges Refetch"
description: "Realtime kommt meist als zweite Kopie Ihres Zustands. Jedes Socket-Event als Schreibvorgang in den Query-Cache zu behandeln statt als Signal zum Nachladen hält es bei einer."
url: https://riteshkc.com.np/de/blog/six-socket-events-and-not-one-refetch
source: https://riteshkc.com.np/de/blog/six-socket-events-and-not-one-refetch.md
updated: 2026-08-05
site: "Ritesh KC"
---
# Sechs Socket-Events, und kein einziges Refetch

Realtime kommt meist als zweite Kopie Ihres Zustands. Jedes Socket-Event als Schreibvorgang in den Query-Cache zu behandeln statt als Signal zum Nachladen hält es bei einer.

- Published: 2026-08-05
- Tags: Next.js, Architecture, Realtime
- Author: Ritesh KC (https://riteshkc.com.np)

The default way to add realtime to an app that already fetches data is to bolt a socket onto the side, listen for events, and refetch whatever changed.

It works. It also means every piece of state now has two sources: the fetch that owns it and the socket that pokes it, and the two disagree for as long as the refetch takes. Add a few features and you have a second state tree, unversioned, with no cache policy, racing the first one.

The version I ended up with has six event handlers and almost none of them fetch anything. The socket writes into the same cache the rest of the app reads from. Realtime stops being a parallel system and becomes a second writer to the one you already have.

## The unread badge

Start with the smallest case, because it makes the principle obvious.

A notification bell shows an unread count. The count changes when a notification arrives, when you read one, and when you read one *in another tab*.

The refetch version listens for an event and invalidates the count query. That's a round trip to learn a number the server just sent you.

```ts
// Authoritative unread count: covers multi-tab and post-mutation sync.
socket.on(UNREAD_COUNT_EVENT, (count: number) => {
  queryClient.setQueryData(notificationKeys.unreadCount(), count);
});
```

The server emits the count because the server knows the count. Writing it straight into the cache is one line, zero requests, and it fixes multi-tab for free: every tab holding a socket gets the same authoritative number at the same time, without any of them asking.

The word doing the work is *authoritative*. This only holds if the server sends the real value rather than a nudge. An event that says "something changed" forces a refetch by construction. An event that says "it's four" doesn't. That's a payload design decision, and it's the one that determines whether the rest of this is possible.

## The row that only updates if you're looking at it

An admin sends a broadcast to a few thousand people. There's a progress card, and a table you can expand to see per-recipient delivery status.

The backend emits one event per recipient as it works through the queue. If each of those invalidated the campaign detail query, an admin who happened to have the table open would fire thousands of requests at their own API while it was already busy sending email.

So the handler patches, and only if there's something to patch:

```ts
// Patch one recipient's row in an open drill-down. Only touches the detail
// cache if it's already loaded (table expanded): no fetch on its own.
socket.on(CAMPAIGN_RECIPIENT_EVENT, (event: CampaignRecipientEvent) => {
  queryClient.setQueryData<CampaignDetail>(
    campaignKeys.detail(event.campaignId),
    (prev) =>
      prev
        ? {
            ...prev,
            recipients: prev.recipients.map((r) =>
              r.userId === event.userId
                ? { ...r, inApp: event.inApp, emailStatus: event.emailStatus }
                : r,
            ),
          }
        : prev,
  );
});
```

The `prev ? ... : prev` is the whole trick. `setQueryData` with an updater that returns `undefined` would seed an entry; returning `prev` unchanged when there's nothing cached means the handler is a no-op for every admin who *isn't* staring at that table. No fetch, no cache entry, no work.

An event whose only effect is on data nobody has loaded should cost nothing. That's hard to arrange with invalidation, which is why invalidation is the wrong default here.

## Invalidating something the event didn't mention

One handler does invalidate, and it invalidates more than you'd expect.

When a counselor approves or rejects a student's document, the obvious move is to refresh the document library. But documents also gate application submission: an application sitting in draft is blocked until its attached documents are verified. A student watching that draft while their document gets approved should see the submit button unlock, without reloading.

So `document:changed` busts the applications cache too. The reason is in the code, because six months later it looks like a copy-paste mistake:

> application documents mirror the library's review outcome and gate submission: refresh them too so an open draft unlocks live

This is the case where invalidation is right. The event carries a document id; the consequence is spread across an unknown number of application records the client may or may not hold. There's no value to write. So you refetch, and you write down why you're refetching something the event didn't name.

The handler is also role-scoped: an admin receives these events for every student, so the key it invalidates includes the `studentId` from the payload rather than the current user's. One handler, two audiences, different cache keys.

## The one that isn't cache at all

The last piece isn't a cache write, and it's the one users actually notice.

A notification arrives while the tab is in the background. The app fires a toast into a document nobody is looking at, the toast times out, and the notification is gone. The user finds out later, or doesn't.

```ts
// Tab in the background → fire a native OS notification instead of an
// in-app toast the user can't see. Foreground keeps the sonner toast so
// the two never fire at once.
if (
  typeof window !== "undefined" &&
  "Notification" in window &&
  Notification.permission === "granted" &&
  document.hidden
) {
  const native = new Notification(ui.title, {
    body: ui.message,
    tag: ui.id,
    icon: "/logo/gradsy-mascot.png",
  });
  native.onclick = () => {
    window.focus();
    if (ui.actionUrl) router.push(ui.actionUrl);
    native.close();
  };
  return;
}
```

`document.hidden` picks the channel. The early `return` is what stops both from firing. `tag: ui.id` dedupes, so the same notification arriving twice replaces rather than stacks. And the click handler focuses the window before routing, because a deep link into a background tab is a page change nobody sees.

The honest limit: this only works while the tab exists. Close it and there's no page to run the handler. Real push needs a service worker, which is written up in that repo and not built.

## What the rule turned out to be

I didn't set out with a principle. It emerged from getting the campaign handler wrong first.

**If the event carries the new value, write it. If it carries a fact whose consequences you can't enumerate, invalidate. If it changes something nobody has loaded, do nothing.**

Most events are the first kind, and most codebases treat all of them as the second. That's the difference between a socket layer that stays small and one that turns into a second state tree.

The prerequisite is the payload. All of this rests on the server sending values instead of nudges, which is a backend decision made before any of this frontend code exists. If your events say `{ type: "changed" }`, you don't have this option, and getting it back means changing both sides.

## Related work

- [Gradsy](https://riteshkc.com.np/de/work/gradsy): Der gesamte Betrieb einer Auslandsstudien-Beratung über zwei Repos. Studierende bewerben sich, Berater bewegen Bewerbungen durch Zustände, Admins machen den Rest. Viel davon war Nebenläufigkeit.
