navigator.sendBeacon is the most reliable way to deliver tracking events from a browser. The request is queued by the user agent and guaranteed to be sent even if the page is unloading — which is exactly when you most want events like product_click and add_to_cart to land.
Use this guide as the starting point for any custom storefront integration. If you’re on Shopify with the Layers Storefront Pixel installed, this is already handled for you.
Prerequisites
- A Layers storefront access token. The same token used everywhere else in the Storefront API.
- A stable, anonymized
session_id for the current visit. Generate one at session start and persist it in sessionStorage.
- The
attribution_token returned by Search, Browse, or Blocks responses, if you want clicks and adds-to-cart to attribute back to the originating request.
The minimum viable beacon
A few things to call out:
- Token in the query string.
sendBeacon cannot set headers, so the token rides on the URL. See Authentication for why this is safe.
Blob with type: 'application/json'. Without the explicit MIME type, the browser sends the payload as text/plain and the worker will reject it.
fetch with keepalive fallback. sendBeacon returns false if the user agent’s queue is full or the payload exceeds ~64 KB. fetch({ keepalive: true }) has the same unload-safety guarantees and accepts larger bodies.
Batching events
Sending one beacon per event works, but is wasteful. A small in-memory queue that flushes every 2 seconds (or on pagehide) cuts requests by 10–20× without losing data.
Listen for pagehide and the visibilitychange → hidden transition, not beforeunload. pagehide fires reliably on mobile Safari and back/forward cache restores; beforeunload does not.
Wiring it to storefront interactions
Once you have track() (or enqueue()), call it from the right places in your storefront code:
Impression tracking with IntersectionObserver
product_impression events are the highest-volume signal in the system — fire one only when a tile is actually visible.
Session and attribution helpers
The two helpers used above. Keep them in a shared module.
Call setAttributionToken(response.attributionToken) every time you get a response from the Search, Browse, or Blocks APIs. Subsequent clicks, views, and add-to-cart events will then attribute correctly.
Common pitfalls
text/plain payload. Forgetting type: 'application/json' on the Blob causes the worker to read no JSON body and drop the batch. Always set the MIME type.
- Sending headers with
sendBeacon. You can’t. If you need the header form of the token, switch to fetch({ keepalive: true }).
- Sending more than 100 events at once. The batch limit is 100. Cap your queue at ~20–50 to stay well under both that and
sendBeacon’s ~64 KB payload limit.
- Reusing
event_id. Use a fresh ULID/UUID per event. The platform deduplicates on this field.
- Tracking on
beforeunload. Use pagehide and visibilitychange instead — they fire in mobile Safari and on back/forward cache transitions where beforeunload does not.
Next steps