Blog
Wydajność

Pasek zgód na własnym kodzie — dlaczego liczy się moment, a nie kilobajty

Logika zgód z Consent Mode v2 zmieściła się u mnie w 900 bajtach po kompresji. Sam skrypt wejściowy gotowej platformy to 28 kB i osobne połączenie do obcej domeny. Co trzeba w takim pasku zrobić dobrze, na czym się przewróciłem i kiedy gotowiec jest jednak właściwym wyborem.

Pięć ciastek na otwartym laptopie — cztery ułożone w stos i jedno obok, na gładziku

Skrypt wejściowy popularnej platformy zgód waży 121 kB, po gzipie 28,3 kB, i jest tylko loaderem — konfigurację domeny oraz samo okno dialogowe dociąga osobno. Logika zgód, którą napisałem zamiast niego, ma 1922 bajty po minifikacji i 900 bajtów po gzipie, wchodzi we wspólny bundle i nie dokłada żadnego żądania.

To porównanie obejmuje po obu stronach samą logikę. Moje 900 bajtów nie zawiera komponentu rysującego pasek, a te 28 kB nie zawiera okna dialogowego platformy. Komponent u mnie to 2,8 kB źródła Vue przed kompresją, które w bundlu i tak dzieli style z resztą serwisu.

Różnica w kilobajtach jest tu jednak najmniej ciekawa. Skrypt zgód z definicji musi wystartować przed tagami, które ma blokować, więc ląduje w tym miejscu ładowania strony, gdzie każde żądanie konkuruje z treścią. Ten efekt zmierzyłem — choć na innych skryptach niż platforma zgód.

Co jest pomiarem, a co wnioskiem

Chcę to powiedzieć na wstępie, żeby nikt nie musiał się domyślać.

Zmierzone: rozmiar loadera platformy zgód, rozmiar mojego kodu, oraz koszt dwóch innych skryptów zewnętrznych w oknie krytycznym mojej strony głównej.

Wywnioskowane przez analogię: że gotowa platforma zgód kosztuje podobnie. Nie wstawiłem jej na swoją stronę i nie zmierzyłem. Wniosek opieram na tym, że jest większa od tamtych dwóch skryptów i z konieczności siedzi wcześniej. Traktuj to jako hipotezę popartą sąsiednim pomiarem.

Dziesięć kilobajtów, które kosztowały 450 ms

Na targetx.pl Cloudflare wstrzykiwał własny beacon do statystyk — beacon.min.js, 10 kB przy 332 kB całego skryptu strony. Przy tych proporcjach wyglądał na zaokrąglenie.

Przez większość dnia optymalizacji FCP stało nieruchomo na 2701 ms, powtarzalne co do milisekundy w dwunastu kolejnych przebiegach PageSpeed Insights. Nie drgnęło po zdjęciu ponad 300 kB skryptu. Nie drgnęło po ucięciu 87 kB HTML-a. Ruszyło dopiero po wyłączeniu beacona: 2701 → 2251 ms.

Tyle pomiaru. Teraz interpretacja, bo mechanizmu nie udowodniłem.

<script type="module"> jest domyślnie odroczony i renderu nie blokuje, więc te 450 ms nie biorą się z blokowania parsera. Najbardziej prawdopodobne wyjaśnienie to koszt otwarcia połączenia do trzeciej domeny — rozwiązanie DNS, uzgodnienie TLS i transfer — w oknie, w którym przeglądarka zajmuje się obrazkiem hero. Lighthouse symuluje wolne łącze, więc dodatkowy handshake waży tam więcej niż na moim biurku. Pewności nie mam; mam za to powtarzalny wynik i moment, w którym się zmienił.

Jedno zastrzeżenie do samej powtarzalności. PageSpeed Insights liczy FCP modelem Lantern, na podstawie grafu żądań, a nie z prawdziwego przebiegu na łączu. Dwanaście identycznych wyników to więc cecha symulacji, nie dowód stabilności w terenie. Model wzmacnia moją interpretację, bo dolicza koszt dodatkowego połączenia — i jednocześnie znaczy, że te 450 ms jest wynikiem laboratoryjnym. Dane z prawdziwych przeglądarek mogą pokazać mniej albo więcej.

Wniosek praktyczny zostaje ten sam niezależnie od mechanizmu: przy skryptach ładowanych wcześnie patrz na pozycję w kolejce, nie na rozmiar pliku.

Druga historia: poprawna konfiguracja, która nic nie robiła

Na tej samej stronie miałem włączoną funkcję podającą kontener Tag Managera z pierwszej domeny, spod ścieżki w rodzaju mojadomena.pl/zig4/. Kosztowała około 300 kB, ale gorsze było co innego: wstawka google_tags_first_party siedziała na bajcie 151 dokumentu HTML.

Kontener startował więc przed hydracją aplikacji, a mój kod ustawiający domyślne odmowy zgód trafiał do dataLayer dopiero po zdarzeniach gtm.dom i gtm.load. Consent Mode wymaga odwrotnej kolejności — domyślne ustawienia muszą być na miejscu przed startem kontenera, bo inaczej tagi czytają stan, którego jeszcze nie ustawiłeś.

W panelu wszystko wyglądało poprawnie. W przeglądarce mechanizm zgód był pozorny.

Po wyłączeniu tej funkcji wynik ogólny poszedł w górę: Performance 70 → 81–85, LCP 5,6 s → 3,35 s. FCP, jak wyżej, nie zareagowało wcale — stąd dwie osobne historie zamiast jednej.

Dla porządku co do liczb: 332 kB skryptu to stan po wyłączeniu tej funkcji. Beacon mierzyłem już w tym stanie.

Co taki pasek musi umieć

Zanim napiszesz własny, warto wiedzieć, na co się piszesz. Od strony technicznej:

  1. Ustawić domyślne odmowy w Consent Mode v2, zanim wystartuje kontener.
  2. Pokazać wybór, gdy decyzji jeszcze nie ma.
  3. Zapamiętać decyzję i przekazać ją do dataLayer.
  4. Pozwolić ją wycofać, ze skutkiem natychmiastowym.
  5. Nie pokazywać się nikomu, kto już zdecydował.

Od strony prawnej dochodzą dwa wymogi dotyczące samego interfejsu, o których łatwo zapomnieć przy pisaniu własnego. Przycisk odmowy musi być równorzędny z przyciskiem zgody — ten sam poziom, podobna widoczność, bez chowania odmowy w ustawieniach szczegółowych. I żadna zgoda nie może być domyślnie zaznaczona; brak decyzji znaczy odmowę.

Pasek z jednym przyciskiem „OK" łamie pierwszy warunek wprost, a drugi omija, bo nie daje żadnego wyboru. Widuje się go wciąż często.

Domyślne odmowy

const gtag: Gtag = function () {
  warstwa().push(arguments);
};

export function ustawDomyslneZgody(): void {
  gtag('consent', 'default', {
    ad_storage: 'denied',
    ad_user_data: 'denied',
    ad_personalization: 'denied',
    analytics_storage: 'denied',
    wait_for_update: 500,
  });
}

Funkcja gtag wypycha do dataLayer obiekt arguments, a nie zwykłą tablicę. Tak wygląda oryginalna wtyczka Google'a i tak trzeba to zrobić, bo kontener rozpoznaje polecenia zgód po tym kształcie.

wait_for_update: 500 każe tagom wstrzymać się pół sekundy na wypadek, gdyby zaraz przyszła aktualizacja odczytana z ciasteczka. W mojej architekturze jest to asekuracja, a nie mechanizm, na którym polegam: kontener startuje dopiero po zdarzeniu load, więc odczyt z ciasteczka i tak trafia do kolejki wcześniej. Zostawiam to pole na wypadek, gdyby ktoś przestawił moment startu.

Aktualizacja po decyzji podnosi wyłącznie jedną flagę:

export function zaktualizujZgode(wybor: WyborZgody): void {
  gtag('consent', 'update', {
    ad_storage: 'denied',
    ad_user_data: 'denied',
    ad_personalization: 'denied',
    analytics_storage: wybor === 'udzielona' ? 'granted' : 'denied',
  });
}

Trzy pozostałe zostają odrzucone niezależnie od wyboru, bo na tych serwisach nie ma reklam. Gdy są, dwa przyciski przestają wystarczać: potrzebujesz osobnych kategorii i ekranu szczegółowego, a to jest moment, w którym własne rozwiązanie zaczyna się robić projektem samym w sobie.

Zapis i wycofanie

const NAZWA_CIASTECZKA = 'targetx_zgody';
const WAZNOSC_DNI = 180;

export function zapiszZgode(wybor: WyborZgody): void {
  const bezpieczne = location.protocol === 'https:' ? '; Secure' : '';
  document.cookie = `${NAZWA_CIASTECZKA}=${wybor}; path=/; max-age=${WAZNOSC_DNI * 86400}; SameSite=Lax${bezpieczne}`;
}

Zwykłe ciasteczko, bez biblioteki. SameSite=Lax wystarcza, bo nikt nie czyta tego z żądania między domenami, a Secure dokładam warunkowo, żeby mechanizm działał też pod localhost.

Wycofanie zgody wymaga trzech rzeczy, a nie jednej — i tu popełniłem błąd, który wyłapała dopiero recenzja tego wpisu. Moja pierwsza wersja kasowała ciasteczko wyboru i pokazywała pasek, przez co analityka chodziła dalej na udzielonej zgodzie aż do końca bieżącej odsłony. Poprawnie:

zapomnijZgode();
zaktualizujZgode('odrzucona');
widoczny.value = true;

Czego nadal nie robię: ciasteczka _ga i _ga_* zostają w przeglądarce. Po odmowie analityka do nich nie sięga, więc są bezużyteczne, ale fizycznie tam są. Skasowanie ich wymaga odgadnięcia domeny, na której zostały ustawione, co przy adresach w rodzaju co.uk bywa zawodne. Jeśli robisz to u siebie, sprawdź, na jakiej domenie faktycznie siedzą.

Sam przełącznik w stopce to link z atrybutem data-zmien-zgody, a komponent nasłuchuje kliknięć na całym dokumencie i reaguje na najbliższy pasujący element. Dzięki temu link można wstawić w dowolnym miejscu układu bez przekazywania czegokolwiek przez propsy.

Kiedy startuje Tag Manager

Tu leży druga połowa zysku na wydajności:

const LIMIT_BEZCZYNNOSCI_MS = 3000;
const ZAPASOWE_OPOZNIENIE_MS = 2500;

const odpal = () => zaladujGtm(identyfikator);

const poBezczynnosci = () => {
  const okno = window as Window & {
    requestIdleCallback?: (wywolanie: () => void, opcje?: { timeout: number }) => void;
  };

  if (typeof okno.requestIdleCallback === 'function') {
    okno.requestIdleCallback(odpal, { timeout: LIMIT_BEZCZYNNOSCI_MS });
    return;
  }

  setTimeout(odpal, ZAPASOWE_OPOZNIENIE_MS);
};

if (document.readyState === 'complete') {
  poBezczynnosci();
} else {
  window.addEventListener('load', poBezczynnosci, { once: true });
}

window.addEventListener('pointerdown', odpal, { once: true, passive: true });
window.addEventListener('keydown', odpal, { once: true });

Kontener czeka na zdarzenie load, potem na pierwszą wolną chwilę głównego wątku, z twardym limitem trzech sekund. Równolegle wisi nasłuch na pierwszej interakcji, więc ktoś, kto od razu klika, nie czeka.

odpal może więc zostać wywołane z trzech miejsc. Przed podwójnym wstrzyknięciem kontenera chroni flaga po stronie funkcji ładującej:

export function zaladujGtm(identyfikator: string): void {
  const okno = window as OknoZWarstwa;

  if (identyfikator === '' || okno.targetxGtmZaladowany) {
    return;
  }

  okno.targetxGtmZaladowany = true;
  // dalej: wstawienie skryptu
}

Domyślne zgody ustawiam przed tym wszystkim, w pierwszej linii pluginu. To jest ta kolejność, którą psuła funkcja opisana wcześniej.

Warto uczciwie dodać: samo opóźnienie startu Tag Managera da się zrobić także z gotową platformą zgód. To dwie niezależne decyzje i zysk z nich też jest niezależny. Własny pasek daje kontrolę nad jednym skryptem mniej w oknie krytycznym, opóźnienie GTM-u daje resztę.

Trzy pułapki, których nikt za Ciebie nie obsłuży

Gotowa platforma bierze na siebie także to, co niżej. Przy własnym kodzie trafiasz na to osobiście — ja trafiłem na wszystkie trzy w ciągu jednego popołudnia.

Żeby dalsze przykłady dało się czytać, dwa zdania o tym, co mam w kontenerze. Google Analytics 4 działa w trybie zaawansowanym i ma jeden wyzwalacz, zdarzenie inicjujące. Microsoft Clarity, czyli mapy ciepła i nagrania sesji, działa w trybie podstawowym i wymaga zgody, więc dostał dwa wyzwalacze oraz ustawienie „Raz na stronę". Te same dwa tagi zobaczysz niżej w JSON-ie kontenera.

Tag wymagający zgody nie policzy wizyty, w której zgoda padła

Jeśli w Tag Managerze zaznaczysz tagowi „Wymagaj dodatkowej zgody na uruchomienie", a wyzwalaczem jest zdarzenie inicjujące albo odsłona strony, przy pierwszej wizycie dzieje się tak: kontener startuje, tag zostaje zablokowany brakiem zgody, użytkownik czyta stronę, klika „Zgadzam się" — i nic się nie dzieje. Wyzwalające zdarzenie dawno minęło, a tag odpala się dopiero przy następnej odsłonie.

Tracisz przez to odsłonę wejścia, czyli tę jedyną, która niesie źródło ruchu.

Poprawka po stronie kodu to jedno zdarzenie:

export function zglosUdzielenieZgody(): void {
  warstwa().push({ event: 'consent_granted' });
}

Po stronie kontenera dokładasz temu tagowi drugi wyzwalacz typu „zdarzenie niestandardowe" o nazwie consent_granted.

Razem z tym przestaw „Opcje uruchamiania tagów" na „Raz na stronę". Domyślne „Raz na zdarzenie" ogranicza tag do jednego uruchomienia w obrębie jednego zdarzenia, a tutaj masz dwa różne zdarzenia, więc przed dublowaniem nie chroni wcale. Kolejna sekcja pokazuje, jak to wygląda w praktyce.

Ta sama poprawka potrafi liczyć podwójnie

Pierwsza wersja, którą napisałem, wypychała to zdarzenie wewnątrz zaktualizujZgode. Wyglądało logicznie: zgoda się zmienia, więc informuję o tym kontener.

Problem w tym, że ta funkcja woła się w dwóch miejscach. Raz przy kliknięciu w pasku, drugi raz przy starcie strony, gdy plugin odczytuje zapisaną zgodę z ciasteczka. Przy każdym powrocie stałego czytelnika tag odpalał się więc dwa razy: raz ze zdarzenia inicjującego, raz z mojego. Ustawienie „Raz na zdarzenie" tego nie zatrzymało, bo to były dwa osobne zdarzenia.

Zdarzenie należy do gestu, a nie do stanu. Wypycham je wyłącznie z obsługi kliknięcia:

const rozstrzygnij = (wybor: WyborZgody) => {
  zapiszZgode(wybor);
  zaktualizujZgode(wybor);

  if (wybor === 'udzielona') {
    zglosUdzielenieZgody();
  }

  widoczny.value = false;
};

Jest jeszcze jeden układ, w którym to samo wraca: ktoś klika zgodę, zanim kontener zdąży się pobrać. Mierzyłem taki przebieg — kliknięcie padło w 275 ms, kontener zaczął się ładować w 312 ms. Oba zdarzenia trafiają wtedy do kolejki jeszcze przed startem kontenera, a ten przetwarza je już ze zgodą udzieloną i może uruchomić tag dwukrotnie.

Mierzyłem ten przebieg, gdy tag stał jeszcze na „Raz na zdarzenie", i dublowania nie zobaczyłem — ale zawdzięczałem to zabezpieczeniu wewnątrz samego Clarity, a nie własnej konfiguracji. Po przestawieniu na „Raz na stronę" chroni już ustawienie, na które mam wpływ. Stąd to zalecenie jako wymóg, nie ozdoba.

Poprawna poprawka zależy od trybu zgody

Consent Mode ma dwa tryby i decyduje o nich jedno pole w konfiguracji tagu.

Tryb podstawowy, czyli zaznaczone „Wymagaj dodatkowej zgody": przed decyzją nie leci nic. Sprawdziłem to na drugim swoim serwisie, gdzie Analytics stał wtedy właśnie w tym trybie — zero żądań do Google, zero ciasteczek. To opisana wyżej sytuacja, w której drugi wyzwalacz jest konieczny.

Tryb zaawansowany, czyli brak takiego wymogu: tag startuje zawsze, ale do czasu zgody wysyła sygnał bez ciasteczek i bez identyfikatorów.

Tu potrzebne jest zastrzeżenie, którego w poradnikach zwykle brakuje. Takie sygnały nie pojawiają się w raportach jako zwykłe odsłony. Zasilają modelowanie zachowań, a modelowanie włącza się dopiero po przekroczeniu progów ruchu, których mały serwis nie osiągnie. Przy kilkudziesięciu wizytach miesięcznie różnica między trybami jest w praktyce mniejsza, niż wynikałoby z opisu.

Trzeba z tego wyciągnąć wniosek, który mnie samego kosztował chwilę: przy małym ruchu wybór trybu jest w praktyce decyzją głównie teoretyczną. Ani jeden, ani drugi nie pokaże Ci w raportach wejść od osób, które paska nie dotknęły. Wybrałem zaawansowany, bo nic nie kosztuje technicznie, bo mój pasek i tak mówi użytkownikowi, że statystyki chodzą bez ciasteczek, i bo przy większym ruchu modelowanie zacznie działać bez przestawiania czegokolwiek. Gdyby serwis miał reklamy albo dane wrażliwe, wybrałbym odwrotnie.

Dochodzi do tego kwestia prawna. Argument, że sygnał przed zgodą niczego nie zapisuje na urządzeniu i nie niesie identyfikatora, jest moją interpretacją, a nie ustalonym stanowiskiem. Wytyczne EDPB 2/2023 czytają artykuł 5 ustęp 3 dyrektywy ePrivacy szeroko i obejmują nim także piksele oraz śledzenie przez parametry adresu, czyli techniki bez zapisu po stronie przeglądarki. Jeśli doradzasz w tej sprawie klientowi, warto o tym wiedzieć, zanim powtórzysz moje uzasadnienie.

W trybie zaawansowanym drugi wyzwalacz wysyła drugie trafienie dla tej samej odsłony, bo tag odpalił się już na zdarzeniu inicjującym. Dokładnie to zrobiłem, przenosząc rozwiązanie z jednego serwisu na drugi bez sprawdzenia, że tagi są tam skonfigurowane inaczej.

Czy to znaczy dwie odsłony w raportach, czy odzyskanie tej, która wcześniej poszła bez zgody i do raportów nie trafiła — tego z przeglądarki nie rozstrzygniesz. Widzisz dwa żądania, a co z nimi zrobi Google, zależy od obsługi trybu zgody po jego stronie. Dlatego w trybie zaawansowanym drugiego wyzwalacza nie dokładam: przy niepewności wolę jedno trafienie, o którym wiem, co znaczy.

Zasada, która z tego zostaje: najpierw wybierasz tryb, dopiero potem dobierasz wyzwalacze. W trybie podstawowym drugi wyzwalacz jest konieczny i jego skutek jest jednoznaczny. W zaawansowanym daj sobie z nim spokój.

Jak to sprawdzić, zamiast zakładać

Panel Tag Managera pokazuje, co zamierzałeś. Do sprawdzenia, co naprawdę jest opublikowane, wystarczy pobrać kontener:

curl -s "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX" -o kontener.js

W środku siedzi czytelny JSON. Lista tagów mówi, czego każdy wymaga i jak często może odpalić:

"tags":[{"function":"__googtag","consent":["list"],
         "once_per_event":true,"vtp_tagId":"G-XXXXXXXXXX","tag_id":3},
        {"function":"__cvt_MQDKZ","consent":["list","analytics_storage"],
         "once_per_load":true,"vtp_projectId":"xxxxxxxxxx","tag_id":6}]

To jest mój prawdziwy kontener, z zaślepionymi identyfikatorami. Indeks 0 to Google Analytics, indeks 1 to Clarity — __cvt_MQDKZ oznacza szablon z galerii, a nie wbudowany typ tagu.

Pole consent z wpisem analytics_storage znaczy tryb podstawowy, sama ["list"] — zaawansowany. once_per_event to „Raz na zdarzenie", once_per_load to „Raz na stronę". Widać więc od razu, że Analytics działa w trybie zaawansowanym, a Clarity w podstawowym.

Obok znajdziesz warunki wyzwalaczy i mapowanie jednych na drugie:

"predicates":[{"function":"_eq","arg0":["macro",0],"arg1":"gtm.init"},
              {"function":"_eq","arg0":["macro",0],"arg1":"gtm.js"},
              {"function":"_eq","arg0":["macro",0],"arg1":"consent_granted"}],
"rules":[[["if",0],["add",0]],[["if",1],["add",1]],[["if",2],["add",1]]]

Czyta się to tak: warunek 0 uruchamia tag 0, a warunki 1 i 2 uruchamiają tag 1. Czyli Analytics na zdarzeniu inicjującym, Clarity na odsłonie strony oraz na zdarzeniu zgody — dokładnie ten układ dwóch wyzwalaczy, o którym była mowa wyżej.

Jedna pułapka przy czytaniu: liczba w add to indeks w tablicy tags, a nie tag_id. Obok stoją tag_id równe 3 i 6, więc łatwo je pomylić.

Nazwa zdarzenia jest widoczna wprost, więc od razu sprawdzisz, czy zgadza się co do znaku z tym, co wypycha strona.

Po stronie przeglądarki najszybszy pomiar daje parametr gcs w żądaniu do /g/collect:

performance.getEntriesByType('resource')
  .filter(z => /g\/collect/.test(z.name))
  .map(z => z.name.match(/[?&]gcs=([^&]*)/)?.[1]);

G100 oznacza oba magazyny odrzucone, G101 — analitykę udzieloną. Liczba samych żądań mówi o dublowaniu.

Cztery stany warto przejść za każdym razem, na czystej sesji: przed decyzją, zaraz po kliknięciu, po przeładowaniu z zapisaną zgodą i po wycofaniu. Ten ostatni jest tym, o którym zapomniałem.

Kiedy gotowa platforma jest właściwym wyborem

Własny pasek zgód ma sens przy serwisie, który zbiera statystyki odwiedzin i nic ponadto. Gotowe rozwiązanie wygrywa w czterech sytuacjach:

AdSense, Ad Manager albo AdMob. W Europejskim Obszarze Gospodarczym, Wielkiej Brytanii i Szwajcarii Google wymaga przy nich certyfikowanej platformy zgód obsługującej framework TCF od IAB. To setki stron specyfikacji i rejestr dostawców, który zmienia się co tydzień. Tego nie pisze się samodzielnie.

Obowiązek okresowego skanowania. Platformy przeglądają serwis i wykrywają ciasteczka, które ktoś dołożył wtyczką bez informowania nikogo. Przy serwisie prowadzonym przez zespół to realna wartość.

Wiele języków i jurysdykcji. Inny zakres zgód dla Unii, inny dla Kalifornii, inny dla Brazylii, do tego tłumaczenia.

Dowód zgodności na papierze. Część klientów potrzebuje dokumentu z logo dostawcy do audytu. Własne 900 bajtów tego nie zastąpi, choćby działały lepiej.

W pozostałych przypadkach płacisz abonamentem i miejscem w kolejce ładowania za funkcje, których nie używasz.

Co z tego zostaje

Skrypt zgód jest jedynym elementem strony, który z definicji musi wystartować przed wszystkim innym. Dlatego jego ocena po rozmiarze pliku prowadzi na manowce — liczy się, co przez niego czeka. Dziesięciokilobajtowy beacon kosztował mnie 450 ms FCP, a trzystukilobajtowa paczka skryptu nie kosztowała ani milisekundy tej samej metryki.

Własna implementacja daje kontrolę nad tym momentem. Kosztuje za to trzy pułapki opisane wyżej i obowiązek sprawdzenia w przeglądarce, czy to, co widzisz w panelu, faktycznie się dzieje.

Na trzy pułapki trafiłem sam, przy wdrożeniu. Kolejne trzy błędy — w tym jeden w opisie samej poprawki i jeden w kodzie wycofywania zgody — znalazł dopiero ktoś, kto przeczytał ten tekst uważniej ode mnie. Taki jest koszt rozwiązania, którego nikt za Ciebie nie utrzymuje.