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.
| # | Decision | Rationale |
|---|---|---|
| ADR-001 | The Worker serves the personalised SDK bundle; there is no separate loader script | One 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-002 | Two builds, esnext and es5, chosen by module/nomodule | A small modern bundle plus an old-browser fallback, without runtime user-agent sniffing. New code must compile to both |
| ADR-003 | Custom lightweight DI, one level of modules, one merged config | PulseModule, 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-004 | Package layering is fixed | common, 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-005 | A single global proxy is the integration surface | window.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-006 | Events travel as state-header chunks | The PulseBatchChunk envelope is the contract with event-receiver, which merges the header into each event |
| ADR-007 | Delivery is durable and budgeted | IndexedDB, 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-008 | Consent gates collection | TrackingConsentService decides whether the queue accepts messages, whether server cookies are set, and whether autopilot runs. Only Force consent events bypass it |
| ADR-009 | Identity formats are stable | Downstream systems parse device_id, session_id and tab_id. A w2m_meta_info value always wins |
| ADR-010 | Cross-tab correctness is required | Services assume N tabs over shared storage: sync-storage change streams, LockManager, BroadcastChannel, tab-owned persisted work |
| ADR-011 | Storage goes through the storage abstractions | Storage tokens, prefixed or unprefixed storage, and IndexedDBService. IndexedDB work is time-boxed with a fallback |
| ADR-012 | Modes change configuration, not code paths | standard, anonymous and page-session are config overrides in the modules' buildConfig, not if (mode === …) checks in services |
| ADR-013 | Testing layers | Colocated Jest specs, @pulse/testbed-node for tabs and storage, Playwright e2e against prebuilt bundles |
| ADR-014 | Releases are semantic | semantic-release decides versions from commit types; npm publishing uses OIDC only. See Contributing |
| ADR-015 | Bundle size is a design constraint | Browser 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.
| Decision | Why | Commits |
|---|---|---|
| Worker returns the full bundle, no separate loader script | One request instead of two | 3ee316e3, 5c64bed1, 9aed110a (DK-1044/1045) |
| Cloudflare Worker and KV instead of Nest and S3, served under optifyr.com next to settings | Edge latency; one hostname lets the settings request be prefetched and avoids extra TLS handshakes | 0c7d047c |
esnext and es5 builds via module/nomodule | Small modern bundle, old-browser fallback without runtime detection | 79e6ef56, 2665659f |
Prefer keepalive fetch over sendBeacon | sendBeacon can't set headers and has CORS problems | d3a305c0 |
| 60 KB batches and an in-flight byte budget | Browsers reject keepalive bodies over 64 KB in total across all requests in flight | f3e8ff39, f26189da (DK-1150) |
No unload or beforeunload; flush on visibilitychange | Requested by the web team because unload handlers break the back-forward cache; visibility is the reliable last moment | 68686cb7, 2f4a449b, eaadef0d |
| IndexedDB persistence of events and batches | Stops loss on crash or close; the next page load resends | b8002e3b |
| Settings served from memory, IndexedDB only as the backing store | Latency of get() | de993e71 (DK-2220) |
| Adaptive settings deadline | The fixed 1500 ms deadline missed about 4–5% of requests, which then fell back to defaults | 07e22b13 |
PulseSyncStorage patches Storage.prototype | The storage event only fires in other tabs; same-tab and third-party writes must be observable too | 43e54ff4, ce74b63e |
Native navigator.locks with a fallback lock manager | Firefox throws on locks.query in iframes | 726f3df2, 7ebcf2e7 (DK-990) |
| Mini tracker | Miniapps run inside a host that already runs Pulse; the mini only forwards events | f4e66d18 (DK-1576) |
pulse-state cookie on the apex domain | Share identity across subdomains; SSR personalisation | 153f38a1, f50845a6 |
| Device id recovery from cookies | Recover identity when localStorage is wiped | c1874284 |
| First visit date decoded from the device id | Drops a separate storage value; the backend decodes segment 3 of the id | a354637f, 961ecca9 (DK-2979) |
| Experiment preview synced across tabs | Per-tab preview state made tabs evict each other's cache entries, at about 150 requests per second | 371626d6 |
| Live debugger device-id handoff | Saves copying the id off the badge by hand; the pinned target origin stops the id leaking to other openers | 86573090 |
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.
- 2021–22: adopt the server id. A
paa-didcookie or header replaces the local device id, to support cross-site identity (9ae10173,928c8c2c). - 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). - 2026-09-23: reverted.
ace6788awent back to adopting the server id, with no reason recorded.898653e3then added ananalytics_statsevent,device_id_replaced, to measure how often the replacement happens.
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.
- Duplicate built-in events.
session_start,navigationandconsent_changehave 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. - Double SDK init (
854b6107,e21e539e,c6a1739e,fa4ca883). Guard every entry point. - Cross-origin sibling or opener access (
ec7f6381,e36a0df5,822b430a). Check same-origin before touching another window. - IndexedDB hangs and corruption. About ten commits between
40e6e768andc0223104. Time-box every operation, keep a path that works without IndexedDB, and emitanalytics_statson timeout, error or upgrade-needed. - Lost last events at unload (
d14a675a,eaadef0d,fa7ff56e,72a849aa, where the event that overflowed a batch was dropped), plus the 64 KB keepalive cap. - Prefixed versus unprefixed storage (
20e3c69c,a098b6db,ca0ba413). Foreign keys must use unprefixed tokens, or you read keys likepaa_paa-did. - Slow
onInithooks regenerate sessions and break e2e (ad31f4b1,8c033ae1). - Settings cache staleness versus request loops (
a02c2b54,a45e786e,2d4ab95a,5e5e0c6d,371626d6). - Consent provider flip-flops per domain (OneTrust and DataGrail), about ten commits. The configuration lives in the Worker's
values.ts,gdpr.tsandorigins.tsunderapps/server/src/data/. - Batch time and RUM toggled back and forth (
fc2a393c,b9992c05,327da8f5,7cd55184). Keep the Worker and client defaults aligned, and measure before changing them. - 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 everyonInit. 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
datafield 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 anObservableof a boolean. Subscribe to it, for example withtake(1). - Leaking or multiplying RxJS subscriptions. Every
subscribeneeds an end; share side-effect streams; guard state-driven triggers withdistinctUntilChanged. Otherwise you get request storms like the settings cache ping-pong in371626d6. - Letting later modules override computed defaults.
beforeInitresults are shallow-merged in import order, so a later module that spreads shared defaults overwrites values an earlier module computed. Mode-specific overrides belong inbuildConfigintracker/src/tracker.module.ts. - Registering a stateful service without
provideInRoot: true. A dependency resolved inside a child injector'sonInitcan otherwise be a second instance. Services that own connections, locks, caches or subscriptions must be root-only;IndexedDBServiceis 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
windowordocumentin@pulse/common. It also runs in Node and Workers (@pulse/server, SSR). Browser access belongs inbrowser/shared, behindPulseEnvironment. - Using an API the ES5 build can't polyfill (
WeakRef,BroadcastChannel,navigator.locks,CompressionStream, a resizableArrayBuffer) 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,testordocsnever 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.
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
| Area | pulse-web path |
|---|---|
| DI framework and config merge | packages/common/src/di/ (module.ts) |
Mode overrides (buildConfig) | packages/browser/tracker/src/tracker.module.ts |
| Client batching and RUM defaults | packages/browser/tracker/src/tracker.configuration.ts |
| Worker segment, consent and origin data | apps/server/src/data/values.ts, gdpr.ts, origins.ts |
| Transport and device-id replacement | packages/browser/shared/src/services/http/http.service.ts |
| Global proxy | packages/common/src/pulse/proxy.ts |
| Release configuration | .releaserc.yml, .gitlab-ci.yml |