typescript and javascript logo
author avatar

Grzegorz Dubiel

15-04-2026

Za ekranami: Projektowanie i budowa systemu kampanii reklamowych w Node.js

Jestem typem osoby, która trzyma się z dala od ekranów, bo mnie rozpraszają. Wiem, że brzmi to dziwnie, biorąc pod uwagę mój zawód, ale nie będę tu opowiadał o moim podejściu do disital well-being'u (to temat na inny artykuł). Ostatnio zauważyłem kilka cyfrowych ekranów rozmieszczonych w centrum miasta, w którym mieszkam. Ogólnie nie lubię reklam, zbyt wielu ekranów ani zbyt wielu migających kolorów, ale przyszła mi do głowy jedna rzecz: jak, do licha, tworzy się taki system rozproszonych ekranów wyświetlających to, co zostanie zaplanowane? Co jeśli stracą połączenie? Jak wtedy kampanie mają dalej działać na tych ekranach? Dodatkowo zdałem sobie sprawę, że technologia, którą lubię i w której się specjalizuję, mogłaby dobrze pasować do takiego systemu. Postanowiłem podjąć wyzwanie i zaprojektować taki system oraz zbudować jego POC. Co więcej, od niedawna zacząłem bardziej skupiać się na rozwiązywaniu „problemów z zakresu system design”, więc zanurzmy się w temat.

Zbieranie wymagań funkcjonalnych i niefunkcjonalnych

Zanim zaczniemy rysować jakikolwiek diagram, musimy zebrać wymagania i jasno je określić. Najlepiej zacząć od podzielenia ich na wymagania funkcjonalne i niefunkcjonalne.

Wymagania funkcjonalne

  • Użytkownik powinien mieć możliwość utworzenia kampanii poprzez zdefiniowanie metadanych, harmonogramu i zasobów
  • Użytkownik powinien mieć możliwość zobaczenia wszystkich swoich kampanii
  • System powinien utworzyć reklamę na podstawie metadanych i zasobów przesłanych przez użytkownika
  • Reklamy powinny być wyświetlane zgodnie z harmonogramem na wybranych urządzeniach (ekranach)
  • System powinien mieć możliwość anulowania wybranej kampanii

Wymagania niefunkcjonalne

  • System powinien obsługiwać do 500 współbieżnych żądań utworzenia kampanii
  • System powinien równocześnie wysyłać kampanie do maksymalnie 20000 ekranów
  • Tworzenie kampanii powinno być idempotentne
  • Kampania powinna rozpoczynać się w ciągu 1 sekundy od zaplanowanego czasu
  • Ekrany powinny wysyłać potwierdzenia do backendu natychmiast po zainstalowaniu reklamy na urządzeniu
  • Ekrany powinny działać offline, a jednocześnie nadal uruchamiać kampanie zgodnie z harmonogramem

Określenie kluczowych komponentów systemu

Jak widać, to zadanie nie jest trywialne. Aby spełnić wszystkie te wymagania, będziemy musieli zdefiniować trzy główne warstwy aplikacji:

  1. Dashboard Admin UI - obsługuje tworzenie kampanii poprzez dodawanie metadanych i zasobów oraz wyświetla utworzone kampanie i ich statusy.
  2. Backend - będzie zawierał komponenty takie jak dashboard API, worker szablonów do tworzenia i przechowywania szablonów, bazę danych do przechowywania metadanych, publisher do publikowania kampanii na urządzenia oraz consumer ACK-ów, który będzie odbierał wiadomości z urządzeń, aby śledzić stan publikacji kampanii. Workery będą połączone przez trwałą kolejkę.
  3. Frontend ekranów - będzie prostą aplikacją w vanilla JS, która będzie odbierać manifest kampanii, instalować kampanię w pamięci podręcznej, uruchamiać kampanie i powiadamiać backend o statusie publikacji.

UWAGA: W tym projekcie nie będziemy omawiać uwierzytelniania ani autoryzacji. Przykładowe repozytorium również ich nie implementuje. W tym artykule chcę skupić się wyłącznie na głównej idei systemu interaktywnych kampanii relkamowych na ekranach cyfrowych.

Przpływ danych oraz diagram design system'u

Najlepszym sposobem, aby zrozumieć, jak działają komponenty systemu, jest narysowanie diagramu. Po jego przeanalizowaniu będziemy mieli jasny, wysokopoziomowy obraz całego systemu.

Diagram systemu kampanii reklamowych

Przepływ danych działa w pewnego rodzaju pętli sprzężenia zwrotnego uruchamianej z poziomu Admin UI, gdzie mamy: request -> API -> compute template, create manifest -> publish to the screens -> send ack to the backend, co domyka tę pętlę. Serwisy backendowe komunikują się przez BullMQ. Backend ma też storage plików (na potrzeby tego POC jest to po prostu system plików, ale równie dobrze może to być object storage albo cokolwiek innego, co pasuje do potrzeb). Spójność i atomowość są zapewnione dzięki użyciu wzorca outbox podczas rejestrowania zdarzeń tworzenia i anulowania kampanii oraz tworzenia template. Request POST służacy do utworzenia nowej kampanii wymaga klucza idempotencyjnego, który zapobiega tworzeniu wielu jobów dla tej samej kampanii. Bez idempotencji mogłoby to być trudne do udźwignięcia, szczególnie przy większej skali, ponieważ generowanie szablonu i przechowywanie zasobów kampanii nie są trywialnymi zadaniami, a w systemie oczywiście nie może być duplikatów. Od samego początku będziemy używać repliki do operacji odczytu jako świadomego wyboru na potrzeby skalowalności, ponieważ nawet przy umiarkowanym ruchu procesy działające w tle generują dużo zapisów i mogą szybko wpłynąć na wydajność odczytów, co przełoży się na słabe UX.

Model danych

Model danych w tym przypadku jest prosty. W ramach tego POC stworzymy cztery główne encje domenowe: device, campaign, campaign asset, and delivery event. Dodatkowo będziemy używać tabeli outbox, aby wspierać niezawodne przetwarzanie zdarzeń wewnętrznych. W przyszłości model danych można rozszerzyć o users, teams itd.

Oto minimalny model danych aplikacji, wraz z tabelą outbox:

Tabela: campaigns

SQL

Tabela: devices

SQL

Tabela: campaign_assets

SQL

Tabela: delivery_events

SQL

Tabela: outbox

SQL

Jak widać, cały model danych na tym etapie jest prosty i obejmuje przechowywanie zasobów oraz powiązanie kampanii z urządzeniami za pomocą tabeli delivery_events, a także wsparcie dla wzorca outbox. W Admin UI nie ma jeszcze żadnych filtrów, więc indeksy w przedstawionym projekcie nie są rozbudowane.

Struktura projektu

Struktura projektu jest zorganizowana jako monorepo, które składa się z Admin UI, backendu ze wszystkimi istotnymi usługami backendowymi w formie modułów, takimi jak API, publisher, outbox service i template worker, a także applikacji do ekranów. Na tym etapie wszystkie usługi backendowe będą miały jedno źródło prawdy, którym jest baza danych SQL.

Aby omówić wybory, kompromisy i wzorce, musimy przyjrzeć się nieco bliżej serwisom.

Admin UI

Admin UI odpowiada za tworzenie, odczytywanie i anulowanie kampanii. Do zbudowania tego serwisu użyłem React + Vite + TanStack Query.

Oto integracja API po stronie klienta dla wspomnianych przypadków:

typescript

Te fetchery API są opakowane hookami TanStack Query i użyte poniżej w formularzu oraz w liście:

Tabela z paginacją

JSX

Formularz

JSX

Jedną z najważniejszych rzeczy z perspektywy niezawodności i spójności całego systemu jest to, że Admin UI generuje klucz idempotencyjny, dzięki czemu to samo żądanie utworzenia kampanii zostanie zapisane tylko raz. Dzięki temu żądanie jest bezpieczne w sytuacjach, gdy klient musi je ponowić, dochodzi do podwójnego kliknięcia albo z innych powodów klient wysyła zduplikowane żądanie. Usługa po stronie backendowego API wykorzysta później ten klucz podczas wstawiania kampanii do bazy danych.

Dlaczego React, a nie React z metaframeworkiem?

Od początku wybrałem React + Vite, ponieważ to ugruntowany i dobrze utrzymywany stack, co zawsze jest dobre, gdy zespół musi się skalować. W tym przypadku nie jestem skłonny wybierać żadnego metaframeworka. Moim zdaniem ten projekt nie będzie wymagał przypadków użycia z dużą przewagą odczytów, które często optymalizuje się za pomocą SSR, ani nie ma potrzeby optymalizowania SEO w przypadku dashboardu Admin UI.

Campaign API

Dla usług backendowych wybrałem NestJS i PostgreSQL, a TypeORM pełni tutaj rolę warstwy ORM.

Campaign API odpowiada za serwowania endpointów dla Admin UI i odzwierciedla te same zadania: tworzenie kampanii, anulowanie kampanii oraz wyświetlanie listy kampanii.

Przyjrzyjmy się od razu jego serwisowi:

typescript

W metodzie create widać, jak wykorzystywany jest wspomniany wcześniej klucz idempotencyjny. Służy on do sprawdzenia, czy kampania już istnieje. Jeśli tak, nowa kampania nie zostanie wstawiona do bazy danych.

Dlaczego wybrałem NestJS do backend'u?

To opinionated framework, ma szeroki zestaw paczek, jest dobrze utrzymywany, wspiera modularną architekturę, ma ugruntowane wzorce i świetnie integruje się z kolejkami oraz brokerami, co pomaga utrzymać wszystko uporządkowane. Dobrze sprawdza się też w przypadkach, w których trzeba zaktualizować wiele tabel w ramach jednej transakcji.

Dlaczego wybrałem relacyjną bazę danych z TypeORM

TypeORM ma wbudowane wsparcie dla replikacji w modelu primary-replica i świetnie integruje się z NestJS. PostgreSQL wybrałem ze względu na wygodne wsparcie dla jsonb, które przydaje się do przechowywania metadanych w ważnych encjach, takich jak campaigns i devices. Całość dobrze integruje się także z resztą systemu. Korzystanie z SQL i silnych gwarancji ACID jest ważne dla spójności i dobrze współgra ze wzorcami takimi jak outbox przy rozwiązywaniu problemu dual write w workerach.

Outbox Poller

Aby zapewnić atomowość i spójność oraz poradzić sobie z problemem dual write, zdecydowałem się wdrożyć wzorzec outbox dla tworzenia kampanii, przetwarzania template i anulowania kampanii. Wewnętrzna dystrybucja zdarzeń jest obsługiwana przez BullMQ. Widoczne jest to w przykładzie kiedy, Outbox Poller odpowiada za publikowanie do kolejki, dzięki czemu nie trafiają do niej niespójne joby:

typescript

Jak widać, próbowałem osiągnąć tutaj wysoką niezawodność poprzez batchowanie jobów i użycie locków. Cały przepływ jest zorganizowany w sposób przypominający claim/lock pattern: najpierw pobieramy zdarzenia z outboxa, potem przypisujemy do nich workera, aby mieć pewność, że tylko jeden konkretny worker obsługuje dany job, następnie publikujemy go do kolejki i aktualizujemy zdarzenie w outboxie. Gdy lock staje się nieaktualny, cleanupStaleClaims zwolni zdarzenie. Redukuje to ryzyko, że przypadku błędu zdarzenie zostanie utracone.

Dlaczego BullMQ do komunikacji między usługami?

W tym przypadku wybrałem BullMQ, ponieważ nie potrzebuję złożonego routingu ani wielu różnych typów wiadomości. Wymagania funkcjonalne sugerują też relatywnie umiarkowany ruch przy masowym tworzeniu kampanii (500 współbieżnych żądań), więc to rozwiązanie powinno dać radę.

Dlaczego polling zamiast CDC?

Wybrałem polling zamiast CDC, ponieważ szybkość reakcji nie jest w tym przypadku aż tak istotna. Wiele kampanii będzie zaplanowanych z dużym wyprzedzeniem względem czasu publikacji, więc jeśli polling co 5 sekund okaże się zbyt częsty ze względu na obciążenie bazy danych albo koszty, będziemy mieć przestrzeń, by zmniejszyć jego częstotliwość. Dla wymaganej przepustowości podejście oparte na pollingu jest lepszym wyborem, ponieważ powinno działać dobrze bez niepotrzebnego narzutu infrastruktury i dodatkowej złożoności związanej z CDC.

Template Worker

Template worker ma jedno zadanie. Zbiera zasoby i metadane kampanii oraz składa z nich szablon, który później zostanie pobrany przez urządzenie ekranu w celu wyświetlenia:

typescript

Ta klasa jest po prostu workerem BullMQ. Zdecydowałem się użyć systemu plików jako storage, ponieważ to tylko POC, ale w środowisku produkcyjnym lepiej byłoby użyć object storage, np. S3.

Obszar do optymalizacji

Funkcja generateTemplate to dobre miejsce, które warto tutaj obserwować. W testach obciążeniowych nie zauważyłem problemów z wydajnością, ale jeśli kod w środku zostanie rozbudowany o większą liczbę obliczeń, może zacząć blokować event loop. Jeśli tak się stanie, tę funkcję należałoby przenieść do worker_threads.

Publisher Worker

Kolejny worker odpowiada za wypychanie manifestu kampanii do klientów ekranów albo publikowanie wiadomości odwołujących kampanię. Wysokopoziomowo jest to po prostu model pub-sub. Publisher używa protokołu MQTT do komunikacji z urządzeniami. MQTT (Message Queuing Telemetry Transport) implementuje wzorzec pub-sub z założenia. Jego głównym komponentem jest broker, który działa jak centralny serwer odbierający wiadomości i rozprowadzający je do klientów poprzez organizowanie danych w topics.

Ok, zobaczmy, co mamy w kodzie procesora:

typescript

Metody do sprawdzania i ustawiania danych w trackerze są wywoływane wewnątrz metod handlePublish i handleCancel w serwisie publisher processora.

Serwis obsługuje dwie ścieżki publikacji: publikowanie manifestu instalacyjnego (handlePublish) oraz anulowanie kampanii (handleCancel). Kontrole startResult i finalResult pomagają zapobiegać race conditions.

Aby pomóc zagwarantować idempotentność publikacji, mamy mały serwis wykorzystujący Redis'a:

typescript

Wymagania mówią nam, że będziemy obsługiwać całkiem dużą liczbę urządzeń, więc potrzebujemy małego serwisu do strumieniowania urządzeń z bazy danych i publikowania do każdej takiej partii:

typescript

Ta klasa udostępnia metodę będącą generatorem. Generator zwraca urządzenia w konfigurowalnych batchach opartych na paginacji kursorem, co daje dużą kontrolę nad przetwarzaniem danych i pozwala dostosować je w razie potrzeby.

Musiałem też uwzględnić przypadek, w którym urządzenia nie otrzymują manifestu albo wiadomości anulującej z powodu bycia offline. Miałem co najmniej dwie opcje do wyboru:

  1. Zmusić broker MQTT do obsługi retry poprzez skonfigurowanie kolejkowania po stronie brokera.
  2. Utworzyć osobny worker service odpowiedzialny za ponawianie dostarczania manifestu do urządzeń offline.

Wybrałem drugą opcję jako bezpieczniejszą. Dzięki temu nie trzeba używać kolejek wewnątrz brokera MQTT, a jednocześnie mamy pełną kontrolę nad redelivery, w tym nad tym, jak paginowane są niedostarczone joby. Zmniejsza to też ryzyko wyczerpania zasobów po stronie brokera, bo przechowywanie niedostarczonych wiadomości mogłoby stać się wąskim gardłem, zwłaszcza przy skali rzędu 20k urządzeń.

Oto kod:

typescript

Jeszcze jedna rzecz to konfiguracja brokera MQTT, gdzie wywołanie mqtt.connect() używa dostrojonych opcji:

  • keepalive: 30 — wysyła PINGREQ co 30 s, aby wykrywać martwe połączenia
  • reconnectPeriod: 1000 — ponawia połączenie po 1 s od rozłączenia
  • connectTimeout: 10_000 — 10 s timeout dla początkowego połączenia
  • clean: true — startuje z czystą sesją (bez starego stanu)
  • reschedulePings: true — resetuje timer keepalive przy aktywności (unika zbędnych pingów przy wysokiej przepustowości)

W scenariuszu z 20k urządzeń wystarczyło jedynie lekko dostroić konfigurację, aby obsłużyć wyższą przepustowość. Jeśli ten limit wzrośnie, możemy wprowadzić małą pulę połączeń i użyć algorytmu round-robin do rozdzielania ruchu wiadomości, ale warto pamiętać, że wiąże się to z większym zużyciem zasobów, ponieważ do osiągnięcia tego celu potrzeba więcej połączeń WebSocket'ów.

Dlaczego MQTT zamiast samych WebSocketów

Dla tej części kodu zdecydowałem się polegać na MQTT, ponieważ rozwiązuje wiele problemów out out of the box, takich jak dystrybucja wiadomości oparta na topicach. Zachowuje się już jak prawdziwy system pub-sub. Gdybym zdecydował się użyć wyłącznie WebSocketów, skończyłbym na implementowaniu dodatkowej abstrakcji. Koszt infrastruktury jest też niższy w porównaniu z budowaniem i utrzymywaniem własnego serwisu pub-sub od zera.

Screen Device

Aplikacja działająca na ekranie to mała aplikacja frontendowa napisana bez użycia żadnego frameworka frontendowego. Zdecydowałem się na takie podejście, ponieważ template jest składany po stronie serwera, zapisywany, a następnie pobierany przez ekran, więc nie ma potrzeby budowania go za każdym razem w runtime na urządzeniu. Dzięki temu frontend pozostaje bardzo lekki, co ma znaczenie w przypadku takich urządzeń.

Główny moduł aplikacji ekranu wygląda tak:

typescript

Ten kod orkiestruje kilka rzeczy, które są kluczowe, aby ekran mógł zainstalować, zaplanować i anulować kampanię:

  • storage inicjalizuje, zapisuje, pobiera i usuwa manifest. W tym przypadku wykorzystuje IndexedDB, które jest wystarczające jako storage.
  • połączenie klienta MQTT pozwala urządzeniu subskrybować pub-sub po stronie serwera i komunikować się z serwerem poprzez wysyłanie ACK-ów dla operacji publikacji i anulowania.
  • scheduler inicjalizuje scheduler, który obsługuje czas wyświetlania na urządzeniu i spełnia jedno z wymagań funkcjonalnych.
  • fetch template pobiera szablon zbudowany na serwerze i zapisany w storage zasobów. Warto zauważyć, że szablon jest prefetchowany wcześniej (5 min obsługiwane przez scheduler), aby mieć pewność, że będzie dostępny, gdy nadejdzie zaplanowany czas. Zgodnie z wymaganiami funkcjonalnymi dopuszczalne opóźnienie wynosi 1 sekundę.

Dlaczego cache’ować manifest i planować kampanie lokalnie zamiast wypychać je zgodnie z harmonogramem przez MQTT

Gdy mamy ~20k urządzeń subskrybujących pub-sub, poleganie na serwerze przy live publishing'u staje się ryzykowne. W trakcie publikacji wiele rzeczy może pójść nie tak, na przykład opóźnienia sieciowe, przeciążony broker albo urządzenia będące offline w zaplanowanym czasie. Przechowywanie manifestu po stronie klienta na urządzeniu ma praktycznie zerowy koszt. Jest też przestrzeń na dalsze zwiększanie niezawodności, na przykład przez zdefiniowanie maksymalnego czasu wyprzedzenia przed startem harmonogramu, w którym kampania musi zostać utworzona.

ACK Consumer

Teraz czas na ostatni serwis w naszym projekcie, który domyka pętlę publish -> ACK. Ten consumer odpowiada za odbieranie i przetwarzanie ACK-ów. Urządzenia ekranów publikują do niego wiadomości w zależności od tego, którą operację trzeba potwierdzić.

typescript

Ten serwis musi być przygotowany na obsługę znaczącego burstu ACK-ów, ponieważ wymagania mówią o ~20k urządzeń. Wybranie naiwnego podejścia polegającego na wstawianiu każdego zdarzenia ACK bezpośrednio do bazy danych nie skalowałoby się dobrze. W tym przypadku zdecydowałem się połączyć buffering z batchingiem w kolejce. Jako kolejki użyłem istniejącej infrastruktury (BullMQ). Rozważałem Kafkę, ale w tym przypadku BullMQ w zupełności wystarcza i daje nam wszystko, czego potrzebujemy, w tym buforowanie danych, obsługę błędów i konfigurowalną współbieżność.

Logika batch processora wygląda tak:

typescript

Zdarzenia są najpierw mapowane, a następnie maksymalny poziom równoległości jest obliczany dynamicznie na podstawie maksymalnej liczby dostępnych połączeń w puli, tak aby zachować równowagę między szybkością a niezawodnością. Następnie wykonywane są inserty, a na końcu dotknięte urządzenia są oznaczane jako widziane.

Podsumowanie

Projektowanie systemów, rozbijanie ich na komponenty, rozważanie kompromisów i podejmowanie decyzji daje mi ogromną satysfakcję. Wiem, że ten kod nie jest jeszcze gotowy na produkcję (i tak naprawdę wciąż bardzo daleko mu do tego stanu), ale mimo to mam z jego budowania mnóstwo frajdy i nadal czuję wyrzut dopaminy, gdy widzę przechodzące load testy. Ten POC jest dobrym punktem wyjścia do stworzenia prawdziwego systemu. Odkąd AI stało się "mądrzejsze", słyszałem wiele narzekań na utratę satysfakcji z programowania. Dla mnie ta satysfakcja jest taka sama jak przed erą AI. Najbardziej satysfakcjonujące nadal jest budowanie złożonych systemów, składanie wzorców oraz eksplorowanie różnych opcji i rozwiązań. W kodzie często dzieje się to w mikro skali, ale kiedy patrzymy na szerszy obraz, satysfakcja przychodzi z makro skali.

Sprawdź repozytorium z tym projektem na moim GitHubie

PS: W tym artykule wspomniałem też o wzorcu outbox. Zajrzyj jeśli chcesz dowiedzieć się więcej o samym wzorcu.

typescript and javascript logogreg@aboutjs.dev

©Grzegorz Dubiel | 2026