Settings, Experiments and Segments Internals
Definition
This document explains how the settings module (packages/browser/settings, SettingsModule) fetches, caches and revalidates remote configuration, and how experiments, segments, experiment preview mode and the pulse-state cookie are built on top of it. It is written for engineers changing the settings SDK, not for integrators. For the integrator-facing side of segmentation see User Role based Segmentation, Segmentation based on provided attributes and is-test Flag.
Where settings come from
The settings SDK reads remote configuration from optifyr. That configuration is authored in tools-portal-front through abs-config (see The Pulse Ecosystem). How abs-config publishes to optifyr is not visible in any of the Pulse repositories, so treat optifyr as the contract boundary: the SDK only knows the HTTP request it sends and the JSON it gets back.
tools-portal-front ──► abs-config ──► (publish, not visible here) ──► optifyr
▲
GET /web/v2/{app}/{tag}?... │
window.pulse.get / observe ──► SettingsService ──► cache miss? ──────────┘
▲ │
└──── memory mirror ◄────┴──► IndexedDB pulseDBN.settingsStore
Public API and argument order
pulse.get(tag, settingName, defaultValue?, attributes?, remoteSettingEnabled = true) // Promise
pulse.observe(tag, settingName, defaultValue?, attributes?, remoteSettingEnabled = true) // Observable
pulse.segments()
pulse.experiments()
The first argument is the tag, which becomes part of the URL path; the second is the setting name. The abstract types in packages/common/src/sdks/settings/ and the global proxy name these parameters (name, tag), but they forward arguments positionally, so the names there are misleading. The order that actually counts is the implementation's (tag, settingName), which @pulse/react and most examples use.
When you add or rename a parameter, change it positionally in every layer (abstract type, proxy, implementation, React hooks). A mismatch in names is harmless today only because nothing reads the names.
observe and the adaptive timeout
The first emission of observe races an adaptive timeout. If the network response loses the race, the observer emits the default value; later emissions are revalidations with the real value.
| Input | Rule |
|---|---|
| Base deadline | RTT × 6, or TTFB × 2 |
| Clamp | [minSettingsGetTimeout = 1500, maxSettingsGetTimeout = 5000] ms |
| Padding | + 100 ms |
The adaptive deadline replaced a fixed 1500 ms deadline, which roughly 4–5% of requests missed, so those users always saw defaults on first render. Network quality is measured by network-quality.service.ts in @pulse/browser-shared; the deadline itself is computed in adaptive-timeout.service.ts.
segments() and experiments()
Both read from what previous settings responses stored. If nothing is cached yet, they trigger a fetch with a dummy tag, because every settings response carries the full segment and experiment lists regardless of which tag was asked for.
The fetch request
The request is built in services/settings/settings-fetch.helper.ts:
GET {settingsBaseURL}/{app}/{tag}
?did=&uid=&tid=&dltid=&ur=&is-test=&versioncode=&exp=&debug-exp=
&ref=&cidp=&usrc=&umed=&ucmp=&ucnt=&attr-<k>=… (attributes sent only when there are 10 or fewer)
credentials: 'include'
fetch priority: 'high'
abort after 20 s
→ { settings: { name: value }, experiments: [...], segments: [...] }
settingsBaseURLdefaults tohttps://optifyr.com/web/v2. The Worker points non-production segments at the stage host and the local dev server at the dev host (see SDK Delivery). A page URL parameter?pa_env=prod|stageoverrides the host at runtime.- Identity and targeting inputs (
did,uid,tid,dltid,ur,is-test, UTM and referrer fields, attributes) are what optifyr segments on. Each is described from the integrator side in the segmentation pages linked above. @pulse/servercalls the same endpoint as{settings.host}/web/v2/{app}/{tag}, so a change to the query contract affects server-side rendering too.
The cache
The cache lives in services/cache.service.ts.
Storage layout
There is one IndexedDB record per setting name in pulseDBN.settingsStore:
{ id, [name]: value, time, tag, attributesHash, emptyForTags }
An in-memory mirror of the store is loaded at start, and reads are served from memory. IndexedDB is the durable copy and the cross-tab source of truth; the mirror keeps get from paying an IndexedDB round trip.
Expiry and the attributes hash
A record expires after cacheTimeout (10 minutes) or when its attributes hash no longer matches the current one. The hash covers every input that can change the server's answer:
| Group | Inputs |
|---|---|
| Request scope | tag, user role, is_test |
| Identity | user_id, tier_id, device_last_tier_id |
| Preview | the debug (preview) experiment |
| Client | attributes, app version |
This is why the integrator pages say that a different ur, is-test or attribute set clears the cache: the hash changes and the record is treated as stale.
Request deduplication
Two layers keep concurrent reads from turning into concurrent requests:
- Same tab: an in-flight lock list keyed by the attributes hash, so parallel reads with the same inputs wait for one request.
- Across tabs:
LockManagerkeyed by the attributes hash. A tab that finds the lock held waits for the holder and then reads the value from the cache instead of fetching.
A waiter's budget is the adaptive fetch deadline plus 250 ms of headroom. The headroom covers the lock poll interval and the cache write that follows a response; without it a waiter could give up in the same tick the holder resolved and fall back to defaults even though the settings had just arrived. Both layers live in settings-observer.helper.ts.
Cross-tab coherence
After a write the SDK broadcasts:
{ type: 'settings-cache-updated', tag }
on the BroadcastChannel pulse_bc_pulse-settings-cache, with a localStorage fallback where BroadcastChannel is not available. Receiving tabs reload their memory mirror from IndexedDB and revalidate their observers. Writes are batched into a single broadcast so that a response containing many settings does not produce a burst of messages.
Revalidation triggers
settings-observer.helper.ts revalidates active observers when:
- tracker state changes in
user_id,tier_id,device_last_tier_id,uroris-test; - the preview experiment changes.
Prefetch
?prefetch=tag1,tag2 on the pulse script URL prefetches those tags at boot, so the first get for them is usually a memory hit.
Every fix in this area trades freshness against request volume, and more than one change has caused a request loop — one reached about 150 requests per second with only two tabs open. Any new cache-busting input must be deduplicated (for example with distinctUntilChanged) and must not make every tab reload in response to every other tab's write.
Experiments
Experiments are stored in paa_experiments and written into the tracker header as:
infected: [{ id, variant, participation_timestamp, participation_session }]
Two built-in events describe the lifecycle:
| Event | When it fires |
|---|---|
experiment_populate | For trackable experiments, once the SDK learns about them from a settings response |
experiment_participate | Lazily, the first time code reads a setting value that belongs to an experiment |
Participation is lazy on purpose. UsageTrackerService (services/usage-tracker.service.ts) wraps object setting values in a Proxy with a get trap, so participation is recorded only when application code actually uses the value, not when the SDK merely fetched or cached it. This keeps experiment analysis honest: a user who never reached the feature is not counted as exposed.
The SDK sends experiment_participate directly. event-receiver still renames a legacy _experiment_participate to experiment_participate for older SDK versions (see The Pulse Ecosystem).
Segments
Segments are stored under paa_segments- followed by the first five characters of the device id (segments-resolver.service.ts) and read back by segments().
Every settings response is also pushed into tracker state, so segments and experiments travel with events:
ResponseTrackerService.trackResponsepushes{ pulse: 'state', segments, experiments }to the dataLayer, with only trackable experiments included.DataLayerPluginin the tracker turns that entry intotracker.set().segmentsandexperimentsend up in the chunk header of every subsequent batch (see Event Pipeline).
Test segments are authored in the portal with status isTest; the SDK sends is-test on the fetch so that optifyr can match them (see is-test Flag).
Experiment preview mode
Preview mode lets someone in the portal see a specific experiment variant on the live site. It is implemented in services/experiment-debug.service.ts.
tools-portal-front site running Pulse
────────────────── ──────────────────
"preview" link ──► ?pulse-debug-exp=JSON([app, experimentId, experimentName, variant, returnURL])
│
▼
app === config.app ? ── no ──► ignore (param still removed)
│ yes
▼
store tuple at paa_debug-exp, strip param from URL
│
▼
every settings fetch sends debug-exp={id}:{variant}
cache cleared, observers revalidate
│
▼
all tabs follow via storage change stream
"experiment" debug badge links back to returnURL
- The portal builds a link. Its "preview" action opens the site with
?pulse-debug-exp=carrying a JSON array of app, experiment id, experiment name, variant and return URL (buildDebugURLintools-portal-front/src/app/main/experiments/experiment-utils.ts). - The SDK checks and stores it. It ignores the link when the app in the tuple is not
config.app, so the portal'sapp.bundlemust equal the SDKapp. Otherwise it stores the tuple atpaa_debug-exp. Either way it removes the parameter from the URL. - Fetches carry the preview. Every settings fetch sends
debug-exp={id}:{variant}. Because the preview experiment is part of the attributes hash, the cache is invalidated and observers revalidate when the preview changes. - Every tab follows. Preview state is watched with
storage.change(…, StorageChangeOrigin.All), so all tabs switch together. An earlier per-tab design made tabs evict each other's cache entries. - A badge shows while it is active. The "experiment" debug badge links back to the return URL. Stopping the preview removes the key without reloading every tab.
The pulse-state cookie
services/cookie-sync.service.ts writes a pulse-state cookie on the apex domain with path /. It holds 16 pipe-separated fields:
| Group | Fields |
|---|---|
| Identity | deviceId, userId, isTest |
| Experiments | experiments, debugExperiments |
| Traffic source | referrer, internalReferrer, UTM fields, clickId, clickIdSrc |
| Targeting | tierId, deviceLastTierId, userRole, attributes |
Why it exists:
- Subdomains share identity. A user moving between subdomains keeps the same device and targeting inputs.
- Server-side personalisation.
@pulse/serverreads it to personalise SSR responses with the same inputs the browser would send to optifyr. - Device id recovery.
DeviceIdServicecan recover the device id from it when localStorage is empty (see Identity and State).
The SDK keeps the cookie small by pruning stale records and experiments that are no longer active.
packages/common/src/context/state.ts parses this cookie for both the browser and the server. Several SDK versions are live at the same time on the same apex domain, so changing the field count, order or meaning is a breaking change. Browser readers (parseStateCookie, used by DeviceIdService and cookie-sync.service.ts) reject any cookie that does not have exactly 16 fields, so even appending a field makes older SDKs ignore the cookie; only the server-side parseState reads positionally and tolerates extra fields. See Cross-Repo Contracts.
Where to look in the code
| Concern | Location |
|---|---|
| Module wiring and config | packages/browser/settings/src/settings.module.ts, packages/browser/settings/src/configuration.ts |
| Public API contract | packages/common/src/sdks/settings/ |
get / observe implementation | packages/browser/settings/src/services/settings/settings.service.ts |
| Fetch request | packages/browser/settings/src/services/settings/settings-fetch.helper.ts |
| Adaptive timeout | packages/browser/settings/src/services/settings/adaptive-timeout.service.ts, packages/browser/shared/src/services/network/network-quality.service.ts |
| Revalidation triggers | packages/browser/settings/src/services/settings/settings-observer.helper.ts |
| Cache and broadcast | packages/browser/settings/src/services/cache.service.ts |
| Request deduplication and locks | packages/browser/settings/src/services/settings/settings-observer.helper.ts |
| Experiments and participation | packages/browser/settings/src/services/experiments.service.ts, packages/browser/settings/src/services/usage-tracker.service.ts |
| Segments | packages/browser/settings/src/services/segments-resolver.service.ts |
| Response into tracker state | packages/browser/settings/src/services/response-tracker.service.ts, packages/browser/tracker/src/plugins/data-layer.plugin.ts |
| Experiment preview | packages/browser/settings/src/services/experiment-debug.service.ts, tools-portal-front/src/app/main/experiments/experiment-utils.ts |
pulse-state cookie | packages/browser/settings/src/services/cookie-sync.service.ts, packages/common/src/context/state.ts |