Use this document to understand how the Optimization SDK Suite keeps a visitor profile continuous when a Node server and a browser client both participate in personalization and analytics. It explains the difference between profile identity and profile data, how each SDK runtime stores that state, and which application boundaries still belong to your implementation.
identify()Profile synchronization applies differently across SDK runtimes:
| Runtime | Applicability |
|---|---|
| Node SDK | Stateless server runtime. It uses forRequest() to bind consent, locale, event context, and the current profile ID for one request. It does not store cookies or long-lived profile state. |
| Web SDK | Stateful browser runtime. It can use localStorage and the readable ctfl-opt-aid cookie for durable browser-server continuity when persistence consent resolves to true. |
| React Web SDK | React wrapper around the Web SDK. Providers and hooks share the same browser state, localStorage, cookie, and persistence-consent mechanics as the Web SDK. |
| Next.js adapter | Hybrid runtime. Server helpers bind request-scoped Node clients. Server helpers and ESR persistence own ctfl-opt-aid writes and clears. The request handler only forwards sanitized request context headers. Client components continue through the Web or React Web SDK state. |
| React Native and mobile SDKs | React Native uses AsyncStorage, and native mobile SDKs use platform storage instead of browser cookies. They can continue mobile profiles through the Experience API, but they do not provide a built-in ctfl-opt-aid cookie handoff to a Node server. |
Profile synchronization is event-driven. The SDKs do not run a continuous replication protocol between server memory, browser memory, cookies, localStorage, and the Experience API.
Instead, synchronization uses three contracts:
ctfl-opt-aid cookie, also
exported as ANONYMOUS_ID_COOKIE, so both runtimes send future Experience events to the same
profile.page(), identify(), screen(), track(), and
sticky trackView() return event results whose data contains profile,
selectedOptimizations, and changes when the event is accepted and data is available.The Experience API remains the source of truth for profile aggregation. The shared cookie is a continuity handle, not a full profile record.
Server request reads ctfl-opt-aid
-> Node SDK sends an Experience event with profileId
-> Experience API returns profile, selectedOptimizations, and changes
-> Server renders or persists the returned profile ID
-> Browser initializes from cookie and localStorage when persistence consent is true
-> Web SDK sends later Experience events with the same profile ID
-> Experience API returns updated profile data
-> Web SDK updates signals, plus localStorage and ctfl-opt-aid when persistence consent is true
Profile synchronization depends on runtime policy and storage state before an SDK sends or resumes profile-changing events:
allowedEventTypes permits that event when event consent is unset or false.ctfl-opt-aid load or write only when persistence consent resolves to true. Configured defaults
can seed in-memory state without enabling durable storage.defaults.profile, defaults.selectedOptimizations,
defaults.changes, and consent defaults bootstrap one runtime. They do not synchronize server and
client state unless both runtimes also use the same profile ID or the same Experience API
response. In React Web and Next.js handoff, keep defaults for configuration or default state
such as consent policy, and pass server-returned Optimization data through
serverOptimizationState when the provider or root receives the data directly. In Next.js, render
NextjsOptimizationState only as a page-level marker under existing SDK context.For consent gates, see Consent management in the Optimization SDK Suite. For state shape and observable state mechanics, see Core state management.
The server, browser, and API each own different parts of the profile lifecycle:
| Runtime or layer | Owns | Does not own |
|---|---|---|
| Experience API | Profile creation, profile updates, audience evaluation, selected optimizations, and changes. | Application consent policy, cookie settings, Contentful fetching, rendering, or response caching. |
| Node SDK | Stateless Experience and Insights calls using request-scoped profile IDs and request options. | Cookies, sessions, long-lived profile state, browser storage, or consent state. |
| Web SDK and React Web SDK | Browser state, consent state, localStorage caches, readable anonymous-ID cookie, and queues. | Server sessions, server response caching, server-rendered hydration data, or Contentful fetching. |
| Application | Consent policy, identity policy, cookie attributes, request context, and cache boundaries. | The internal profile aggregation rules of the Experience API. |
The React Web SDK uses the Web SDK under its providers and hooks, so profile synchronization follows the same browser mechanics.
React Native is also stateful, but it persists with AsyncStorage instead of browser cookies. It can continue a mobile profile through the Experience API, but this repository does not provide a built-in cookie handoff between a React Native app and a Node server.
Choose the server-rendering path before implementing a hybrid flow, because the choice determines
whether the browser needs only the shared profile ID or a bootstrapped OptimizationData snapshot.
The shared cookie is enough when the browser performs personalization after hydration. It is not enough when the server already rendered profile-derived HTML and the browser must continue from the same evaluated data before its first client-side Experience response.
| Path | Use when | Browser startup contract |
|---|---|---|
| Server owns the first render | The server renders selected variants and profile-derived values, and the client can wait for fresh SDK data before re-resolving. | Persist ctfl-opt-aid when allowed, and prevent stale browser caches from driving visible personalized content before a later Experience response. |
| Server bootstraps the browser | The client must continue from the same evaluated data before its first browser Experience response. | For direct Web SDK initialization, serialize the server's profile, selectedOptimizations, and changes into defaults. For React Web and Next.js direct provider handoff, pass the server OptimizationData through serverOptimizationState. For Next.js page-level handoff, render NextjsOptimizationState under existing SDK context. |
| Browser owns personalization | The server can render baseline or loading output while the client resolves personalization after hydration. | Persist ctfl-opt-aid when allowed, then let the Web SDK call page() and resolve entries after selected optimizations are available. |
Direct Web SDK bootstrapping must use the same OptimizationData response that drove the server
render:
const optimization = new ContentfulOptimization({
clientId: 'your-client-id',
environment: 'main',
defaults: {
profile: window.__OPTIMIZATION_DATA__.profile,
selectedOptimizations: window.__OPTIMIZATION_DATA__.selectedOptimizations,
changes: window.__OPTIMIZATION_DATA__.changes,
},
})
If the browser re-resolves entries from stale localStorage while the server rendered from a newer
profile evaluation, the user can see a mismatched variant or profile-derived value. For direct Web
SDK initialization, use explicit defaults. For React Web and Next.js, pass the server
OptimizationData to serverOptimizationState when the provider or root receives the data
directly, or render NextjsOptimizationState under an existing SDK context when a Next.js page owns
the data. A fresh client-side page() response or a render boundary can also prevent stale cached
state from driving visible content.
Personalized HTML is not shared-cache safe unless the cache varies on all personalization inputs. Raw Contentful entries are the safer cache boundary; resolve variants per request or per profile selection.
Profile identity and profile data are separate artifacts:
| Artifact | Shape | Purpose | Crosses the client-server boundary? |
|---|---|---|---|
| Profile ID | String | Selects which Experience API profile to create or update. | Yes, commonly through ctfl-opt-aid. |
| Partial profile payload | { id: string, ... } |
Lets stateless calls associate an event with an existing profile. | Yes, but the ID is the important continuity field. |
| Full profile | Profile |
Represents the evaluated profile returned by the Experience API. Includes traits and audiences. | Only if the application intentionally serializes it into output. |
| Selected optimizations | SelectedOptimizationArray |
Drives entry variant resolution for the evaluated profile. | Only if server-rendered output or client hydration needs it. |
| Changes | ChangeArray |
Drives Custom Flag values. | Only if server-rendered output or client hydration needs it. |
| Event consent state | boolean | undefined |
Controls whether gated events leave a stateful client or server request client. | Application-defined. |
| Persistence consent state | boolean | undefined |
Controls whether durable profile-continuity state can be loaded, written, or persisted. | Application-defined. |
The full Profile object can contain traits, audience memberships, location data, and session
statistics. Treat it as profile data, not as a general client-visible session token. In a hybrid
browser and server application, the durable shared value is normally the profile ID.
Merge tags resolve from the active profile. They do not read values from changes.
All profile-changing Experience paths converge on upsertProfile():
profileId is provided, the API client creates a profile with POST /profiles.profileId is provided, the API client updates that profile with POST /profiles/:id.OptimizationData with profile, selectedOptimizations, and
changes, then surfaced as event result data.The Experience API client also exposes getProfile(id) for reading the current evaluated profile
data without sending an event. That read path can refresh application state, but it does not replace
the event-driven synchronization flow used by page(), identify(), screen(), track(), and
sticky trackView().
Stateful SDKs write that response to in-memory signals as a single batch. This means subscribers see the profile, selected optimizations, and changes as one consistent snapshot after any state interceptors complete.
Web and React Web read browser storage during initialization when persistence consent allows it. After Core publishes in-memory state changes, Web signal effects mirror profile-continuity state to localStorage and the anonymous-ID cookie on a best-effort basis. Browser subscribers do not wait for a separate durable write before observing the new in-memory snapshot.
React Native reads AsyncStorage during async initialization when persistence consent allows it. Its state persistence interceptor awaits the AsyncStorage write for an accepted Experience response before Core publishes that response snapshot to observable state.
iOS and Android read platform storage before initializing the bridge when persistence consent allows
it. Their native bridge state handlers write the received snapshot through UserDefaults or
SharedPreferences when persistence consent is true, then update native observable state from that
snapshot.
Stateless Experience methods and trackView() return event results with accepted and optional
data. Non-sticky trackView() resolves with no data; trackClick(), trackHover(), and
trackFlagView() resolve without an event-result payload. Those Insights-only paths require a
request-bound profile because there is no ambient SDK state. The caller decides what to render, what
to persist, and what to pass into later SDK calls.
The Node SDK extends the stateless Core runtime. Create the Node SDK once per process or module, but
bind request-scoped inputs with forRequest() before calling event methods.
In Node, Experience methods use the request-bound profile ID as the profile selector:
const appLocale = getAppLocale(req)
const profileId = req.cookies[ANONYMOUS_ID_COOKIE]
const requestOptimization = optimization.forRequest({
consent: true,
locale: appLocale,
profile: profileId ? { id: profileId } : undefined,
})
const { accepted, data: optimizationData } = await requestOptimization.page({
properties: { path: req.path },
})
For the difference between the application Contentful locale and the SDK Experience/event locale, see Locale handling in the Optimization SDK Suite.
The Node SDK passes that ID to the Experience API as profileId. If the ID is absent, the API can
create a profile and return the new profile.id.
Insights-only stateless methods need a request-bound profile because there is no ambient state. For
example, non-sticky trackView(), trackClick(), trackHover(), and trackFlagView() require a
profile ID passed to forRequest(). Sticky trackView() first sends an Experience event and can
use the returned profile for the paired Insights event.
The server must derive these values per request:
ANONYMOUS_ID_COOKIE or an application session.The Node SDK does not keep those values between requests. This avoids cross-request state leakage in long-lived Node processes and serverless runtimes.
Consent policy belongs to the application layer on the server. A conservative server policy is:
profile.id.profile.id.If the server uses preflight: true, the Experience API evaluates a profile state without storing
the mutation. Use that for preview or evaluation flows, not as the normal continuity path for a
profile ID you intend to persist.
The Web SDK extends the stateful Core runtime. It owns browser-specific persistence and event
delivery around the same profile, selectedOptimizations, and changes signals described in
Core state management.
On construction, the Web SDK first resolves event consent and persistence consent. It loads durable
profile-continuity state and anonymous-ID cookies only when persistence consent resolves to true.
The effective initialization order is:
defaults, persisted LocalStore values,
and legacy accepted consent.defaults supplied in the SDK configuration.LocalStore only when the
resolved persistence consent is true. Explicit defaults.profile, defaults.changes, and
defaults.selectedOptimizations can still seed in-memory state for that SDK instance.true.LocalStore.anonymousId, seed the anonymous ID from the
cookie. The SDK clears active and cached profile-continuity state before seeding only when that
cookie also differs from the active profile.id, where an active profile exists.This order lets the browser resume from localStorage when durable profile-continuity persistence is allowed and there is no server handoff, while still letting a server-set cookie take precedence when a hybrid request identifies a different profile. If persistence consent is granted after construction, the Web SDK runs the cookie adoption check at that time.
The Web SDK exposes browser persistence through stable behavior, not localStorage key names.
Application code must treat exact keys as diagnostic-only implementation details and use SDK APIs,
configuration defaults, and ANONYMOUS_ID_COOKIE instead of reading or writing SDK localStorage
entries.
| Contract | Stored state | Integration boundary |
|---|---|---|
| Consent and debug storage | SDK event consent, SDK persistence consent, and debug logging preference. | Persistence consent does not disable this policy and diagnostic storage. Set it through defaults, consent(...), and debug configuration. |
| Profile-continuity storage | Anonymous profile ID, returned Profile, selected optimizations, and Custom Flag changes. |
Loads and writes only when persistence consent resolves to true; clears durable continuity when persistence consent becomes false. |
| Shared cookie | ctfl-opt-aid, exported as ANONYMOUS_ID_COOKIE, stores the profile ID for browser-server handoff. |
Built-in browser-server continuity channel when persistence consent is true; keep it browser-readable when the Web SDK must adopt it. |
| Legacy migration | Legacy anonymous-ID browser storage or cookie values. | The SDK can migrate or remove legacy values during initialization. Do not depend on legacy names in application code. |
During diagnostics, you might see localStorage entries such as __ctfl_opt_consent__,
__ctfl_opt_persistence_consent__, __ctfl_opt_debug__, __ctfl_opt_anonymous_id__,
__ctfl_opt_profile__, __ctfl_opt_selected-optimizations__, and __ctfl_opt_changes__. These
names and serialized shapes are implementation details, not values application code reads or writes.
The app-personalization-consent name appears in examples only as an application-owned consent
cookie ('granted', 'denied', or absent) that server and browser code can read. It is not an SDK
localStorage key.
Structured cache values are schema-validated when they are read. Malformed JSON or invalid shapes are removed instead of being used as SDK state.
When persistence consent is true, the browser also persists the profile ID in the ctfl-opt-aid
cookie. This cookie is the only built-in browser-server synchronization channel.
Persistence consent controls whether the SDK writes and reloads profile-continuity state: anonymous
ID, profile, selected optimizations, changes, and the ctfl-opt-aid cookie. It does not disable SDK
storage of consent or debug settings.
Browser persistence is best-effort and follows Web SDK state. When persistence consent is true,
Web signal effects mirror profile, selected optimizations, changes, and anonymous ID to localStorage
and the readable cookie after Core state changes. Live reads come from memory; localStorage and the
cookie are startup and server-handoff durability channels.
The Web SDK keeps the cookie and localStorage anonymous ID aligned when persistence consent is
true:
ctfl-opt-aid cookie overrides a different local anonymous ID
only when persistence consent resolves to true. The SDK calls reset() to clear active and
cached profile-continuity state only when the cookie also differs from the active profile.id,
where an active profile exists. If the cookie matches the active profile, the SDK preserves active
state and writes the cookie value to localStorage. If persistence consent is granted later, the
same cookie adoption check runs then.profile.id to both localStorage and the ctfl-opt-aid cookie.ctfl-opt-aid cookie for future
continuity. Use reset() or withdraw persistence consent when the application needs to end that
continuity.ctfl-opt-aid.ntaid exists, the SDK migrates its value and removes the legacy cookie.For a hybrid Node and browser application, do not mark ctfl-opt-aid as HttpOnly. The Web SDK
must be able to read the cookie during initialization. Server or Next.js response code owns
response-cookie policy such as SameSite, Secure, path, domain, and readability. Configure
those attributes so the server route and browser code can access the same cookie. The Web SDK
browser writer supports its cookie.domain and cookie.expires options, always writes Path=/,
and does not expose SameSite or Secure controls for the browser-side write.
In the browser, Experience events select the profile with this priority:
getAnonymousId(), which by default returns LocalStore.anonymousId only when persistence
consent is true.profile.id.Stateful browser methods accept a profile field in some payload types for API consistency, but the
synchronization channel is the stateful profile ID selected by the queue. In normal Web SDK usage,
seed or override browser identity through the shared cookie, localStorage, defaults, or
getAnonymousId, not by trying to pass a one-off profile object to every stateful call.
Insights events in the browser use the current profile signal. If no profile exists, the Insights queue logs a warning and skips delivery.
A Node and Web SDK application usually follows this lifecycle:
ctfl-opt-aid cookie. If the application's consent
policy permits the server event, the server calls page() or identify() without a profile ID.
The Experience API creates or evaluates a profile.profile.id in ctfl-opt-aid with
path: '/' and a readable cookie policy for browser code when the application's persistence
consent policy permits durable continuity.true. If localStorage has a different anonymous ID or cached profile, the SDK clears the old
profile-continuity state and adopts the cookie ID.page(), router events, identify(), track(), and sticky
trackView() calls update the same Experience API profile.ctfl-opt-aid cookie only when persistence consent is true.{ profile: { id } } into
the next stateless SDK call.The node-sdk+web-sdk reference implementation demonstrates this flow by setting
ANONYMOUS_ID_COOKIE on the server and verifying that the browser localStorage anonymous ID matches
the cookie.
identify()identify() adds a known userId and optional traits to the profile flow. It does not replace the
application's authentication session. The user ID comes from your application identity system, while
the profile ID continues to select the Experience API profile being updated.
On the server, pass the current anonymous profile ID into the request-bound client. If the page view
must be attributed to the known user, call identify() before page() on that same client:
const appLocale = getAppLocale(req)
const profile = anonymousId ? { id: anonymousId } : undefined
const requestOptimization = optimization.forRequest({
consent: true,
locale: appLocale,
profile,
})
const identifyResult = await requestOptimization.identify({
userId,
traits: { authenticated: true },
})
const pageResult = await requestOptimization.page({
properties: { path: req.path },
})
Then render and persist from the response that matches the user state you want for that response:
pageResult when the page response drives the render, or identifyResult when identification is
the last Experience response for the request. The request-bound client updates its internal profile
after each accepted Experience response, so the later page() call uses the profile returned by
identify(). If identify() and page() happen in different request-bound clients, bind the
returned profile into the later client with forRequest({ profile }), or persist profile.id and
bind { id } on the later request. Do not pass a profile into page().
If the request arrived anonymous but the response must include identified traits, render from the
identify() response or from a later page() response that used the identified request-bound
profile.
In the browser, call identify() after the Web SDK has adopted the shared anonymous ID. The
subsequent Experience response updates the same stateful profile and rewrites the browser caches.
Different lifecycle methods have different synchronization effects:
| Operation | Effect |
|---|---|
optimization.reset() in Web |
Clears profile, selected optimizations, changes, event streams, anonymous ID localStorage, and cookie. |
LocalStore.reset() default |
Clears profile-related browser caches and anonymous ID, but preserves consent and debug values. |
optimization.consent({ persistence: false }) |
Clears SDK-managed durable profile-continuity storage and cookies, but leaves active in-memory profile state available until reset or teardown. |
optimization.consent(false) |
Sets event and persistence consent to false, purges SDK queues, clears SDK-managed durable profile-continuity storage, and re-applies the allow-list gate. |
optimization.destroy() |
Flushes queues and releases runtime listeners. It does not clear persisted user state. |
| Server consent revocation | Must clear the application-owned consent state and any persisted profile ID such as ctfl-opt-aid. |
consent(false) leaves the active in-memory SDK profile, selected optimizations, and changes
available until the application calls reset() or tears down the runtime. Future event calls emit
only when their event type remains permitted by allowedEventTypes. If consent revocation must also
remove active session personalization, call reset() in the browser and clear the server-side
cookie or session value in the same user flow.
The following cases are common sources of profile-sync bugs:
| Case | What happens | Mitigation |
|---|---|---|
ctfl-opt-aid is HttpOnly |
The server can read it, but the Web SDK cannot adopt it. | Use a readable cookie for hybrid Node and Web SDK continuity. |
| Cookie domain or path mismatch | The browser and server use different profile IDs or no shared ID. | Set path: '/' and a domain that covers the pages that initialize the Web SDK. |
| Cookie differs from localStorage | The Web SDK clears cached profile-continuity data and adopts the cookie ID when persistence consent is true. |
Treat this as expected when the server changes identity. |
| Cookie changes after SDK construction | The running Web SDK does not continuously watch cookies. | Reinitialize intentionally after teardown or update identity through SDK event flows. |
| Multiple browser tabs | Tabs share storage, but in-memory signals are per runtime and do not auto-sync from storage events. | Let each tab refresh state through Experience events or reload-sensitive application flows. |
| Offline browser Experience events | Events queue locally and no new profile data is available until a successful flush. | Design UI so cached selections are acceptable while offline. |
| Missing browser profile for Insights | Insights delivery is skipped because stateful Insights events use the current profile signal. | Ensure an Experience call has returned a profile before relying on Insights-only tracking. For direct Web SDK initialization, bootstrap a valid defaults.profile when the server already evaluated the profile. For React Web and Next.js direct provider handoff, use serverOptimizationState. For Next.js page-level handoff, use NextjsOptimizationState under existing SDK context. |
Server uses preflight for normal flows |
The API evaluates without storing the mutation, which breaks durable profile continuity expectations. | Reserve preflight for preview or non-persistent evaluation. |
| Full profile serialized unnecessarily | More profile data reaches the browser than the UI needs. | Share only the profile ID unless hydration needs profile data, changes, or selections. |
Use this checklist when implementing a hybrid Node and browser profile flow:
ANONYMOUS_ID_COOKIE (ctfl-opt-aid) on the server after an Experience response returns
profile.id and the application's persistence consent policy allows durable continuity.path: '/', an appropriate domain, and a browser-readable cookie policy when the Web SDK must
continue the same profile.{ profile: { id } } into Node SDK Experience calls when a cookie or session ID exists.identify() before page() on the same Node request-bound client when the page view must be
attributed to a known user.true before expecting the Web SDK to load persisted
profile-continuity state or adopt ctfl-opt-aid.OptimizationData response that matches the current identity state.defaults, use React Web or Next.js serverOptimizationState for direct
provider handoff, or use NextjsOptimizationState under existing Next.js SDK context for
page-level handoff, when server-rendered personalized output must match client-side resolution
before the first browser Experience response.