Use this document to understand how browser interaction tracking works in
@contentful/optimization-web and the React layer provided by @contentful/optimization-react-web.
It explains how entry views, clicks, hovers, Custom Flag views, page events, and custom events move
from browser behavior to Core event delivery.
For setup steps, see Integrating the Optimization Web SDK in a web app and Integrating the Optimization React Web SDK in a React app. For server-owned rendering, see Interaction tracking in Node and stateless environments.
Interaction tracking has two separate jobs:
The Web SDK owns browser detection for Contentful entry views, clicks, and hovers. Core owns event construction, consent checks, queues, profile state, and API delivery. React Web does not implement a separate tracking engine; it renders Web SDK tracking metadata and exposes the underlying Web SDK tracking API through React providers and hooks.
This split keeps the browser-specific code small. A view observer can focus on
IntersectionObserver, timers, and DOM lifecycle. Core can focus on whether a trackView() call is
allowed, which event type it becomes, and which queue receives it.
| Layer | Responsibility in interaction tracking |
|---|---|
@contentful/optimization-core |
Builds page, identify, track, entry view, entry click, entry hover, and Custom Flag view events. Applies consent gates. Queues Experience API and Insights API work. |
@contentful/optimization-web |
Initializes Core for a browser runtime. Persists consent, profile data, selected optimizations, and anonymous IDs. Discovers tracked DOM elements and observes interactions. |
@contentful/optimization-react-web |
Creates and tears down the Web SDK instance, resolves entries in React, emits data-ctfl-* attributes, exposes interactionTracking, and emits router-driven page() calls. |
The application still owns Contentful fetching, rendering policy, consent UX, identity policy, route
ownership, and any business event taxonomy passed to track().
Tracking events use two API paths:
| Path | Methods | Main effect |
|---|---|---|
| Experience API | page(), identify(), screen(), track(), sticky trackView() |
Evaluates or updates a profile and returns profile, selectedOptimizations, and changes. |
| Insights API | non-sticky trackView(), trackClick(), trackHover(), trackFlagView() |
Sends Analytics events for entry interactions and Custom Flag observations. |
The Web SDK normally starts a browser journey with page() or identify(). Those calls use the
Experience API and populate the state that later Insights events need for attribution. If an
Insights event is emitted before the stateful SDK has a profile, Core logs a warning and drops that
Insights event because there is no profile to attach to the batch.
The entry interaction methods map to these wire event types:
| SDK method | Wire type | Common source |
|---|---|---|
trackView() |
component |
IntersectionObserver entry view detection, or direct application call. |
trackClick() |
component_click |
Document click listener, or direct application call. |
trackHover() |
component_hover |
Pointer or mouse hover listener, or direct application call. |
trackFlagView() |
component with componentType: 'Variable' |
getFlag() and states.flag(name) reads from stateful Core. |
Sticky entry views touch both paths. When trackView({ sticky: true, ... }) is called, Core sends
the view through Experience first, then sends an Insights view event. Non-sticky views only use
Insights.
The Web SDK defaults allowedEventTypes to ['identify', 'page']. Until consent is accepted, Core
blocks non-allowed event types such as track, component, component_click, and
component_hover.
Consent affects two parts of automatic entry tracking:
true.
Calling consent(false) stops those auto-enabled detectors.tracking.enableElement(...) can force an element detector to run, but emitted events are still
blocked unless consent or allowedEventTypes permits the event type.This means a typical browser integration needs both:
page() or identify().Blocked events are observable through onEventBlocked and states.blockedEventStream. Events that
are blocked by consent are not replayed later when consent changes.
Automatic tracking starts from DOM metadata. The Web SDK looks for HTML or SVG elements with a
non-empty data-ctfl-entry-id attribute.
| Attribute | Used for |
|---|---|
data-ctfl-entry-id |
Required. Becomes componentId in entry interaction events. |
data-ctfl-optimization-id |
Optional. Becomes experienceId. |
data-ctfl-sticky |
Optional. true sends the first successful view for the element through the sticky Experience path. |
data-ctfl-variant-index |
Optional. Non-negative integer used as variantIndex; invalid or unsafe values are ignored. |
data-ctfl-track-views |
Optional. true or false override for view observation when the view detector is running. |
data-ctfl-track-clicks |
Optional. true or false override for click observation when the click detector is running. |
data-ctfl-track-hovers |
Optional. true or false override for hover observation when the hover detector is running. |
data-ctfl-view-duration-update-interval-ms |
Optional. Per-element interval for periodic view-duration updates. |
data-ctfl-hover-duration-update-interval-ms |
Optional. Per-element interval for periodic hover-duration updates. |
data-ctfl-clickable |
Optional. true marks a non-semantic element as part of a clickable path for click tracking. |
The tracking payload uses the resolved entry ID, not the baseline entry ID. When an application
needs the baseline ID for rerendering, store it separately, for example in data-ctfl-baseline-id.
The Web SDK does not use data-ctfl-baseline-id in event payloads.
Manual element observation can provide the same metadata without DOM attributes:
optimization.tracking.enableElement('views', element, {
data: {
entryId: resolvedEntry.sys.id,
optimizationId: selectedOptimization?.experienceId,
sticky: selectedOptimization?.sticky,
variantIndex: selectedOptimization?.variantIndex,
},
})
When manual data is valid, it takes precedence over data-ctfl-* values on the element. If manual
data is missing or invalid, the detector falls back to the element attributes.
Automatic tracking is opt-in. If autoTrackEntryInteraction is omitted, all automatic interaction
types default to false:
const optimization = new ContentfulOptimization({
clientId: 'your-client-id',
autoTrackEntryInteraction: {
views: true,
clicks: true,
hovers: false,
},
})
The same runtime can be controlled after initialization:
| API | Effect |
|---|---|
tracking.enable(interaction, options?) |
Enables automatic observation for 'views', 'clicks', or 'hovers'. |
tracking.disable(interaction) |
Disables automatic observation for that interaction and stops it when no manual overrides need it. |
tracking.enableElement(interaction, element, options?) |
Force-enables one element, even when global automatic tracking for that interaction is off. |
tracking.disableElement(interaction, element) |
Force-disables one element. |
tracking.clearElement(interaction, element) |
Removes the manual override so the element falls back to attribute overrides or automatic state. |
For each element, the runtime resolves tracking in this order:
enableElement(...) or disableElement(...).data-ctfl-track-* override.reset() stops interaction trackers and clears manual element overrides. destroy() also
disconnects DOM observers and browser lifecycle listeners.
The Web SDK has one shared entry element registry. When an interaction detector starts, the registry does two things:
document.querySelectorAll('[data-ctfl-entry-id]').MutationObserver that watches child additions and removals in the document
subtree.When a tracked element is added, the registry notifies the running view, click, and hover detectors. When a tracked element is removed, the registry notifies those detectors so they can unobserve the element or stop treating it as tracked.
The registry observes element existence, not arbitrary attribute changes. Entry payload attributes
such as data-ctfl-entry-id are read when an event fires, so updated IDs can affect later payloads.
Per-element tracking overrides such as data-ctfl-track-views and interval attributes are resolved
when the element is added to the detector. If an existing mounted element needs dynamic override
changes, use the tracking.*Element(...) API or remount the tracked element.
View tracking uses IntersectionObserver and dwell-time timers.
Default view settings:
| Setting | Default |
|---|---|
| Required visible dwell time | 1000 ms |
| Periodic duration update interval | 5000 ms |
| Minimum visible ratio | 0.1 |
IntersectionObserver root |
null |
IntersectionObserver root margin |
0px |
| Disconnected-element sweep interval | 30000 ms |
A view cycle works like this:
viewId, resets accumulated duration, and starts
the dwell timer.trackView() with viewId and viewDurationMs.viewId.The observer pauses dwell accumulation when the page is hidden and resumes when the page becomes visible again. It coalesces in-flight callbacks so a slow or failing event send does not create duplicate concurrent sends for the same element. If visibility ends while an event send is in flight, the final duration update is sent after that in-flight attempt settles.
Sticky view handling is per DOM element. If the payload has sticky: true, the first view attempt
for that element sends sticky: true to Core. After Core returns optimization data for that sticky
view, later view events for the same element omit sticky. If the sticky attempt does not return
optimization data, the detector retries sticky on the next visibility cycle for that element.
Separately rendered elements with the same entry ID are treated as separate sticky targets.
Click tracking uses one document-level capture listener. It does not call preventDefault() or
stopPropagation().
When a click occurs, the detector resolves two facts:
The click detector treats these paths as clickable:
a[href]buttoninputselecttextareasummary[role="button"][role="link"][onclick]onclick property handler[data-ctfl-clickable="true"]The tracked entry can be the clicked element, an ancestor of the clicked element, or a descendant of a clickable ancestor. The detector also handles click events whose original target is a text node by walking to the parent element.
If a tracked entry and a clickable path are found, the detector resolves entry metadata and calls
trackClick(). Click events do not include a click duration or click ID; the payload carries the
entry and optimization metadata.
Hover tracking uses element-level listeners. When PointerEvent is available, the observer listens
for pointerenter, pointerleave, and pointercancel, and ignores touch pointer events. When
pointer events are unavailable, it falls back to mouseenter and mouseleave.
Default hover settings:
| Setting | Default |
|---|---|
| Required hover dwell time | 1000 ms |
| Periodic duration update interval | 5000 ms |
| Disconnected-element sweep interval | 30000 ms |
A hover cycle mirrors the view cycle:
hoverId, resets accumulated duration, and starts
the dwell timer.trackHover() with hoverId and hoverDurationMs.hoverId.Like view tracking, hover tracking pauses while the page is hidden, coalesces in-flight callbacks, and sweeps disconnected element state.
React Web wraps the Web SDK; it does not replace the Web SDK tracking runtime.
OptimizationRoot creates one ContentfulOptimization instance from its props and destroys that
instance on unmount. autoTrackEntryInteraction, allowedEventTypes, defaults, queuePolicy,
and other Web SDK configuration values pass through to the underlying instance.
OptimizedEntry does three tracking-related things:
useOptimizedEntry().display: contents.The wrapper receives:
<div
data-ctfl-entry-id="resolved-entry-id"
data-ctfl-optimization-id="experience-id"
data-ctfl-sticky="true"
data-ctfl-variant-index="1"
data-ctfl-duplication-scope="session"
></div>
data-ctfl-duplication-scope is emitted by React Web for optimization metadata, but the Web SDK
entry interaction payload does not use it.
During loading, OptimizedEntry does not emit resolved entry tracking attributes. Loading UI is
therefore not tracked as the resolved Contentful entry. If children is a direct ReactNode
instead of a render prop, the wrapper still receives tracking attributes, but the child content does
not change based on the resolved entry.
useOptimizedEntry() only resolves data. It does not add DOM attributes or register an element. If
a component uses useOptimizedEntry() directly, it must either render the data-ctfl-* attributes
itself or use interactionTracking.enableElement(...).
React Web router adapters emit page() calls when supported routers change route. They are page
event helpers, not entry interaction detectors. Entry views, clicks, and hovers still come from the
Web SDK runtime.
Insights events are queued by current profile ID and sent in batches. The queue flushes
periodically, flushes when the queued event count reaches the batch threshold, and can be flushed
explicitly with optimization.flush().
Experience events are sent immediately when the browser is online. When the browser is offline,
Experience events are queued up to the configured offline maximum and replayed when the online
signal becomes true.
The Web SDK wires browser lifecycle events into this queue model:
online and offline update Core's online signal. Going online forces a flush.visibilitychange, pagehide, and beforeunload call flush() once per hide cycle.navigator.sendBeacon().Browser persistence is best-effort. Consent, profile, selected optimizations, Custom Flag changes,
and the anonymous ID are read from localStorage at startup when available. The anonymous ID is
also persisted in the ctfl-opt-aid cookie and migrated from the legacy anonymous ID cookie when
present.
When an expected interaction does not appear, check the gates in this order:
page() or identify() before relying on Insights events. Insights delivery
needs a current profile in state.states.consent.current === true or configure allowedEventTypes for the
event types that must emit before consent.autoTrackEntryInteraction or tracking.enable(...) is enabled
for the relevant interaction.data-ctfl-entry-id, or that enableElement(...) supplies valid data.entryId.minVisibleRatio for dwellTimeMs.data-ctfl-clickable="true".disableElement(...) or data-ctfl-track-*="false" is
not suppressing the element.For local diagnostics, subscribe to states.eventStream and states.blockedEventStream, and use
onEventBlocked for consent-gating visibility.
The Web SDKs do not own every part of tracking:
Keep these boundaries explicit when integrating or changing tracking behavior. Detection belongs to the browser runtime, event semantics belong to Core, and application-specific policy stays in the application.