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

Tearing Down Shopify’s Product Network Tracking Script

Jake CastoJake Casto4 min read

Originally posted on Medium.

Read the original

I was debugging an issue on a customer store when I noticed a click/impression tracking script served from Shopify’s CDN that I’d never seen before. It’s part of the Product Network, Shopify’s ad platform for surfacing products across other storefronts. I went down the rabbit hole and came out with a list of things we were doing worse.

What the Script Does

Remote product links carry attribution in the URL itself.

Product Network ads look like normal product links, but the URL follows a specific pattern:

/products/{seo-handle}-{encoded-payload}-{signature}-remote

The payload is base64-decoded client-side into version, product ID, advertiser shop ID, ad response ID, and timestamp. The signature is parsed but not browser-validated; that’s presumably done server-side. Clean design: attribution travels with the link; no secondary lookup required.

Two analytics layers share one observation pass.

Products are bucketed into Remote, Collective-Static, and Organic origin types. Remote products emit ad-specific telemetry (rendered, impression, click, duplicate detection) plus standard product impression and click events. Organic products only get the latter. Ad attribution and storefront behavior analytics run through the same pipeline, but downstream consumers don’t have to understand ad state unless they need it.

ShopifyAnalytics.meta first, fetch second.

For organic products, instead of fetching /products/{handle}.js to resolve a product handle to an ID, the script first reads window.ShopifyAnalytics.meta.product and window.ShopifyAnalytics.meta.products. Shopify has already put the data on the page. Product pages have meta.product. Collection pages have meta.products. The script uses it directly and only falls back to a fetch if nothing’s there. The detail that made me look at our own code.

Performance guards run before observation, not during it.

A few things worth noting:

  • Elements with a bounding box under 10x10px are filtered out before the IntersectionObserver sees them: hidden template nodes, zero-size utility links, and offscreen duplicates are gone.
  • Product links are deduplicated by normalized URL before observation starts. There is one entry per unique product URL. Multiple links pointing to the same product get a single impression ID assigned upfront.
  • A hard MAX_PROCESSED_LINKS cap prevents pathological storefronts from creating unbounded work.
  • The MutationObserver only rescans when candidate-related attributes change or product nodes are inserted. It does not rescan on every mutation.

rendered and impression are separate events.

The script tracks DOM presence and viewport exposure independently. Shopify can distinguish what loaded from what was actually seen. This is useful for ad quality measurement and not something most tracking scripts do.

What We Found in Our Own Code

A few things we fixed after reading this:

Repeated array scans occurred on every event. Resolving which product a DOM element belonged to ran Array.find() against the full result set on every impression, hover, and click. We indexed results by handle, product ID, and request key on first access and cached the maps.

No element context caching existed. DOM parsing and state matching ran redundantly across impression, hover, and touch flows for the same element. We added a WeakMap cache keyed by element, cleared on each observer refresh so dynamic content stays fresh.

No minimum size filter existed. We weren’t filtering out zero-size or sub-10px elements before observing. We added the same getBoundingClientRect() check. This required updating JSDOM tests since default JSDOM boxes are zero-sized.

Observer set was not deduplicated. Product cards in Shopify themes typically include multiple elements that point to the same product: an image link, a title link, and a quick-add button. We were observing all of them. Now we build a deduplicated candidate set by normalized key (href, data-product-url, data-product-id) before handing anything to IntersectionObserver.

MutationObserver was firing on irrelevant mutations. Shopify storefronts mutate constantly: drawers, app embeds, inventory badges, quantity inputs. We were rescanning on all of it. The observer now checks whether any added node contains a product candidate or whether a candidate-related attribute changed before scheduling a refresh.

Product view fetching occurred when the data was already on the page using the same ShopifyAnalytics.meta pattern. We were fetching /products/{handle}.js as the default on product and collection pages. It’s now the fallback.

The script isn’t doing anything novel. IntersectionObserver for impressions, MutationObserver for dynamic content, and deduplication before observation are all established patterns. What’s notable is the consistency. Every optimization follows from the same principle: reduce work before it starts rather than handling it after.

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