> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sending events with navigator.sendBeacon

> Use navigator.sendBeacon to ship Layers tracking events from the browser without blocking navigation, including page-unload and pagehide flushes.

`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](/api-reference/browse).
* 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

```js theme={null}
const STOREFRONT_TOKEN = 'shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const ENDPOINT = `https://cl.uselayers.com/beacon?token=${encodeURIComponent(STOREFRONT_TOKEN)}`;

function track(event) {
  const body = new Blob(
    [JSON.stringify({ events: [event] })],
    { type: 'application/json' }
  );

  const ok = navigator.sendBeacon(ENDPOINT, body);
  if (!ok) {
    // Browser refused (queue full or payload too large) — fall back to fetch keepalive.
    fetch(ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ events: [event] }),
      keepalive: true,
    }).catch(() => {
      /* swallow — tracking is best-effort */
    });
  }
}

track({
  event_id: crypto.randomUUID(),
  event_type: 'product_view',
  timestamp: new Date().toISOString(),
  session_id: getSessionId(),
  product_id: 7003338965178,
  attribution_token: getAttributionToken(),
});
```

A few things to call out:

* **Token in the query string.** `sendBeacon` cannot set headers, so the token rides on the URL. See [Authentication](/tracking-api/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.

```js theme={null}
const STOREFRONT_TOKEN = 'shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
const ENDPOINT = `https://cl.uselayers.com/beacon?token=${encodeURIComponent(STOREFRONT_TOKEN)}`;
const MAX_BATCH = 20;       // worker limit is 100; 20 keeps us well under sendBeacon's 64 KB
const FLUSH_INTERVAL = 2000;

let queue = [];
let flushTimer = null;

function enqueue(event) {
  queue.push(event);
  if (queue.length >= MAX_BATCH) {
    flush();
  } else if (!flushTimer) {
    flushTimer = setTimeout(flush, FLUSH_INTERVAL);
  }
}

function flush() {
  if (flushTimer) {
    clearTimeout(flushTimer);
    flushTimer = null;
  }
  if (queue.length === 0) return;

  const events = queue;
  queue = [];

  const body = new Blob(
    [JSON.stringify({ events })],
    { type: 'application/json' }
  );

  const ok = navigator.sendBeacon(ENDPOINT, body);
  if (!ok) {
    fetch(ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ events }),
      keepalive: true,
    }).catch(() => {
      /* best-effort */
    });
  }
}

// Flush on unload so in-flight events aren't lost.
window.addEventListener('pagehide', flush);
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
```

<Tip>
  Listen for **`pagehide`** and the **`visibilitychange` → hidden** transition, not `beforeunload`. `pagehide` fires reliably on mobile Safari and back/forward cache restores; `beforeunload` does not.
</Tip>

## Wiring it to storefront interactions

Once you have `track()` (or `enqueue()`), call it from the right places in your storefront code:

```js theme={null}
// Product detail page
document.addEventListener('DOMContentLoaded', () => {
  enqueue({
    event_id: crypto.randomUUID(),
    event_type: 'product_view',
    timestamp: new Date().toISOString(),
    session_id: getSessionId(),
    product_id: window.LAYERS_PRODUCT_ID,
    attribution_token: getAttributionToken(),
  });
});

// Clicks on product tiles in a collection or search results
document.querySelectorAll('[data-product-tile]').forEach((tile, index) => {
  tile.addEventListener('click', () => {
    enqueue({
      event_id: crypto.randomUUID(),
      event_type: 'product_click',
      timestamp: new Date().toISOString(),
      session_id: getSessionId(),
      attribution_token: getAttributionToken(),
      product_id: Number(tile.dataset.productId),
      position: index + 1,
    });
  });
});

// Add-to-cart
document.querySelector('#add-to-cart')?.addEventListener('click', () => {
  enqueue({
    event_id: crypto.randomUUID(),
    event_type: 'add_to_cart',
    timestamp: new Date().toISOString(),
    session_id: getSessionId(),
    attribution_token: getAttributionToken(),
    product_id: window.LAYERS_PRODUCT_ID,
    variant_id: getSelectedVariantId(),
  });
});
```

## Impression tracking with `IntersectionObserver`

`product_impression` events are the highest-volume signal in the system — fire one only when a tile is actually visible.

```js theme={null}
const impressionObserver = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const tile = entry.target;
      if (tile.dataset.impressed === '1') continue;
      tile.dataset.impressed = '1';

      enqueue({
        event_id: crypto.randomUUID(),
        event_type: 'product_impression',
        timestamp: new Date().toISOString(),
        session_id: getSessionId(),
        attribution_token: getAttributionToken(),
        product_id: Number(tile.dataset.productId),
        position: Number(tile.dataset.position),
      });

      impressionObserver.unobserve(tile);
    }
  },
  { threshold: 0.5 } // at least 50% visible
);

document.querySelectorAll('[data-product-tile]').forEach((tile) => {
  impressionObserver.observe(tile);
});
```

## Session and attribution helpers

The two helpers used above. Keep them in a shared module.

```js theme={null}
function getSessionId() {
  let id = sessionStorage.getItem('layers_session_id');
  if (!id) {
    id = `sess_${crypto.randomUUID()}`;
    sessionStorage.setItem('layers_session_id', id);
  }
  return id;
}

// Store the most recent attributionToken from Search/Browse/Blocks responses.
function setAttributionToken(token) {
  if (token) sessionStorage.setItem('layers_attribution_token', token);
}
function getAttributionToken() {
  return sessionStorage.getItem('layers_attribution_token') || undefined;
}
```

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

* [Payload examples](/tracking-api/payload-examples) — full request bodies per event type.
* [Tracking page navigation in a SPA](/tracking-api/guides/spa-tracking) — pattern for client-side routed storefronts.
