An admin panel isn’t a dashboard you stare at for fun. When an order comes in, a complaint gets filed, or a new customer registers, the warehouse needs to see it now — not after someone hits F5. In a shop processing dozens of daily orders, “just refresh it” is a fast track to burnout.
I recently added a live admin view to a white-label auto parts e-shop. The server pushes changes, the client displays them. No WebSocket, no external service, no Redis Pub/Sub. A few hundred lines of plain HTTP.
The problem
Every admin sees counts of orders, invoices, delivery notes, credit notes, and complaints grouped by status — plus CRM task deadlines and stock corrections. When an operator confirms an order, the “pending” badge count needs to drop and “shipped” needs to rise, before their colleague in the other warehouse can say “did you refresh?”
Client-side polling works, but ten admins × one request every two seconds is wasted load on the API server. And polling during idle periods burns data on mobile hotspot tariffs.
Why SSE over WebSocket
Server-Sent Events is an HTTP protocol where the server opens a connection and pushes data until the client leaves. Compared to WebSocket:
- One-way — data flows server → client only. No duplex channel needed when the admin doesn’t send anything back.
- Standard HTTP — no upgrade handshake, no proxy troubles, no client library. Browsers have
EventSourcebuilt in. - Auto-reconnect —
EventSourcereconnects on its own when the connection drops.
WebSocket would be over-engineering. This is a notification channel, not a multiplayer game.
Architecture: one poller for everyone
When ten admins are connected, we don’t want ten database queries. The poller runs once in the Node process and fans the result out to everyone.
┌──────────┐ ┌──────────────┐ ┌──────────────────┐
│ EventSource │────▶│ subscribe() │◀────▶│ Interval (2s) │
│ (admin 1)│ │ EventEmitter │ │ computeRevision()│
├──────────┤ │ │ └────────┬─────────┘
│ EventSource │────▶│ │ │
│ (admin 2)│ │ │ prisma.groupBy
└──────────┘ └──────────────┘ ┌─────────┐
│orders │
│invoices │
│complaints│
│...8 tables│
└─────────┘
subscribeAdminChanges()attaches a listener to an EventEmitter and starts the interval — only if it isn’t already running.- Every ~2 seconds
computeAdminRevision()computes a fingerprint across 8+ tables (groupBy status, count rows). - When the fingerprint changes — an insert, a delete, or a status transition — an event is sent to every connected admin.
- When the last admin closes their browser tab, the interval stops.
This in-process, reference-based fingerprint needs no Redis or Postgres LISTEN/NOTIFY. If the app ever scales to multiple instances, swapping pulse-stream.ts for a shared bus is all it takes — nothing else changes.
Fingerprint: what actually changed
Instead of comparing full objects or timestamps, we compute a text fingerprint:
o[pending:5,shipped:12]|i[issued:3,paid:8]|r[open:2,resolved:1]|...
Each table is grouped by its (indexed) status column. When the count in any bucket changes — insert, delete or status transition — the fingerprint differs. Only then does a new revision get sent.
No JSON diffing, no timestamps. One string, one comparison.
Client side
The AdminLive component lives in the admin layout and renders nothing:
- Opens an
EventSourceto/api/admin/events - The first revision is the baseline — no refresh triggered
- Every subsequent difference calls
router.refresh()after 400ms coalescing - When an admin confirms three orders in quick succession, only one refresh fires
The 400ms coalescing isn’t arbitrary. During batch processing (e.g. confirming multiple invoices), each change fires a separate event. Without coalescing the page would redraw five times a second.
Production details
- Nginx:
X-Accel-Buffering: nodisables buffering, or nginx holds the response until its buffer fills up. - Heartbeat: a comment ping (
: ping\n\n) every 25 seconds keeps the proxy from killing idle connections. - Cleanup: closing the tab or navigating away deregisters the listener and stops the interval. No dangling connections.
Result
An admin panel that lives. An order arrives, the badge count updates, the operator sees it — no F5, no notification popup, no “did you refresh?”
All it took was one module and one EventSource listener. No Redis, no WebSocket server, no new infrastructure. Just HTTP the way it was meant to work.