Architecture Overview
Definition
This document is meant for engineers who wish to contribute to the web tracking SDK or just want
to find out how it works. It explains how the Pulse web SDK (the pulse-web repository) is put
together: what the SDK is responsible for, which systems it talks to, and the core ideas you
need before changing it. Every other page in this section goes deeper into one part of the
picture.
This section is written for engineers contributing to pulse-web. If you only want to integrate Pulse into an application, start with the Integration Guide.
What the web SDK does
Pulse is Picsart's first-party analytics platform. In the browser, the SDK has three jobs:
- Tracking. It collects events and user attributes, and sends them in batches to
event-receiver, the ingestion service. Events come from your code (
pulse.event(...)), from thedataLayer, or from the SDK's built-in plugins. - Remote settings. It fetches feature flags, experiments and segments from optifyr and caches them. The values are authored in the Tools portal.
- Self-delivery. A Cloudflare Worker serves the SDK itself, and personalises the bundle for every request. The SDK can also load Autopilot, the session recorder.
The same code base also ships wrappers for other environments. @pulse/react runs in the
browser, and @pulse/core works both in the browser and on the server. @pulse/server, @pulse/express and @pulse/nest run on the server, and
@pulse/mini runs in miniapps.
System map
Tools portal (picsart.tools)
authors ───────────────┬───────────────────────┬──────────── views
event definitions │ settings, experiments │ live debugger · session replay
│ │ segments │ ▲ ▲
▼ ▼ │ │ │
event-definition optifyr ◄── GET /web/v2/{app}/{tag} websocket session-replay
│ ▲ (settings) │ gateway API
│ │ │ ▲ ▲
▼ │ │ debug stream Kafka
event-receiver ◄───────┼── POST t.picsart.com/events/v2/web/{app}/fetch │
validate · enrich │ (events + attributes) replay-receiver
→ Kafka topics │ ▲
│ │ replay metadata
page ─ <script module | nomodule src=optifyr.com/pulse/{app}/…/pulse.js> │
│ Cloudflare Worker (apps/server): segments → config → bundle │
▼ │
window.pulse (tracker + settings) ── getState() / getState$() ──► Autopilot
gzip batches → CDN
Each arrow is a contract: a URL, payload shape, message type or storage key that another system depends on. The Cross-Repository Contracts page lists them all.
A page load, step by step
- The page adds two script tags. It includes the module and nomodule tags for
https://optifyr.com/pulse/{app}/…/pulse.js, either written by hand or injected by@pulse/browser'sinitialize(). Modern browsers load theesnextbundle and older browsers load thees5one. - The Worker personalises the bundle. It works out which segments the request belongs to, based on origin, country and query parameters. From those segments it builds the configuration: consent provider, settings URL, debug keys, batch sizes and so on. It then returns the full bundle with that configuration embedded.
- Calls wait for the SDK. Any calls the page made on
window.pulsebefore the bundle arrived have been waiting in a queue. - The bundle boots. It starts its modules with Pulse's small dependency-injection
container, merges the Worker config with the page's own config, builds the
pulseinstance, and replays the queued calls in order. - The SDK starts working. The tracker restores identity (
device_id,session_id,tab_id), checks consent and starts its plugins. That producessession_startandnavigationevents, clicks and so on. The settings module servespulse.get(...)andpulse.observe(...)from its cache, and refreshes values from optifyr. - Events are sent in batches. Events are persisted to IndexedDB, grouped into batches of 60 KB or less, and sent to event-receiver. If the page is closed mid-flight, the next page load resends them.
- Autopilot may load. If Autopilot is enabled for the page and consent allows it, Pulse loads it. Autopilot reads the Pulse session to label its recording.
Core concepts
These ten ideas explain most of the design. Each links to the page that covers it in depth.
| # | Concept | In short | Read more |
|---|---|---|---|
| 1 | One global, many entry points | window.pulse is a callable proxy. Calls made before the SDK loads are queued and replayed in order. Every integration goes through it. | SDK Delivery |
| 2 | The Worker serves the SDK itself | There is no separate loader. The Worker returns the complete bundle with per-request config embedded. Client config overrides server config. | SDK Delivery |
| 3 | A small custom DI container | PulseModule composes modules one level deep. All modules share one merged configuration object. | Packages and DI |
| 4 | State header plus events | Events are never sent on their own. They are grouped under the state (header) they were created with, and event-receiver merges the header into every event. | Event Pipeline |
| 5 | Durable delivery | IndexedDB, then batches of 60 KB or less, then a byte-budgeted queue, then fetch keepalive, sendBeacon or XHR. Retries use backoff, and unsent batches survive a reload. | Event Pipeline |
| 6 | Stable identity formats | device_id looks like a.<c or m>.<base36 time>.<uuid>, and session_id is <start ms>_<device_id>. Other systems parse both. | Identity and State |
| 7 | Cross-tab by default | Every tab runs its own copy of every service over shared storage. Storage change streams, locks and BroadcastChannel keep the tabs consistent. | Identity and State |
| 8 | Consent gates collection | Nothing is queued without tracking consent. The only exception is consent_change itself. Autopilot also follows consent. | Identity and State |
| 9 | Modes change configuration, not code | standard, anonymous and page-session only switch storage types and enrichment flags. | Identity and State |
| 10 | Other systems depend on the SDK silently | event-receiver strips undeclared fields and still answers 200. The debugger and Autopilot depend on exact field names. | Ecosystem, Contracts |
The monorepo at a glance
apps/server Cloudflare Worker: serves the personalised bundles from KV
packages/
common DI container, configuration models, global proxy (no browser code)
browser/
shared storage, cookies, locks, HTTP, device/tab id, state, IndexedDB
tracker-shared plugin & pipe hosts, RUM collectors
tracker the full tracker: session, queue, batching, built-in events
settings remote settings, experiments, segments, cookie sync
mini lightweight tracker for miniapps
loader bundle entry points (all-in-one, mini) and the Autopilot loader
public/
browser, core, react browser wrappers
server, express, nest server-side SDK and middleware
mini npm wrapper for the mini tracker
testing/ JSDOM and Playwright test harnesses
The layering is strict. Lower packages never import higher ones. tracker, settings and
mini never import each other; the loader package is the only place where they are combined.
See Packages and DI.
Before you change something
Ask yourself these questions. Most past regressions came from one of them.
- Does another repository read this? Examples are a header or event field, a query
parameter, a storage or cookie key, a
postMessagetype, awindowglobal or a URL. If so, follow Cross-Repository Contracts. - Does it run during bootstrap? Slow work in module init delays the tracker. That regenerates sessions and breaks the e2e tests.
- What happens with two tabs open? Also consider no localStorage, a hanging IndexedDB, and consent turned off.
- Can it fire an event twice? Duplicate
session_start,navigationandconsent_changeevents are the most common bug class. - Does it add weight to every page? Every byte of browser code ships on every page load, and it must also work in the ES5 build.
The Design Decisions and Common Pitfalls page explains the history behind each of these rules.
Pages in this section
| Page | What it covers |
|---|---|
| Core Concepts | Messages, state, sessions, collectors, pipes, plugins, services, chunks and batches, visibility, tabs, location history, storage |
| Monorepo Packages and Dependency Injection | Package layers, the DI container, how configuration is merged |
| SDK Delivery | The Worker, bundles and KV, ES5/ESNext, the global proxy, public wrappers |
| Event Pipeline | From pulse.event() to the server: pipes, plugins, queues, batching, transport |
| Identity, State, Consent and Modes | Device, session and tab ids, the state header, attributes, consent, modes, debug keys |
| Settings Internals | Fetching, caching, experiments, segments, preview mode, the pulse-state cookie |
| The Pulse Ecosystem | What event-receiver, the Tools portal, Autopilot and replay-receiver do with Pulse data |
| Cross-Repository Contracts | The things that must change in several repositories together |
| Contributing to pulse-web | Build, tests, CI, releases, conventions and the review checklist |
| Design Decisions and Common Pitfalls | Settled decisions, design history, recurring bug classes |