Skip to content

On a long-term contract.Book a call for what's next.

Split, Croatia, CET RSS

Analytics

4 min read

Why our purchase event waited for the page to load

Microsoft Ads reported a slow order success page. The real problem was a race between Vue hydration and the browser's load event that only showed on a warm cache, and the fix made the purchase event wait longer.

Ante Borzić

The ticket said the order success page was slow. The tracking team at Microsoft Ads had noticed that sometimes, not always, the purchase event fired before GTM and the page had finished loading. When that happened, their purchase tag could miss the conversion.

"Sometimes, not always" gave it away. A slow page is slow on every visit. An event that only sometimes arrives too early means two things are racing.

Three kinds of loaded

On a Nuxt storefront, "the page has loaded" can mean three different things.

The browser fires load once every image, script and iframe on the page is done. GTM listens for it and pushes gtm.load into the dataLayer, which is what its Window Loaded trigger waits for. Meanwhile Vue hydrates, the success page looks up the order, and our code pushes the purchase event as soon as the order data is there.

None of these wait for each other. Most of the time load came first anyway, because the page has large hero images and a third-party voucher widget that take a while. On a warm cache, the order flipped. Our own JavaScript came from cache, hydration was quick, the order lookup came back, and the purchase event went into the dataLayer while the images and the widget were still holding load back.

I didn't want to argue about a race on a hunch, so I added temporary timing marks: performance.mark at DOMContentLoaded, at load, at mount, when the order data resolved and at the dataLayer push, printed with console.table. A few reloads were enough. Cold loads had load first. Warm reloads often had the push first.

That's also why it slipped through. In development you test on a cold load, where the app's own JavaScript is slow and load usually wins. The bug only shows up on the second visit.

Fixing the order at the source

The first option was in GTM. A trigger group fires a tag only after all of its triggers have fired, so a group of Window Loaded and the purchase event would have made the Microsoft Ads tag wait.

I decided against it. It fixes one tag at a time, and every new tag that reads the purchase would need the same setup. It also lives in a container the frontend team doesn't own, so the fix would depend on someone remembering why it's there. And the dataLayer itself would still be out of order for anything else reading it.

So the purchase push waits for load in the app. If the page has already loaded, it runs right away. If not, it waits for one load event.

export function runAfterWindowLoad(fn: () => void) {
  if (document.readyState === 'complete') {
    fn()
    return
  }
  window.addEventListener('load', fn, { once: true })
}

Without the readyState check, a customer who reaches the success page through client-side navigation would never send the event. The document finished loading long ago, so load won't fire again.

The ticket asked for a faster page. The fix made the purchase event wait longer.

The payload didn't change, and only the success page is affected. Every tag in the container now sees gtm.load before the purchase, whichever finishes first.

Firing once, and where once stops

Gating fixes the order, but a purchase event also has to fire only once. We already had that covered. Before scheduling the event, the page checks sessionStorage for a key with the order ID, then sets it.

const key = `purchase-sent:${order.id}`
if (!sessionStorage.getItem(key)) {
  sessionStorage.setItem(key, '1')
  runAfterWindowLoad(() => pushPurchase(order))
}

A reload doesn't send it again, and neither does coming back to the page in the same tab.

sessionStorage belongs to one tab, though. Open the success link in a new tab, or come back in a new session, and the purchase fires again. We know about that and left it. GA4 drops a repeat purchase with the same transaction ID, and the Microsoft Ads conversion goal counts one conversion per click. A proper server-side guard would need a "tracked" flag on the order in the backend, which the frontend doesn't control. For a rare case the platforms already handle, that wasn't worth it.

Checking the order every time

The unit test sets the document to still loading, triggers the purchase and checks nothing was pushed. Then it fires load and checks the event was pushed exactly once.

For QA, the TagHound Chrome extension lists the dataLayer in order, and Window Loaded should sit above the purchase event. Without the extension, a few lines in the console do the same, run a few seconds after the success page loads:

const dl = window.dataLayer
const loaded = dl.findIndex((e) => e.event === 'gtm.load')
const purchase = dl.findIndex((e) => e.event_name === 'purchase')
console.log(loaded !== -1 && purchase > loaded)

It has to print true after a hard reload, after a soft reload and with the network throttled. One run isn't a test when the bug only shows up some of the time.

What I'd do from the start

On the next headless shop, I'd write an end-to-end test before anyone reports anything. Delay one image request so load comes late on purpose, go through a purchase, then check the dataLayer order. That turns "sometimes, not always" into a failure you get every time.

Ante Borzić is a full-stack engineer who works with coding agents and reads everything they write. Most of the work is web apps, APIs and Salesforce integrations, on contract through Ant Hive d.o.o.. If that's the kind of help you need, start at Work with me.