AW
Case study 03 · Off-Roading E-Commerce

Klaviyo personalization architecture: dwell-time JS + 6-path Django logic

Klaviyo emails reference a single trigger event, so a browse-abandonment email would otherwise show whatever product the visitor clicked first, not the one they actually cared about. The fix isn't in Klaviyo — it's storefront JavaScript that pushes the session's highest-dwell product as a persistent profile property, plus 6-path Django conditional logic in the email Liquid that reads Shopify customer tags + the Recharge Active Subscriber tag to send the right cross-sell.

If a customer bought Product_W, they shouldn't get the "you already own Product_X, get Product_Y" email. The fix isn't in Klaviyo's UI — it's in the product-tag taxonomy plus Shopify Flow workflows plus Liquid conditionals. Three systems, one personalization story.
Shipped March – April 2026 for an off-roading e-commerce client (engagement details under standard client confidentiality). Live on the production storefront.
The system

One storefront, three data sources, one personalized email

8s
Dwell threshold to fire event
6
Conditional upsell paths in email Liquid
3
Shopify Flow workflows feeding the system
4
Customer-tag inputs to the conditional
The engineering "why"

Six things that make this real personalization, not a tag swap

1

Dwell over click-through

An 8-second threshold filters bots and accidental clicks. Click-through alone would create noise — half the events would be people who tapped the wrong product. 8 seconds means the visitor actually looked at it.

snippet 01
2

sessionStorage selects the highest-dwell product

Most browse-abandonment systems trigger on most-recent-product. This one tracks dwell per product across the session and picks the one the visitor spent the most total time on. That's the product they were actually evaluating.

snippet 01
3

_learnq.push for profile persistence

Klaviyo events trigger flows; profile properties survive across sessions. Pushing Top_Browsed_Product/URL/Image/Price as a profile property means the email rendered an hour later still reflects the right product, not whatever fired the trigger event.

snippet 01
4

Dual visibilitychange + beforeunload listeners

beforeunload alone is unreliable on mobile (iOS suspends pages without firing it). visibilitychange catches tab switches and most navigations. Together they catch every "user left this page" event without double-firing.

snippet 01
5

6-path conditional upsell logic, ordered

Active Subscriber is checked FIRST in the if/elif chain because subscribers also carry Has_Product_X and Has_Product_Y tags. Checked later, they'd match the wrong path and get a "buy what you already own" email. Order matters.

snippet 02
6

Product-tag taxonomy verified before launch

Product_W units were originally mis-tagged "Product_X" — would have falsely sent "you already own Product_X" emails to Product_W buyers. Caught by reading products.json via API and cross-checking the tag taxonomy before any flow went live.

snippet 03
Architecture

Three data sources, one Liquid template, six branches

Storefront → Klaviyo profile
theme.liquid (lines 365-401+)
On every product page, start a timer at page load.
On visibilitychange / beforeunload: check elapsed seconds.
If ≥ 8s: fire Product Dwell Time Klaviyo event with ProductName, ProductID, URL, ImageURL, Price.
Update sessionStorage[productID] with dwell time (only if higher than previous visit this session).
Pick the session's highest-dwell product and push it via _learnq.push(['identify', { Top_Browsed_Product, ... }])
Order → customer tags
Shopify Flow · Order Created
line item product tag contains "Product_X" → add customer tag Has_Product_X
line item product tag contains "Product_Y" → add customer tag Has_Product_Y
line item product tag contains "Product_Z" → add customer tag Has_Product_Z
Recharge · Subscription created
auto-tag customer Active Subscriber
(Recharge ↔ Shopify customer-tag sync verified via the Shopify customer profile)
Shopify customer tags sync to Klaviyo as 'Shopify Tags' profile property
Email render → 6-path conditional
Klaviyo flow Liquid · 6-path Django conditional
1. Active Subscriber→ "you're already in" · show what's new2. Has_Product_X + Has_Product_Y → upsell subscription3. Has_Product_X only → upsell Product_Y4. Has_Product_Y only → upsell subscription (assumes Product_X from off-platform)5. Has_Product_Z only → upsell Product_X (entry product)6. No tags → full system intro
ordering matters · Active Subscriber checked FIRST because subscribers also carry Has_Product_X + Has_Product_Y
Personalized email send
Right product · right customer · right step
The Code · how it flows

Three snippets, in execution order

Real excerpts from theme.liquid (the dwell tracker), the Klaviyo flow email template (the 6-path Django conditional), and the Shopify Flow workflow definition (the customer-tagging trigger). Reading order: see how the storefront pushes data → see how the email reads it → see how the customer-tag inputs are populated.

Step 1 · Storefront

Track dwell, pick the session's highest-engagement product, push to Klaviyo

Runs on every product page. Starts a timer at load, checks elapsed time on visibilitychange or beforeunload, fires a Klaviyo event after 8s, updates sessionStorage per product, and pushes the session's highest-dwell product as a persistent profile property via _learnq.push(['identify']). The image URL is prepended with 'https:' because Shopify's image_url filter returns protocol-relative // URLs that email clients can't resolve.

theme.liquid · dwell-time tracking (lines 365-401+)ts
// theme.liquid — runs on every product page (Liquid renders the {{ product.* }}
// values into the JS at server-side; the JS then runs in the customer's browser)
<script>
(function () {
  var product = {
    id:    {{ product.id }},
    name:  {{ product.title | json }},
    url:   {{ shop.url | append: product.url | json }},
    image: 'https:' + {{ product.featured_image | image_url: width: 600 | json }},
    price: {{ product.price | money_without_currency | json }}
  };

  var startedAt = Date.now();
  var fired = false;

  function elapsedSeconds() {
    return Math.round((Date.now() - startedAt) / 1000);
  }

  function recordDwell() {
    if (fired) return;
    var seconds = elapsedSeconds();
    if (seconds < 8) return;          // filters bots + accidental clicks
    fired = true;

    // 1. Fire the Klaviyo event for trigger purposes
    if (window._learnq) {
      window._learnq.push(['track', 'Product Dwell Time', {
        ProductName:        product.name,
        ProductID:          product.id,
        URL:                product.url,
        ImageURL:           product.image,
        Price:              product.price,
        Time_Spent_Seconds: seconds
      }]);
    }

    // 2. Track per-product highest dwell in this session
    var key = 'dwell_' + product.id;
    var prev = parseInt(sessionStorage.getItem(key) || '0', 10);
    if (seconds > prev) sessionStorage.setItem(key, String(seconds));

    // 3. Find the session's highest-dwell product across all visited products
    var top = { id: null, seconds: 0, name: null, url: null, image: null, price: null };
    for (var i = 0; i < sessionStorage.length; i++) {
      var k = sessionStorage.key(i);
      if (!k || k.indexOf('dwell_') !== 0) continue;
      var s = parseInt(sessionStorage.getItem(k) || '0', 10);
      if (s > top.seconds) {
        top.seconds = s;
        top.id = k.replace('dwell_', '');
      }
    }

    // For the current product (the only one we know full details for from this load),
    // mirror its data into the top-browsed shape if it won the session.
    if (String(product.id) === top.id) {
      top.name  = product.name;
      top.url   = product.url;
      top.image = product.image;
      top.price = product.price;
    }

    // 4. Push the session winner as PERSISTENT profile properties.
    // Profile properties survive across sessions; events do not.
    // By the time the 1-hour Browse Abandonment wait expires, the profile
    // reflects the most-engaged product, not whatever fired the trigger.
    if (window._learnq && top.name) {
      window._learnq.push(['identify', {
        Top_Browsed_Product: top.name,
        Top_Browsed_URL:     top.url,
        Top_Browsed_Image:   top.image,
        Top_Browsed_Price:   top.price
      }]);
    }
  }

  // Dual listeners: visibilitychange catches tab switches and most navigations
  // on mobile + desktop; beforeunload is the desktop backup for hard window
  // closes. Together they catch every "user left this page" event without
  // double-firing (the 'fired' guard handles the overlap).
  document.addEventListener('visibilitychange', function () {
    if (document.visibilityState === 'hidden') recordDwell();
  });
  window.addEventListener('beforeunload', recordDwell);
})();
</script>
Step 2 · Email render

Read the customer's tags, branch to the right cross-sell

The Welcome + Upsell template (used in two flows) reads two pieces of profile data: the Top_Browsed_* properties pushed by the storefront JS, and the Shopify customer tags synced via Klaviyo's Shopify integration. The 6 paths each send a different cross-sell. If/elif order is load-bearing — Active Subscribers also carry Has_Product_X + Has_Product_Y tags, so they MUST be matched first or they'd land in path 2.

Klaviyo flow email · 6-path Django conditionalts
{# Klaviyo flow email · Welcome + Upsell template
   Reads:
     - person|lookup:'Shopify Tags'  (synced from Shopify customer tags)
     - person|lookup:'Top_Browsed_Product'  (pushed by storefront JS)
   Order matters: Active Subscriber checked FIRST because subscribers
   also carry Has_Product_X + Has_Product_Y tags. #}

{% assign tags = person|lookup:'Shopify Tags'|default:'' %}
{% assign top_product = person|lookup:'Top_Browsed_Product'|default:event.Name %}
{% assign top_url     = person|lookup:'Top_Browsed_URL'|default:event.URL %}

{% if tags contains 'Active Subscriber' %}
  {# Path 1 — Active Subscriber. They have everything. Don't sell what they have. #}
  <h1>You're already in.</h1>
  <p>Here's what's dropping next month for subscribers.</p>
  <a href="/collections/upcoming">Browse the latest →</a>

{% elif tags contains 'Has_Product_X' and tags contains 'Has_Product_Y' %}
  {# Path 2 — Full system minus subscription. Only upsell left is subscription. #}
  <h1>You've got Product_X. You've got Product_Y. Now never run out.</h1>
  <a href="/products/subscription">Start a Subscription →</a>

{% elif tags contains 'Has_Product_X' %}
  {# Path 3 — Product_X only. Push Product_Y (don't push subscription yet — first try). #}
  <h1>Now make Product_X yours.</h1>
  <p>Pre-made designs, custom Product_Y units, or the monthly subscription drop.</p>
  <a href="/collections/product-y">Browse Product_Y →</a>

{% elif tags contains 'Has_Product_Y' %}
  {# Path 4 — Product_Y only. They MUST own Product_X (got it off-platform — at an
     event or on Amazon). Shopify just doesn't know. Push subscription, NOT
     a Product_X they already own. #}
  <h1>You already know the system. Never run out of new designs.</h1>
  <a href="/products/subscription">Start a Subscription →</a>

{% elif tags contains 'Has_Product_Z' %}
  {# Path 5 — Product_Z only, no Product_X. Product_X is the entry product. #}
  <h1>Get Product_X.</h1>
  <p>Step one before anything else.</p>
  <a href="/products/product-x">Shop Product_X →</a>

{% else %}
  {# Path 6 — No tags. Could be brand new or could own everything off-platform.
     Full system intro is the safest default. #}
  <h1>Here's how the system works.</h1>
  <p>Product_Z → Product_X → swappable Product_Y → monthly subscription drops.</p>
  <a href="/">Shop the system →</a>
{% endif %}

{# If they have a top-browsed product, surface it below the cross-sell as a
   secondary nudge. This block renders for every path. #}
{% if top_product %}
  <hr>
  <p>You were also looking at:</p>
  <a href="{{ top_url }}">{{ top_product }} →</a>
{% endif %}
Step 3 · Customer tagging

Auto-apply Has_Product_X when Product_X ships in the order

Three Shopify Flow workflows feed the customer-tag inputs the email Liquid reads: Has_Product_X, Has_Product_Y, and Has_Product_Z (the third still pending the tag-taxonomy fix). Each fires on Order Created, walks the line items, and adds the appropriate customer tag if any line item carries the matching product tag. Shopify Flow only tags FUTURE orders — backfilling existing customers requires a one-time Customers → Segments → bulk-tag pass.

Shopify Flow · Has_Product_X customer-tagging workflowts
// Shopify Flow workflow definition · "Has_Product_X customer tagging"
// Trigger:    shopify/orders/create  (fires on every Order Created event)
// Action:     Add customer tag
// Filter:     line item product tag contains "Product_X"
//
// Equivalent JSON shape (Shopify Flow stores workflows as a graph):
{
  "version": "2024-01",
  "trigger": {
    "type": "shopify/orders/create"
  },
  "steps": [
    {
      "id": "filter_has_product_x_line_item",
      "type": "condition",
      "expression":
        "order.lineItems.any(li => li.product.tags.contains('Product_X'))"
    },
    {
      "id": "add_customer_tag",
      "type": "action",
      "action": "shopify/customers/add_tags",
      "input": {
        "customerId": "{{ order.customer.id }}",
        "tags": ["Has_Product_X"]
      },
      "runIf": "filter_has_product_x_line_item == true"
    }
  ]
}

// Two more workflows mirror this shape:
//   - Has_Product_Y: filters on line item product tag "Product_Y"
//   - Has_Product_Z: filters on line item product tag "Product_Z" (pending the
//     tag-taxonomy fix; Product_Z needs its product tag added first)
//
// Anti-foot-gun note: Product_W units were originally tagged "Product_X" by mistake.
// Without catching that, anyone who bought Product_W would have been tagged
// Has_Product_X and received "you already own Product_X, get Product_Y" emails.
// Caught by reading products.json via the Shopify Admin REST API
// (v2024-01) and verifying the tag taxonomy before activating the workflow.
//
// Backfill path (Shopify Flow only tags future orders):
//   Customers → Segments → filter by products_purchased(tag: 'Product_X')
//   → bulk-tag Has_Product_X → repeat for Product_Y.

Source

Storefront JS + Klaviyo flow Liquid + Shopify Flow workflows. Live in production for an off-roading e-commerce client.