[{"data":1,"prerenderedAt":572},["ShallowReactive",2],{"site":3,"writing-\u002Fwriting\u002Fwhy-our-purchase-event-waited-for-the-page-to-load":50,"writing-nav":559},{"id":4,"bookingLabel":5,"bookingUrl":6,"company":7,"contact":8,"extension":11,"hire":12,"legal":15,"location":16,"meta":17,"name":18,"nav":19,"owner":38,"social":39,"status":46,"stem":47,"tagline":48,"__hash__":49},"site\u002Fsite.yml","Book a call for what's next","https:\u002F\u002Fcal.com\u002Fanthive\u002Fintro","Ant Hive d.o.o.",{"heading":9,"intro":10},"Start with a call.","Thirty minutes, no slides. Tell me what you're building and when you need someone.","yml",{"heading":13,"intro":14},"If you're here to hire","Start with the problem you have. I'll tell you straight whether I'm the right person for it.","Ant Hive d.o.o., Braće Radića 41, Solin, Croatia. OIB 87072518531. MBS 05985889, Trgovački sud u Splitu. Share capital € 2.500, paid in full. Director: Ante Borzić.","Split, Croatia, CET",{},"Ant Hive",[20,23,26,29,32,35],{"label":21,"to":22},"Writing","\u002Fwriting",{"label":24,"to":25},"Work with me","\u002Fwork-with-me",{"label":27,"to":28},"On my desk","\u002Fdesk",{"label":30,"to":31},"About","\u002Fabout",{"label":33,"to":34},"Colophon","\u002Fcolophon",{"label":36,"to":37},"Contact","\u002Fcontact","Ante Borzić",[40,43],{"label":41,"to":42},"LinkedIn","https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fante-borzic",{"label":44,"to":45},"GitHub","https:\u002F\u002Fgithub.com\u002Fb0rza","On a long-term contract.","site","Writing code is the easy part now. Musings and work from Ante Borzić, on the rest.","2Xoi9K5fmdspVs2Iabkuc-XV6mG1EfiZBm3RojZ-9K0",{"id":51,"title":52,"body":53,"date":544,"description":545,"draft":546,"extension":547,"headline":548,"meta":552,"minutes":217,"navigation":553,"path":554,"seo":555,"stem":556,"topic":557,"__hash__":558},"writing\u002Fwriting\u002Fwhy-our-purchase-event-waited-for-the-page-to-load.md","Why our purchase event waited for the page to load",{"type":54,"value":55,"toc":537},"minimark",[56,60,63,68,71,83,92,98,115,121,125,128,131,140,256,266,272,278,282,289,377,380,385,389,395,398,517,523,527,533],[57,58,59],"p",{},"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.",[57,61,62],{},"\"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.",[64,65,67],"h2",{"id":66},"three-kinds-of-loaded","Three kinds of loaded",[57,69,70],{},"On a Nuxt storefront, \"the page has loaded\" can mean three different things.",[57,72,73,74,78,79,82],{},"The browser fires ",[75,76,77],"code",{},"load"," once every image, script and iframe on the page is done. GTM listens for it and pushes ",[75,80,81],{},"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.",[57,84,85,86,88,89,91],{},"None of these wait for each other. Most of the time ",[75,87,77],{}," 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 ",[75,90,77],{}," back.",[93,94,95],"sidenote",{},[57,96,97],{},"The timing code never shipped. It lived in one commit, proved the race, and was deleted in the fix.",[57,99,100,101,104,105,107,108,111,112,114],{},"I didn't want to argue about a race on a hunch, so I added temporary timing marks: ",[75,102,103],{},"performance.mark"," at DOMContentLoaded, at ",[75,106,77],{},", at mount, when the order data resolved and at the dataLayer push, printed with ",[75,109,110],{},"console.table",". A few reloads were enough. Cold loads had ",[75,113,77],{}," first. Warm reloads often had the push first.",[57,116,117,118,120],{},"That's also why it slipped through. In development you test on a cold load, where the app's own JavaScript is slow and ",[75,119,77],{}," usually wins. The bug only shows up on the second visit.",[64,122,124],{"id":123},"fixing-the-order-at-the-source","Fixing the order at the source",[57,126,127],{},"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.",[57,129,130],{},"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.",[57,132,133,134,136,137,139],{},"So the purchase push waits for ",[75,135,77],{}," in the app. If the page has already loaded, it runs right away. If not, it waits for one ",[75,138,77],{}," event.",[141,142,147],"pre",{"className":143,"code":144,"language":145,"meta":146,"style":146},"language-ts shiki shiki-themes github-light github-dark","export function runAfterWindowLoad(fn: () => void) {\n  if (document.readyState === 'complete') {\n    fn()\n    return\n  }\n  window.addEventListener('load', fn, { once: true })\n}\n","ts","",[75,148,149,188,206,215,221,227,250],{"__ignoreMap":146},[150,151,154,158,161,165,169,172,175,178,181,185],"span",{"class":152,"line":153},"line",1,[150,155,157],{"class":156},"szBVR","export",[150,159,160],{"class":156}," function",[150,162,164],{"class":163},"sScJk"," runAfterWindowLoad",[150,166,168],{"class":167},"sVt8B","(",[150,170,171],{"class":163},"fn",[150,173,174],{"class":156},":",[150,176,177],{"class":167}," () ",[150,179,180],{"class":156},"=>",[150,182,184],{"class":183},"sj4cs"," void",[150,186,187],{"class":167},") {\n",[150,189,191,194,197,200,204],{"class":152,"line":190},2,[150,192,193],{"class":156},"  if",[150,195,196],{"class":167}," (document.readyState ",[150,198,199],{"class":156},"===",[150,201,203],{"class":202},"sZZnC"," 'complete'",[150,205,187],{"class":167},[150,207,209,212],{"class":152,"line":208},3,[150,210,211],{"class":163},"    fn",[150,213,214],{"class":167},"()\n",[150,216,218],{"class":152,"line":217},4,[150,219,220],{"class":156},"    return\n",[150,222,224],{"class":152,"line":223},5,[150,225,226],{"class":167},"  }\n",[150,228,230,233,236,238,241,244,247],{"class":152,"line":229},6,[150,231,232],{"class":167},"  window.",[150,234,235],{"class":163},"addEventListener",[150,237,168],{"class":167},[150,239,240],{"class":202},"'load'",[150,242,243],{"class":167},", fn, { once: ",[150,245,246],{"class":183},"true",[150,248,249],{"class":167}," })\n",[150,251,253],{"class":152,"line":252},7,[150,254,255],{"class":167},"}\n",[57,257,258,259,262,263,265],{},"Without the ",[75,260,261],{},"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 ",[75,264,77],{}," won't fire again.",[267,268,269],"pull-quote",{},[57,270,271],{},"The ticket asked for a faster page. The fix made the purchase event wait longer.",[57,273,274,275,277],{},"The payload didn't change, and only the success page is affected. Every tag in the container now sees ",[75,276,81],{}," before the purchase, whichever finishes first.",[64,279,281],{"id":280},"firing-once-and-where-once-stops","Firing once, and where once stops",[57,283,284,285,288],{},"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 ",[75,286,287],{},"sessionStorage"," for a key with the order ID, then sets it.",[141,290,292],{"className":143,"code":291,"language":145,"meta":146,"style":146},"const key = `purchase-sent:${order.id}`\nif (!sessionStorage.getItem(key)) {\n  sessionStorage.setItem(key, '1')\n  runAfterWindowLoad(() => pushPurchase(order))\n}\n",[75,293,294,320,340,357,373],{"__ignoreMap":146},[150,295,296,299,302,305,308,311,314,317],{"class":152,"line":153},[150,297,298],{"class":156},"const",[150,300,301],{"class":183}," key",[150,303,304],{"class":156}," =",[150,306,307],{"class":202}," `purchase-sent:${",[150,309,310],{"class":167},"order",[150,312,313],{"class":202},".",[150,315,316],{"class":167},"id",[150,318,319],{"class":202},"}`\n",[150,321,322,325,328,331,334,337],{"class":152,"line":190},[150,323,324],{"class":156},"if",[150,326,327],{"class":167}," (",[150,329,330],{"class":156},"!",[150,332,333],{"class":167},"sessionStorage.",[150,335,336],{"class":163},"getItem",[150,338,339],{"class":167},"(key)) {\n",[150,341,342,345,348,351,354],{"class":152,"line":208},[150,343,344],{"class":167},"  sessionStorage.",[150,346,347],{"class":163},"setItem",[150,349,350],{"class":167},"(key, ",[150,352,353],{"class":202},"'1'",[150,355,356],{"class":167},")\n",[150,358,359,362,365,367,370],{"class":152,"line":217},[150,360,361],{"class":163},"  runAfterWindowLoad",[150,363,364],{"class":167},"(() ",[150,366,180],{"class":156},[150,368,369],{"class":163}," pushPurchase",[150,371,372],{"class":167},"(order))\n",[150,374,375],{"class":152,"line":223},[150,376,255],{"class":167},[57,378,379],{},"A reload doesn't send it again, and neither does coming back to the page in the same tab.",[57,381,382,384],{},[75,383,287],{}," 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.",[64,386,388],{"id":387},"checking-the-order-every-time","Checking the order every time",[57,390,391,392,394],{},"The unit test sets the document to still loading, triggers the purchase and checks nothing was pushed. Then it fires ",[75,393,77],{}," and checks the event was pushed exactly once.",[57,396,397],{},"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:",[141,399,403],{"className":400,"code":401,"language":402,"meta":146,"style":146},"language-js shiki shiki-themes github-light github-dark","const dl = window.dataLayer\nconst loaded = dl.findIndex((e) => e.event === 'gtm.load')\nconst purchase = dl.findIndex((e) => e.event_name === 'purchase')\nconsole.log(loaded !== -1 && purchase > loaded)\n","js",[75,404,405,417,454,485],{"__ignoreMap":146},[150,406,407,409,412,414],{"class":152,"line":153},[150,408,298],{"class":156},[150,410,411],{"class":183}," dl",[150,413,304],{"class":156},[150,415,416],{"class":167}," window.dataLayer\n",[150,418,419,421,424,426,429,432,435,439,442,444,447,449,452],{"class":152,"line":190},[150,420,298],{"class":156},[150,422,423],{"class":183}," loaded",[150,425,304],{"class":156},[150,427,428],{"class":167}," dl.",[150,430,431],{"class":163},"findIndex",[150,433,434],{"class":167},"((",[150,436,438],{"class":437},"s4XuR","e",[150,440,441],{"class":167},") ",[150,443,180],{"class":156},[150,445,446],{"class":167}," e.event ",[150,448,199],{"class":156},[150,450,451],{"class":202}," 'gtm.load'",[150,453,356],{"class":167},[150,455,456,458,461,463,465,467,469,471,473,475,478,480,483],{"class":152,"line":208},[150,457,298],{"class":156},[150,459,460],{"class":183}," purchase",[150,462,304],{"class":156},[150,464,428],{"class":167},[150,466,431],{"class":163},[150,468,434],{"class":167},[150,470,438],{"class":437},[150,472,441],{"class":167},[150,474,180],{"class":156},[150,476,477],{"class":167}," e.event_name ",[150,479,199],{"class":156},[150,481,482],{"class":202}," 'purchase'",[150,484,356],{"class":167},[150,486,487,490,493,496,499,502,505,508,511,514],{"class":152,"line":217},[150,488,489],{"class":167},"console.",[150,491,492],{"class":163},"log",[150,494,495],{"class":167},"(loaded ",[150,497,498],{"class":156},"!==",[150,500,501],{"class":156}," -",[150,503,504],{"class":183},"1",[150,506,507],{"class":156}," &&",[150,509,510],{"class":167}," purchase ",[150,512,513],{"class":156},">",[150,515,516],{"class":167}," loaded)\n",[57,518,519,520,522],{},"It has to print ",[75,521,246],{}," 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.",[64,524,526],{"id":525},"what-id-do-from-the-start","What I'd do from the start",[57,528,529,530,532],{},"On the next headless shop, I'd write an end-to-end test before anyone reports anything. Delay one image request so ",[75,531,77],{}," 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.",[534,535,536],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":146,"searchDepth":190,"depth":190,"links":538},[539,540,541,542,543],{"id":66,"depth":190,"text":67},{"id":123,"depth":190,"text":124},{"id":280,"depth":190,"text":281},{"id":387,"depth":190,"text":388},{"id":525,"depth":190,"text":526},"2026-10-09","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.",false,"md",[549,550,551],"Why our purchase event","> waited for","the page to load",{},true,"\u002Fwriting\u002Fwhy-our-purchase-event-waited-for-the-page-to-load",{"title":52,"description":545},"writing\u002Fwhy-our-purchase-event-waited-for-the-page-to-load","Analytics","Bjz4xR7vR4vXPz_OiAoQkCfygudNawxmkvKWq0SGaF4",[560,564,568,571],{"path":561,"title":562,"topic":563},"\u002Fwriting\u002Fhow-to-audit-a-system-with-an-ai-agent-that-never-gets-the-keys","How to audit a system with an AI agent that never gets the keys","Security",{"path":565,"title":566,"topic":567},"\u002Fwriting\u002Fyour-token-cache-key-has-to-know-about-your-deployments","Your token cache key has to know about your deployments","Backend",{"path":569,"title":570,"topic":563},"\u002Fwriting\u002Fan-install-policy-for-the-year-of-npm-worms","An install policy for the year of npm worms",{"path":554,"title":52,"topic":557},1793359541053]