[ BLOG // 2026-07-16 // 3 MIN ]

Živý admin panel bez F5: Server-Sent Events v B2B e-shopu

Proč nestačí 'refreshni stránku', když admin sleduje objednávky v reálném čase — a jak jedna SSE roura s fingerprintem nahradila deset CRON dotazů.

PROJEKTY nextjseshopautodilyssebackendcase-study

Admin panel B2B e-shopu není dashboard, na který se kouká pro radost. Když přijde objednávka, reklamace nebo nový zákazník, admin to potřebuje vidět hned — ne až po zmáčknutí F5. Věta „refreshni si to” je v provozu s desítkami denních objednávek cesta k vyhořelému skladovému oknu.

Nedávno jsem do jednoho white-label autodílárny přidal živý náhled bez jediného refresh tlačítka. Server posílá změny, klient je zobrazí. Žádný WebSocket, žádná externí služba, žádná závislost na Redis Pub/Sub. Stačilo pár set řádků.

Jak vypadá problém

Každý admin vidí v panelu:

  • počty objednávek, faktur, dodacích listů, dobropisů a reklamací podle stavu
  • CRM úkoly s termíny
  • skladové pohyby a opravy

Když operátor potvrdí objednávku, číslo v badge „čeká na odeslání” musí klesnout a v badge „odesláno” stoupnout — a to dřív, než kolega v druhém skladu stihne říct „refreshni to”.

Řešení typu CRON dotaz každých pár sekund z prohlížeče (polling) funguje, ale deset adminů = deset HTTP requestů každé dvě sekundy = zbytečná zátěž na API server. A polling v cyklu, kde se nic neděje, topí daty zákazníkům na mobilním tarifu.

Proč SSE a ne WebSocket

Server-Sent Events je HTTP protokol, kde server otevře spojení a posílá data, dokud klient neodejde. Proti WebSocketu:

  • Jedna cesta — data jdou jen server → klient. Když admin neposílá nic zpátky, nepotřebujeme plně duplexní kanál.
  • Standardní HTTP — žádné upgrade handshake, žádné problémy s proxy, žádná knihovna na klientovi. Prohlížeč má EventSource vestavěný.
  • Automatické znovupřipojení — při pádu spojení se EventSource sám pokusí reconnectovat.

WebSocket by byl overengineering. Tohle je notifikační kanál, ne multiplayerová hra.

Architektura: jeden poller pro všechny

Klíčové rozhodnutí: když je připojených deset adminů, nechceme deset databázových dotazů. Poller běží jednou v procesu a výsledek posílá všem najednou.

┌──────────┐     ┌──────────────┐     ┌──────────────────┐
│ EventSource │────▶│ subscribe()  │◀────▶│ Interval (2s)    │
│ (admin 1)│     │ EventEmitter │     │ computeRevision()│
├──────────┤     │              │     └────────┬─────────┘
│ EventSource │────▶│              │          │
│ (admin 2)│     │              │          prisma.groupBy
└──────────┘     └──────────────┘          ┌─────────┐
                                            │orders   │
                                            │invoices │
                                            │complaints│
                                            │...8 tables│
                                            └─────────┘
  • subscribeAdminChanges() přidá listener do EventEmitteru a spustí interval (pouze pokud ještě neběží).
  • Každé ~2 vteřiny běží computeAdminRevision() — spočítá fingerprint napříč 8+ tabulkami (groupBy podle statusu, počet řádků).
  • Když se fingerprint změní (přibyla/zmizela objednávka, změnil se status), rozešle se událost všem připojeným adminům.
  • Když poslední admin zavře prohlížeč, interval se zastaví.

Tenhle pattern (in-process, reference-based fingerprint) nepotřebuje Redis ani Postgres LISTEN/NOTIFY. Pokud by aplikace někdy škálovala na víc instancí, stačí vyměnit pulse-stream.ts za sdílenou frontu — zbytek se nemění.

Fingerprint: co se vlastně změnilo

Místo porovnávání celých objektů nebo timestampů počítáme textový fingerprint:

o[pending:5,shipped:12]|i[issued:3,paid:8]|r[open:2,resolved:1]|...

Každá tabulka je seskupená podle (indexovaného) statusu. Když se změní počet v nějaké skupině — ať už insertem, deletem nebo přechodem mezi stavy — fingerprint je jiný. A jen tehdy se adminům pošle nová revize.

Žádné porovnávání JSONů, žádné sledování timestampů. Jeden string, jedna podmínka.

Co se děje na klientovi

Komponenta AdminLive je nasazená jednou v layoutu a nerenderuje žádné HTML:

  1. Otevře EventSource na /api/admin/events
  2. První přijatá revize je baseline — nezpůsobí refresh
  3. Každá další revize, která se liší, spustí router.refresh() po 400ms koalescenci
  4. Když admin rychle potvrdí tři objednávky za sebou, refresh proběhne jen jednou

400ms koalescence není náhoda. Při dávkovém zpracování (např. hromadné potvrzení faktur) by každá změna poslala samostatnou událost a bez koalescence by se stránka zbytečně překreslovala pětkrát za vteřinu.

Specifika provozu

  • Nginx: hlavička X-Accel-Buffering: no zakáže buffering, jinak by nginx držel odpověď, dokud se nenaplní buffer.
  • Heartbeat: každých 25 vteřin pošleme comment ping (: ping\n\n), aby proxy server nepovažoval spojení za mrtvé.
  • Cleanup: při zavření karty nebo odchodu z adminu se listener odregistruje a interval se zastaví. Žádné plovoucí spojení.

Výsledek

Admin panel, který žije. Objednávka přijde, badge změní číslo, operátor ji vidí — bez F5, bez notifikace, bez otázky „refreshnul jsi?”.

A celé to stojí na jednom modulu a jednom EventSource listeneru. Žádný Redis, žádný WebSocket server, žádná nová infrastruktura. Jen HTTP, jak ho známe.

// ALL_POSTS
ZPĚT NA BLOG