Skip to main content

Design Decisions and Common Pitfalls

Definition​

This document explains the settled architecture decisions in pulse-web and why they were made, the history behind the most important ones, and the classes of bugs that have come back more than once. Read it before a non-trivial change: a change that breaks one of these decisions should be raised in the design phase, not worked around.

Settled decisions​

These decisions are settled. Don't deviate from them without explicit approval from the maintainer.

#DecisionRationale
ADR-001The Worker serves the personalised SDK bundle; there is no separate loader scriptOne request instead of two (DK-1044/1045). Per-request config replaces the dynamic.env.* placeholders. Per-site behaviour belongs in the Worker's segment and value data (apps/server/src/data/*), not in a second request
ADR-002Two builds, esnext and es5, chosen by module/nomoduleA small modern bundle plus an old-browser fallback, without runtime user-agent sniffing. New code must compile to both
ADR-003Custom lightweight DI, one level of modules, one merged configPulseModule, PulseInjector and ProviderBuilder are the only DI mechanism. Modules are composed once, in the bundle entry. No third-party container, no nested imports, no config from globals
ADR-004Package layering is fixedcommon, then browser/shared, then tracker-shared, then tracker and mini; settings on browser/shared; loader composes. Siblings don't import each other; public wrappers depend only on common
ADR-005A single global proxy is the integration surfacewindow.pulse queues calls until the SDK is ready, then replays them in order. Script tag, @pulse/browser, @pulse/react and @pulse/core all go through it; dataLayer commands are the other entry point
ADR-006Events travel as state-header chunksThe PulseBatchChunk envelope is the contract with event-receiver, which merges the header into each event
ADR-007Delivery is durable and budgetedIndexedDB, then an rx buffer, then batches of 60 KB or less, then BatchQueue with an in-flight byte budget, then HttpService. Retries with backoff for up to 60 s; batches stay in IndexedDB until confirmed
ADR-008Consent gates collectionTrackingConsentService decides whether the queue accepts messages, whether server cookies are set, and whether autopilot runs. Only Force consent events bypass it
ADR-009Identity formats are stableDownstream systems parse device_id, session_id and tab_id. A w2m_meta_info value always wins
ADR-010Cross-tab correctness is requiredServices assume N tabs over shared storage: sync-storage change streams, LockManager, BroadcastChannel, tab-owned persisted work
ADR-011Storage goes through the storage abstractionsStorage tokens, prefixed or unprefixed storage, and IndexedDBService. IndexedDB work is time-boxed with a fallback
ADR-012Modes change configuration, not code pathsstandard, anonymous and page-session are config overrides in the modules' buildConfig, not if (mode === …) checks in services
ADR-013Testing layersColocated Jest specs, @pulse/testbed-node for tabs and storage, Playwright e2e against prebuilt bundles
ADR-014Releases are semanticsemantic-release decides versions from commit types; npm publishing uses OIDC only. See Contributing
ADR-015Bundle size is a design constraintBrowser and common code ships on every page load of every Picsart site. New runtime dependencies need a justification

For how these decisions shape the code, see Overview, Packages and DI, SDK Delivery and Event Pipeline.

Key design history​

Each row points at the commit that carries the reasoning. Run git show with the hash in the pulse-web repository to read the full message.

DecisionWhyCommits
Worker returns the full bundle, no separate loader scriptOne request instead of two3ee316e3, 5c64bed1, 9aed110a (DK-1044/1045)
Cloudflare Worker and KV instead of Nest and S3, served under optifyr.com next to settingsEdge latency; one hostname lets the settings request be prefetched and avoids extra TLS handshakes0c7d047c
esnext and es5 builds via module/nomoduleSmall modern bundle, old-browser fallback without runtime detection79e6ef56, 2665659f
Prefer keepalive fetch over sendBeaconsendBeacon can't set headers and has CORS problemsd3a305c0
60 KB batches and an in-flight byte budgetBrowsers reject keepalive bodies over 64 KB in total across all requests in flightf3e8ff39, f26189da (DK-1150)
No unload or beforeunload; flush on visibilitychangeRequested by the web team because unload handlers break the back-forward cache; visibility is the reliable last moment68686cb7, 2f4a449b, eaadef0d
IndexedDB persistence of events and batchesStops loss on crash or close; the next page load resendsb8002e3b
Settings served from memory, IndexedDB only as the backing storeLatency of get()de993e71 (DK-2220)
Adaptive settings deadlineThe fixed 1500 ms deadline missed about 4–5% of requests, which then fell back to defaults07e22b13
PulseSyncStorage patches Storage.prototypeThe storage event only fires in other tabs; same-tab and third-party writes must be observable too43e54ff4, ce74b63e
Native navigator.locks with a fallback lock managerFirefox throws on locks.query in iframes726f3df2, 7ebcf2e7 (DK-990)
Mini trackerMiniapps run inside a host that already runs Pulse; the mini only forwards eventsf4e66d18 (DK-1576)
pulse-state cookie on the apex domainShare identity across subdomains; SSR personalisation153f38a1, f50845a6
Device id recovery from cookiesRecover identity when localStorage is wipedc1874284
First visit date decoded from the device idDrops a separate storage value; the backend decodes segment 3 of the ida354637f, 961ecca9 (DK-2979)
Experiment preview synced across tabsPer-tab preview state made tabs evict each other's cache entries, at about 150 requests per second371626d6
Live debugger device-id handoffSaves copying the id off the badge by hand; the pinned target origin stops the id leaking to other openers86573090

The device-id adoption story​

The event-receiver can return a device id of its own in the unprefixed paa-did cookie and response header. Whether the SDK should adopt it has changed direction twice. Read this before touching HttpService.replaceDeviceId.

  1. 2021–22: adopt the server id. A paa-did cookie or header replaces the local device id, to support cross-site identity (9ae10173, 928c8c2c).
  2. 2026-09-14/15: keep the local id instead. Adopting the server id silently re-identified the device and broke continuity. The fix kept the local id and reported the server id as the attribute device_previous_id, persisted once the queue drained (4b84c08b, efb758dc, ad31f4b1, f53da628).
  3. 2026-09-23: reverted. ace6788a went back to adopting the server id, with no reason recorded. 898653e3 then added an analytics_stats event, device_id_replaced, to measure how often the replacement happens.
caution

The direction here is a product decision that is still open. Today the SDK adopts the server id. Check with the owner before changing it. See Identity and State for how the device id is resolved.

Recurring bug classes​

Each of these has broken more than once. Treat them as review checklist items.

  1. Duplicate built-in events. session_start, navigation and consent_change have each been sent twice: across tabs, on re-init, on renewal, on consent transitions (a230fc29, 8b65c929, 9f7c501b, 9cf934ad, 35f7a8e7, f14a128a, f1d6d880). The fixes use locks, HashStore dedup and "report on transition only". f1d6d880 (DK-2980) is the model write-up.
  2. Double SDK init (854b6107, e21e539e, c6a1739e, fa4ca883). Guard every entry point.
  3. Cross-origin sibling or opener access (ec7f6381, e36a0df5, 822b430a). Check same-origin before touching another window.
  4. IndexedDB hangs and corruption. About ten commits between 40e6e768 and c0223104. Time-box every operation, keep a path that works without IndexedDB, and emit analytics_stats on timeout, error or upgrade-needed.
  5. Lost last events at unload (d14a675a, eaadef0d, fa7ff56e, 72a849aa, where the event that overflowed a batch was dropped), plus the 64 KB keepalive cap.
  6. Prefixed versus unprefixed storage (20e3c69c, a098b6db, ca0ba413). Foreign keys must use unprefixed tokens, or you read keys like paa_paa-did.
  7. Slow onInit hooks regenerate sessions and break e2e (ad31f4b1, 8c033ae1).
  8. Settings cache staleness versus request loops (a02c2b54, a45e786e, 2d4ab95a, 5e5e0c6d, 371626d6).
  9. Consent provider flip-flops per domain (OneTrust and DataGrail), about ten commits. The configuration lives in the Worker's values.ts, gdpr.ts and origins.ts under apps/server/src/data/.
  10. Batch time and RUM toggled back and forth (fc2a393c, b9992c05, 327da8f5, 7cd55184). Keep the Worker and client defaults aligned, and measure before changing them.
  11. Storage-less environments such as webviews with no cookies or no localStorage (a1886624, DK-2147; ca0ba413).

Common pitfalls​

These mistakes have caused bugs, reverts or review cycles.

  • Slow work in a module's onInit. Bootstrap awaits every onInit. Resolving a heavy graph there delays the tracker and regenerates sessions (ad31f4b1). Start optional features from a plugin or after the page has loaded.
  • Assuming one tab. Every tab runs its own services over shared localStorage, cookies and IndexedDB. Use locks, storage change streams and tab ownership of persisted records.
  • Trusting storage to exist and answer. Webviews may have no storage at all, and IndexedDB can hang or be corrupt.
  • Adding a field the receiver will strip. A new data field on a built-in event disappears unless it is defined in tools-portal for that app, and the request still returns HTTP 200. See Cross-Repository Contracts.
  • Renaming a persisted key or wire field without compatibility. Old and new bundles share storage on the same device. Keep reading the old key until every consumer has moved.
  • Treating an Observable as a boolean. if (consentService.trackingEnabled()) is always truthy, because it returns an Observable of a boolean. Subscribe to it, for example with take(1).
  • Leaking or multiplying RxJS subscriptions. Every subscribe needs an end; share side-effect streams; guard state-driven triggers with distinctUntilChanged. Otherwise you get request storms like the settings cache ping-pong in 371626d6.
  • Letting later modules override computed defaults. beforeInit results are shallow-merged in import order, so a later module that spreads shared defaults overwrites values an earlier module computed. Mode-specific overrides belong in buildConfig in tracker/src/tracker.module.ts.
  • Registering a stateful service without provideInRoot: true. A dependency resolved inside a child injector's onInit can otherwise be a second instance. Services that own connections, locks, caches or subscriptions must be root-only; IndexedDBService is the example.
  • Importing the SDK into a public wrapper, or siblings into each other. The first bundles the tracker into integrators' apps; the second breaks the layering.
  • Touching window or document in @pulse/common. It also runs in Node and Workers (@pulse/server, SSR). Browser access belongs in browser/shared, behind PulseEnvironment.
  • Using an API the ES5 build can't polyfill (WeakRef, BroadcastChannel, navigator.locks, CompressionStream, a resizable ArrayBuffer) without feature detection and a fallback.
  • Changing batching or RUM defaults on one side only. Client defaults (tracker.configuration.ts) and Worker overrides (apps/server/src/data/values.ts) must agree.
  • Rewriting the device-id policy casually. See the story above.
  • Shipping with a non-releasing commit type. A fix committed as chore, test or docs never reaches users.
  • Skipping the design step for "small" cross-cutting changes. Write a design first for any change that crosses packages; touches identity, consent, the queue or transport; changes a wire field or storage key; adds a dependency; or changes a public npm API.
info

Known open issues on main are tracked in the pulse-web repository, in .claude/skills/pulse-architecture/references/history-and-gotchas.md.

Where to look in the code​

Areapulse-web path
DI framework and config mergepackages/common/src/di/ (module.ts)
Mode overrides (buildConfig)packages/browser/tracker/src/tracker.module.ts
Client batching and RUM defaultspackages/browser/tracker/src/tracker.configuration.ts
Worker segment, consent and origin dataapps/server/src/data/values.ts, gdpr.ts, origins.ts
Transport and device-id replacementpackages/browser/shared/src/services/http/http.service.ts
Global proxypackages/common/src/pulse/proxy.ts
Release configuration.releaserc.yml, .gitlab-ci.yml