NEWSLayers closes first external funding round led by LOI VentureRead more
‹ All Articles

Stop Making `/cart.js` Calls

Jake CastoJake Casto11 min read

Originally posted on Medium.

Read the original

Most Shopify tracking scripts send additional cart requests to determine cart contents. Ours does not. This article explains our approach and why common alternatives are less effective.

To observe this, open the network tab on a Shopify store using a recommendations or analytics app, add an item to the cart, and review the resulting network activity.

You’ll see the storefront’s /cart/add.js. Then, a beat later, you’ll see a /cart.js fired by the app. Sometimes two. Sometimes wrapped in a polling loop. The app is asking Shopify, “Okay, what’s in the cart now?” — a question the storefront’s own response already answered.

This pattern is widespread. Hundreds of the largest Shopify Apps and most session recording tools observe the add event and then issue a follow-up request to reconstruct the cart state. While functional, this approach is unnecessary, generates excess traffic, and makes your script the originator of cart requests rather than a passive observer.

Before detailing our solution, it is important to understand why most apps adopt this approach and the limitations of the alternatives.

How Most Apps Do It (And Why It Doesn’t Scale)

There are three common patterns for cart tracking in third-party Shopify apps. All three share the same flaw: they treat the cart as something to be queried, rather than something to be observed.

Pattern 1: Polling

The simplest approach. Set an interval, fetch /cart.js repeatedly, and diff the result.

let previousCart = null

setInterval(async () => {
  const response = await fetch('/cart.js')
  const cart = await response.json()

  if (previousCart && cart.item_count !== previousCart.item_count) {
    handleCartChange(previousCart, cart)
  }

  previousCart = cart
}, 2000)

This method works across all Shopify themes, requires no knowledge of storefront cart request implementation, and is simple to deploy. As a result, many apps use this approach by default.

Back in early 2025 I called this out publicly on Gorgias and Alia — both used to run polling intervals of 500ms to 2 seconds on storefronts across their install base. Both companies have since adopted the patching pattern we cover or use Shopify Web Pixels.

The problems:

  • It hammers Shopify’s Storefront API rate limits. Shopify enforces a bucket-based rate limit on /cart.js calls. A 500ms polling interval generates 7,200 requests per hour per active session. On a busy storefront with hundreds of concurrent sessions, apps polling this aggressively can exhaust the rate limit bucket — and when that happens, the storefront’s own cart requests start getting 429’d. Shoppers hit “add to cart” and nothing happens. The app caused the outage.
  • It’s laggy by design. Cart changes are only visible after the next tick, which means your add_to_cart event fires up to 2 seconds late.
  • Diffing is fragile. Item count alone misses quantity changes on existing line items. Full deep-diffing cart objects means you’re now maintaining that logic indefinitely.
  • It doesn’t give you the add context. You know the cart changed, but you’ve lost the original request, the product handle, the source surface, and any properties the storefront attached. Attribution at this point is a guess.

Pattern 2: PerformanceObserver

A more sophisticated approach. The PerformanceObserver API can observe completed network requests via PerformanceResourceTiming entries, which gives you URL, timing, and response size — without patching anything.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType === 'fetch' && entry.name.includes('/cart/add')) {
      // We know a cart add happened — but that's all we know
      handleCartAdd(entry)
    }
  }
})

observer.observe({ type: 'resource', buffered: true })

This method is non-invasive and does not modify fetch or XHR. Some analytics vendors use it as a ‘safe’ observation layer, considering it less risky than modifying prototypes.

The problems:

  • You get timing metadata, not request or response bodies. PerformanceResourceTiming tells you a fetch to /cart/add.js completed in 340ms. It doesn’t tell you what was added, which variant, how much, or what the cart looks like now.
  • So you still have to make a follow-up /cart.js call to find out what changed. The observation gives you a trigger, not the data.
  • You can’t enrich the request. There’s no hook into the outbound payload; there is no way to inject a session ID or attribution token before the request leaves the browser.
  • PerformanceObserver has gaps. Entries can be dropped under memory pressure. Buffering behavior varies by browser. It’s a telemetry API, not a reliable interception layer.

Pattern 3: Custom Event Listening

Some themes and apps dispatch custom DOM events when cart state changes — cart:updated, theme:cart:added, and similar. Listening to these is elegant when it works:

document.addEventListener('cart:updated', async (event) => {
  // Some themes pass cart data in event.detail
  const cart =
    event.detail?.cart ?? (await fetch('/cart.js').then((r) => r.json()))
  handleCartUpdate(cart)
})

The problems:

  • This is entirely theme-dependent. Dawn fires different events than Impulse. Custom themes fire none. There’s no standard.
  • You’re at the mercy of the theme developer’s event naming and payload shape. If they change it, your tracking breaks silently.
  • It still usually requires a follow-up fetch because most themes don’t include full cart data in the event payload.
  • You still can’t touch the outbound request. No request enrichment, no line-item attribution.

The Right Approach: Patch the Primitives

All three patterns share a common flaw: they detect that an event occurred and then request the data separately. The original cart request, which contains all necessary information, is not utilized.

The solution is to intercept cart activity at the network layer. Layers’ cart tracking is based on patching the browser’s network primitives to observe existing cart traffic without generating additional requests**.**

Patching fetch and XHR

The interceptor patches both network primitives on initialization:

const originalFetch = window.fetch

window.fetch = function (input, init) {
  const url = resolveUrl(input)

  if (isCartRoute(url) || isLayersRoute(url)) {
    return handleFetchIntercept(originalFetch, input, init, url)
  }

  return originalFetch.apply(this, arguments)
}
const originalOpen = XMLHttpRequest.prototype.open
const originalSend = XMLHttpRequest.prototype.send

XMLHttpRequest.prototype.open = function (method, url, ...rest) {
  this._layersUrl = resolveUrl(url)
  this._layersMethod = method
  return originalOpen.apply(this, [method, url, ...rest])
}

XMLHttpRequest.prototype.send = function (body) {
  if (isCartRoute(this._layersUrl)) {
    return handleXhrIntercept(this, body, originalSend)
  }
  return originalSend.apply(this, arguments)
}

The patch is deliberately narrow. isCartRoute matches a specific allowlist:

function isCartRoute(url) {
  if (!url) return false
  const path = new URL(url, location.origin).pathname
  return [
    '/cart/add',
    '/cart/add.js',
    '/cart/change',
    '/cart/change.js',
    '/cart/update',
    '/cart/update.js',
    '/cart.js',
  ].some((route) => path.endsWith(route))
}

All other requests pass through unchanged. The script acts as a listener on a defined set of routes, not as a proxy.

The Part Most Apps Get Wrong: Payload Safety

Before getting into what we actually inject, it’s worth being explicit about something: patching fetch and XHR on a live Shopify storefront is dangerous if you don’t handle every possible request payload type. You are sitting in the critical path of every cart interaction in the store. If your enrichment code throws on an unexpected body format, the cart breaks. Not for you, for the shopper.

In early 2025, an app deployed a similar network patch across its portfolio of Shopify brands without fully accounting for the payload variants Shopify storefronts can send. The patch assumed JSON bodies. When it encountered a FormData request, which older themes and some native Shopify UI components still use, the enrichment code threw, the modified request was never sent, and cart adds silently failed. This rolled out to their entire install base before it was caught.

That’s the failure mode. Here’s what you actually have to handle.

The Shopify cart payload matrix

Shopify storefront cart requests can arrive in four distinct body formats depending on the theme, the Shopify UI component (Cart API, Section Rendering API, native Liquid forms), and whether the request was constructed by theme JS or a third-party app:

  • JSON string → Modern themes, Ajax cart APIs, most app-generated requests
  • FormData → Native Liquid submits, older themes, some Shopify UI components
  • URLSearchParams → Some older Ajax patterns, theme kit tooling
  • null / undefined → GET /cart.js reads, some edge cases

The enrichment code must handle all four formats and, critically, pass through any unrecognized formats without error.

function enrichRequestBody(body, sessionId, attributionToken, url) {
  try {
    // FormData — mutate in place, it's passed by reference
    if (body instanceof FormData) {
      body.set('attributes[_layers_session_id]', sessionId)
      if (isAddRoute(url) && attributionToken) {
        body.set('properties[_layers_attribution]', attributionToken)
      }
      return body
    }

    // URLSearchParams — same pattern as FormData
    if (body instanceof URLSearchParams) {
      body.set('attributes[_layers_session_id]', sessionId)
      if (isAddRoute(url) && attributionToken) {
        body.set('properties[_layers_attribution]', attributionToken)
      }
      return body
    }

    // JSON string — parse, enrich, re-serialize
    if (typeof body === 'string' && body.trim().startsWith('{')) {
      const parsed = JSON.parse(body)
      parsed.attributes = {
        ...parsed.attributes,
        _layers_session_id: sessionId,
      }
      if (isAddRoute(url) && attributionToken && parsed.items) {
        parsed.items = parsed.items.map((item) => ({
          ...item,
          properties: {
            ...item.properties,
            _layers_attribution: attributionToken,
          },
        }))
      } else if (isAddRoute(url) && attributionToken && parsed.id) {
        // Single-item add shorthand
        parsed.properties = {
          ...parsed.properties,
          _layers_attribution: attributionToken,
        }
      }
      return JSON.stringify(parsed)
    }

    // null, undefined, ReadableStream, Blob, ArrayBuffer, or anything else
    // — return completely untouched, no matter what
    return body
  } catch (err) {
    // Enrichment failed — return the original body unmodified
    // The cart request must succeed even if we can't annotate it
    logger.debug('Cart body enrichment failed, passing through original', err)
    return body
  }
}

Three things to notice:

The try/catch block encompasses the entire function, not just individual branches. If any error occurs, such as a malformed JSON body or unexpected FormData structure, the original body is returned unmodified. The cart request continues, ensuring that annotation is optional and cart functionality is preserved for the shopper.

**Unknown body types are returned as-is.**Formats such as ReadableStream, Blob, ArrayBuffer, or any future types from Shopify are not introspected, and no errors are thrown.

The JSON handling logic supports both single-item and array-item add formats. Shopify’s cart add API accepts both { id, quantity } and { items: […] } structures. Supporting only one format results in missed cart additions.

XHR body handling

XHR send() receives the same payload variety, but without the init wrapper — the body is passed directly:

XMLHttpRequest.prototype.send = function (body) {
  if (!isCartRoute(this._layersUrl)) {
    return originalSend.apply(this, arguments)
  }

  let enrichedBody = body
  try {
    enrichedBody = enrichRequestBody(
      body,
      getSessionId(),
      resolveAttribution(),
      this._layersUrl,
    )
  } catch (err) {
    // Enrichment failed entirely — fire original body
    logger.debug('XHR enrichment failed', err)
    enrichedBody = body
  }

  return originalSend.call(this, enrichedBody)
}

Same principle: if enrichment fails at any level, originalSend fires with the original body. We never swallow the request itself.

Test against all four formats before shipping

If you are developing a similar solution, the following test matrix should be completed before deploying to production:

// 1. JSON string — modern themes
fetch('/cart/add.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ id: variantId, quantity: 1 }),
})

// 2. FormData — Liquid form submits
const fd = new FormData()
fd.append('id', variantId)
fd.append('quantity', 1)
fetch('/cart/add.js', { method: 'POST', body: fd })

// 3. URLSearchParams
const params = new URLSearchParams({ id: variantId, quantity: 1 })
fetch('/cart/add.js', { method: 'POST', body: params })

// 4. XHR with JSON
const xhr = new XMLHttpRequest()
xhr.open('POST', '/cart/add.js')
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(JSON.stringify({ id: variantId, quantity: 1 }))

All four formats should add items to the cart successfully, regardless of enrichment success. Cart functionality must be preserved; annotation is optional.

Request-Side: Enriching What’s Already Being Sent

When the storefront fires a matching cart request, we modify the outbound payload before it leaves the browser. There are two things we inject: a session ID on all cart requests, and a line-item attribution token on add flows only.

Session stitching

Every cart payload format gets a _layers_session_id attribute injected at the cart level:

function injectSessionAttribute(body, sessionId) {
  if (body instanceof FormData || body instanceof URLSearchParams) {
    body.set('attributes[_layers_session_id]', sessionId)
    return body
  }

  if (typeof body === 'string') {
    try {
      const parsed = JSON.parse(body)
      parsed.attributes = {
        ...parsed.attributes,
        _layers_session_id: sessionId,
      }
      return JSON.stringify(parsed)
    } catch {
      return body
    }
  }

  return body
}

This provides downstream systems with a stable identifier for associating cart events with a browser session, eliminating the need for additional requests.

Line-item attribution (add flows only)

For /cart/add requests specifically, we also stamp a _layers_attribution token at the line-item level:

function injectItemAttribution(item, attributionToken) {
  return {
    ...item,
    properties: {
      ...item.properties,
      _layers_attribution: attributionToken,
    },
  }
}

**This process is not applied to /cart/update or /cart/change requests.**Attribution is only added during add flows, as mutation flows do not provide meaningful attribution context. Enrichment is applied only where justified.

Attribution itself is resolved conservatively:

function resolveAttribution(item) {
  // 1. Already on the item — leave it alone
  if (item.properties?._layers_attribution) {
    return item.properties._layers_attribution
  }

  // 2. Current PDP context matches
  const pdpContext = getCurrentPdpContext()
  if (pdpContext && pdpContext.variantId === item.id) {
    return pdpContext.attributionToken
  }

  // 3. Last valid product interaction
  const lastInteraction = getLastProductInteraction()
  if (lastInteraction?.isValid() && lastInteraction.variantId === item.id) {
    return lastInteraction.attributionToken
  }

  // 4. Skip rather than guess
  return null
}

If product identity cannot be reliably resolved, the function returns null and attribution is omitted. Incorrect attribution is considered more detrimental than missing data.

Response-Side: Learning from What the Storefront Already Received

This is where the ‘stop making cart calls’ principle provides the most significant benefit.

Rather than issuing a /cart.js request after a cart mutation, the script listens to the response already generated by the storefront’s request.

The fetch interception pattern

The key implementation detail here is how we handle the response without disrupting the merchant’s promise chain:

function handleFetchIntercept(originalFetch, input, init, url) {
  // Modify the outbound request
  const modifiedInit = enrichRequest(init, url)

  // Fire the original fetch — return the promise immediately
  const fetchPromise = originalFetch.call(window, input, modifiedInit)

  // Process the response in a detached chain
  // Errors here cannot propagate to the merchant's await
  fetchPromise
    .then((response) => response.clone())
    .then((cloned) => cloned.json())
    .then((data) => processCartResponse(data, url))
    .catch((err) => {
      // Log internally, never throw
      logger.debug('Cart response processing failed', err)
    })

  // Return the original promise unmodified
  return fetchPromise
}

The response is cloned before reading to avoid interfering with the storefront’s .then() handler. All processing occurs in a detached chain, ensuring that parsing failures do not result in unhandled rejections in the merchant’s error tools.

Add-to-cart responses

When a /cart/add succeeds, we use the response body to finalize the add_to_cart event:

function processAddResponse(responseData, originalRequestBody) {
  try {
    const items = responseData.items ?? [responseData]

    for (const item of items) {
      const product = resolveProduct(item.product_id, item.variant_id)
      const value = item.final_price ? item.final_price / 100 : null

      emit('add_to_cart', {
        product_id: item.product_id,
        variant_id: item.variant_id,
        quantity: item.quantity,
        value,
        attribution: item.properties?._layers_attribution ?? null,
      })
    }
  } catch {
    // Fall back to request payload — event fires either way
    processAddFromRequest(originalRequestBody)
  }
}

If response parsing fails, the script falls back to the request data. The event is triggered in either case.

Cart state from mutation responses

GET /cart.js, /cart/update, and /cart/change responses update an in-memory productsInCart map:

function syncCartState(responseData) {
  if (!responseData?.items) return

  productsInCart.clear()

  for (const item of responseData.items) {
    productsInCart.set(item.variant_id, {
      productId: item.product_id,
      variantId: item.variant_id,
      quantity: item.quantity,
      handle: item.handle,
    })
  }
}

This context is then used to exclude products in Layers block requests, filtering out items already in the cart, previously shown, or currently viewed. All information is obtained from existing storefront responses without additional requests.

Why Stack Trace Visibility Matters

A significant drawback of naively wrapping fetch is that the script can dominate merchant debugging sessions. If a developer investigates a cart error and the wrapper uses async/await throughout, the script appears first in stack traces.

The initial implementation looked like this:

// Before — our wrapper was in the call stack
window.fetch = async function (input, init) {
  const modifiedInit = await enrichRequest(init)
  const response = await originalFetch(input, modifiedInit)
  await processResponse(response.clone())
  return response
}

Any error in processResponse could propagate, causing the script’s frame to appear in every stack trace involving a cart fetch. Merchant debugging tools would then flag the script.

The solution is to return the original promise immediately and perform all processing in a detached chain:

// After — we're not in the call stack for the merchant's await
window.fetch = function (input, init) {
  const modifiedInit = enrichRequestSync(init)
  const promise = originalFetch.call(window, input, modifiedInit)

  // Detached — errors here are ours, not the merchant's
  promise
    .then((r) => r.clone())
    .then((r) => r.json())
    .then(processResponse)
    .catch(logger.debug)

  return promise
}

The merchant’s await fetch(…) now resolves as it would with native fetch. All processing occurs in a parallel chain, ensuring it does not interfere with the merchant’s code.

The Rules

Every decision above follows from a small set of constraints:

  1. Don’t make extra cart calls to observe the cart. The storefront already made the call.
  2. Enrich in place, don’t replay. Modify the outbound payload and listen for the inbound response.
  3. Patch only known routes. Don’t become a general-purpose network proxy.
  4. Add the minimum metadata. Session ID and attribution token, nothing else.
  5. Never guess attribution. Skip rather than stamp the wrong product.
  6. Treat add and mutation flows differently. Line-item attribution belongs only on adds.
  7. Keep response processing detached. Our failures can’t break merchant cart flows.
  8. Stay out of the stack trace. Developers debugging cart issues shouldn’t be reading our code.

The Broader Point

While observing network primitives is a well-established pattern, few Shopify apps apply it to cart tracking. Most continue to listen for events and then re-fetch to reconstruct state.

The optimal****cart tracking script understands cart activity without generating additional cart requests. It observes rather than duplicates, enriches rather than replays, and remains adjacent to the merchant’s stack rather than interfering with it.

If a script issues cart requests to determine cart state, it likely already has the necessary information but is not observing the correct source.

Jake Casto

Jake Casto · Founder, Layers

Jake Casto is the founder of Layers, the enterprise search and merchandising platform built for Shopify Plus. He previously co-founded Proton, a Shopify Plus engineering studio that shipped more than 400 storefronts, where Layers began as an internal tool for a problem that kept repeating. He writes about search infrastructure, performance, and the engineering behind discovery at scale.

Connect on LinkedIn