Skip to main content

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.

caution

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.

InputRule
Base deadlineRTT × 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: [...] }
  • settingsBaseURL defaults to https://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|stage overrides 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/server calls 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:

GroupInputs
Request scopetag, user role, is_test
Identityuser_id, tier_id, device_last_tier_id
Previewthe debug (preview) experiment
Clientattributes, 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: LockManager keyed 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, ur or is-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.

Cache invalidation versus request storms

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:

EventWhen it fires
experiment_populateFor trackable experiments, once the SDK learns about them from a settings response
experiment_participateLazily, 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.

note

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:

  1. ResponseTrackerService.trackResponse pushes { pulse: 'state', segments, experiments } to the dataLayer, with only trackable experiments included.
  2. DataLayerPlugin in the tracker turns that entry into tracker.set().
  3. segments and experiments end 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
  1. 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 (buildDebugURL in tools-portal-front/src/app/main/experiments/experiment-utils.ts).
  2. The SDK checks and stores it. It ignores the link when the app in the tuple is not config.app, so the portal's app.bundle must equal the SDK app. Otherwise it stores the tuple at paa_debug-exp. Either way it removes the parameter from the URL.
  3. 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.
  4. 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.
  5. 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.

services/cookie-sync.service.ts writes a pulse-state cookie on the apex domain with path /. It holds 16 pipe-separated fields:

GroupFields
IdentitydeviceId, userId, isTest
Experimentsexperiments, debugExperiments
Traffic sourcereferrer, internalReferrer, UTM fields, clickId, clickIdSrc
TargetingtierId, 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/server reads it to personalise SSR responses with the same inputs the browser would send to optifyr.
  • Device id recovery. DeviceIdService can 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.

Contract

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​

ConcernLocation
Module wiring and configpackages/browser/settings/src/settings.module.ts, packages/browser/settings/src/configuration.ts
Public API contractpackages/common/src/sdks/settings/
get / observe implementationpackages/browser/settings/src/services/settings/settings.service.ts
Fetch requestpackages/browser/settings/src/services/settings/settings-fetch.helper.ts
Adaptive timeoutpackages/browser/settings/src/services/settings/adaptive-timeout.service.ts, packages/browser/shared/src/services/network/network-quality.service.ts
Revalidation triggerspackages/browser/settings/src/services/settings/settings-observer.helper.ts
Cache and broadcastpackages/browser/settings/src/services/cache.service.ts
Request deduplication and lockspackages/browser/settings/src/services/settings/settings-observer.helper.ts
Experiments and participationpackages/browser/settings/src/services/experiments.service.ts, packages/browser/settings/src/services/usage-tracker.service.ts
Segmentspackages/browser/settings/src/services/segments-resolver.service.ts
Response into tracker statepackages/browser/settings/src/services/response-tracker.service.ts, packages/browser/tracker/src/plugins/data-layer.plugin.ts
Experiment previewpackages/browser/settings/src/services/experiment-debug.service.ts, tools-portal-front/src/app/main/experiments/experiment-utils.ts
pulse-state cookiepackages/browser/settings/src/services/cookie-sync.service.ts, packages/common/src/context/state.ts