<- All field notes

Field notes / 2026-09-04 / 6 min read

Delay Shopify third-party scripts until first interaction

Most slow Shopify stores are not slow because of the theme. They are slow because of the apps added to it: reviews, chat, email pop-ups, push notifications, restock alerts. Each app puts a script in the page. Each script loads before the page shows. Shoppers wait, and some leave.

We did this work recently on a live fashion store in two languages. The mobile PageSpeed score was in the mid-40s. The theme code was fine. Seven scripts from apps loaded before the page. This post shows what we did, with the code.

Find the heavy parts first

Test your store on mobile withPageSpeed Insights. Open the treemap view. It shows the size of everything the page loads. Sort by size and look for names you know. Those names are apps, not theme code.

On our store, the heaviest item was a reviews widget. It loaded about 2 MB of oversized images on the home page. No theme change can beat that. You fix the app, replace it, or make it load later.

Do this step first. If you delay a script that was never heavy, you win nothing.

The pattern: hold the scripts until the shopper moves

Chat, email pop-ups, and push prompts share one property: nobody needs them in the first second. A shopper who never taps or scrolls never needed them at all. So we hold these scripts until the first tap or scroll. The page shows first.

BEFOREchatemailreviewspushpageusable ~5sAFTERpageusable ~1sfirst tapchatemailreviewspush0s8s

There is a catch on Shopify. Apps inject these scripts, so you cannot adddefer to a tag you do not write. Instead, stop each script when the page creates it. Put this as the first script in the head oftheme.liquid:

<script>
(function () {
  // Hosts to hold until first interaction. Add your own offenders.
  var DELAY = ['chatra', 'klaviyo', 'pushowl', 'instafeed'];
  var parked = [];
  var released = false;

  function matches(src) {
    if (released || typeof src !== 'string') return false;
    for (var i = 0; i < DELAY.length; i++) {
      if (src.indexOf(DELAY[i]) !== -1) return true;
    }
    return false;
  }

  // Hook property assignment: el.src = '...'
  var proto = HTMLScriptElement.prototype;
  var desc = Object.getOwnPropertyDescriptor(proto, 'src');
  Object.defineProperty(proto, 'src', {
    get: desc.get,
    set: function (value) {
      if (matches(value)) {
        parked.push({ el: this, src: value });
        return; // do not set src yet; the browser never fetches it
      }
      desc.set.call(this, value);
    },
  });

  // Hook attribute assignment: el.setAttribute('src', '...')
  var setAttr = proto.setAttribute;
  proto.setAttribute = function (name, value) {
    if (name === 'src' && matches(value)) {
      parked.push({ el: this, src: value });
      return;
    }
    return setAttr.call(this, name, value);
  };

  function release(trigger) {
    if (released) return;
    released = true;
    window.__delayReleasedBy = trigger; // leave a debugging trail
    for (var i = 0; i < parked.length; i++) {
      desc.set.call(parked[i].el, parked[i].src);
    }
    parked = [];
  }

  ['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(function (evt) {
    addEventListener(evt, function () { release(evt); }, { once: true, passive: true });
  });

  // Safety net: a shopper who never interacts still gets everything.
  setTimeout(function () { release('timeout'); }, 8000);
})();
</script>

Three details matter in production:

  • Hook both paths. Some apps set el.src. Other apps call setAttribute('src', ...). Our first version had only the first hook, and one app got through.
  • Leave a trail. window.__delayReleasedByrecords what started the load. When something looks wrong, it answers one question from any console: did the release occur, and why.
  • Always keep the timeout. Chat and analytics must load for a shopper who only reads. Eight seconds is late enough.

Think before you add analytics or consent tools to the delay list. A delayed analytics script does not measure the start of the visit.

Fonts: half a second for free

Examine each @font-face in the theme. Iffont-display is missing or set to fallback, the text can stay invisible while the font downloads. Setfont-display: swap on every font face. The text then shows immediately in a system font, and the correct font replaces it.

fallbackinvisible text (0.5s)text showsswapsystem font at oncebrand font replaces it

On our store, this removed about 0.5 seconds of hidden text across six font faces. It is the cheapest fix in this post.

Remove apps the theme can replace

The best performance win is an app that is gone. Our store paid for a translation app. Its only remaining job was French labels on size options. We moved those labels into the theme with a small Liquid snippet. One subscription gone, one script gone from every page.

Go through your app list. Ask one question per app: what breaks tomorrow if this app is gone? If the answer is "a lot", keep it. If the answer is "one small thing the theme can do", replace it. If the answer is "nothing", remove it today.

What moved

Scores move in steps, not jumps. The interceptor and the font fix moved the mobile PageSpeed score from the mid-40s toward the 50s. The page also responds faster to taps, because seven scripts no longer compete for the processor at the start.

The reviews widget stays the limit. Its 2 MB of images is a problem for the app vendor, not for the theme. That is the honest shape of this work: measure, move the heaviest thing to later, remove what you can, and know which limits belong to which vendor.

Have the same problem?