← Back to list

How I Loaded Third-Party Scripts Without Killing My Page Speed

I was building a donation platform. The stack was Nuxt 4, Vue 3, and TypeScript — a normal SSR setup. And it came with a normal problem…

Eyüp Erbeyin · 2026-06-01 15:00 · 55 claps · 9.2 min read
Open on Medium ↗
Wiki topics: 🌐 · Web Development

How I Loaded Third-Party Scripts Without Killing My Page Speed

I was building a donation platform. The stack was Nuxt 4, Vue 3, and TypeScript — a normal SSR setup. And it came with a normal problem too.

GTM, GA4, Hotjar, Meta Pixel, JivoSite, reCAPTCHA. All of these wanted to be on the page. All of them wanted to load first. And none of them cared about my LCP.

When I opened Lighthouse, the result was not a surprise. The main thread was busy fighting with third-party scripts while the user just looked at a blank screen.

So this post is about how I loaded these scripts without blocking the real work of the page. There is no single magic trick here. It is more like a few small ideas that work together. I will explain them one by one.

First decision: don’t touch SSR at all

Most projects put third-party snippets directly into the HTML, either in the <head> or at the bottom of the <body>. This looks fine, but it causes trouble on the SSR side.

Think about it like this. When the server builds the HTML, it also puts those <script> tags into the response. As soon as the browser starts reading that HTML, the preload scanner starts working. Before the parser even reaches your real content, it scans the page and starts downloading the things it thinks it will need. If GTM and Pixel are already in the HTML, the scanner grabs them too. So your important LCP image now has to share the bandwidth with an analytics script.

The fix is very simple:

// app.vue
onMounted(async () => {
  if (import.meta.client) {
    loadThirdPartyScripts()
    // ...
  }
})

Because of the import.meta.client guard, this function never runs on the server. While Nitro builds the HTML, it does not see any third-party script at all. The server response comes out clean, the scanner does not pull the wrong files early, and the LCP image keeps its priority.

The whole strategy stands on this one decision. Once we agree to inject everything on the client, we are free to choose when we inject it.

Staggered loading: a delay in two layers

The main function looks like this:

export const loadThirdPartyScripts = (): void => {
  if (typeof window === 'undefined' || typeof document === 'undefined') return

  const init = () => {
    setTimeout(() => loadGTM(), 0)
    setTimeout(() => loadHotjar(), 0)
    setTimeout(() => loadMetaPixel(), 0)
    setTimeout(() => loadJivoSite(), 0)
  }

  if (document.readyState === 'complete') {
    setTimeout(init, 2000)
  } else {
    window.addEventListener('load', () => {
      setTimeout(init, 2000)
    })
  }
}

There are two different setTimeout calls here, and each one has its own job. At first the inner one looked useless to me. Then I understood it.

The outer setTimeout(init, 2000) runs two seconds after the load event. The load event already fires after all images, fonts, and CSS are ready. Add two more seconds, and the LCP and CLS measuring windows are long gone. So during the important moments when Core Web Vitals is measuring, the main thread is not busy with third parties.

The inner setTimeout(..., 0) is the clever part. If I called the four loaders one after another inside init(), they would all run in one single task. Together they would become one long task, easily over 50ms. By wrapping each one in its own setTimeout(0), I give the event loop a chance to breathe. Each loader runs in its own small task. No long task, no damage to TBT.

There is also the typeof window === 'undefined' check at the top. The function is already called behind import.meta.client, but the loaders can also be imported from other places (the reCAPTCHA part is one example). So I left this check here as a safety net. Two extra lines that will save someone a headache one day.

Why does GTM load differently?

Hotjar, Pixel, and JivoSite follow the “load + 2s” rhythm. GTM does not. It uses requestIdleCallback:

if ('requestIdleCallback' in window && typeof window.requestIdleCallback === 'function') {
  window.requestIdleCallback(loadScript, { timeout: 2000 })
} else {
  // Safari still doesn't support it, so a fallback is a must
  if (document.readyState === 'complete') {
    setTimeout(loadScript, 1000)
  } else {
    window.addEventListener('load', () => setTimeout(loadScript, 1000))
  }
}

The reason is this. GTM is the central tag manager that all our ecommerce events wait for. When a user adds something to the donation cart, the add_to_cart event is pushed to GTM. For some users this happens only 1–2 seconds after the page opens. If I had put GTM into the "load + 2s" chain too, these early events would sit in the dataLayer queue much longer than needed.

So GTM does not wait for load. It loads the moment the browser becomes idle. The timeout: 2000 is the safety net: "if you can't find an idle moment in two seconds, just run it anyway." The other scripts don't need this. Hotjar and JivoSite start with the session, Pixel keeps its own queue, and loading a bit late does not lose them any data.

There is one more small detail about dataLayer. I put a tiny line into the head of the SSR HTML, before the GTM script even arrives:

// nuxt.config.ts
script: [
  { tagPosition: 'head', innerHTML: `window.dataLayer = window.dataLayer || [];` }
]

Honestly, I forgot why I added this and removed it once. Then I remembered fast, because I got a “dataLayer undefined” error during hydration. Things like useGtmEcommerce or UTM tracking can push an event before GTM is loaded. Since dataLayer is an array, these pushes are not lost — they wait in the queue, and GTM reads them when it arrives. But the array has to exist first. Create it once in the head, and the problem is gone.

The most elegant part: the queue stub pattern

For me, this is the heart of the whole thing. Third-party SDKs have a classic problem:

hj('event', 'donation_started')  // hj is not defined yet → boom

You are delaying the script by two seconds. But what if the user does something during those two seconds? Does the event just disappear? No. Because before we load the real script, we put a stub function on window:

win.hj = win.hj || function (...args: any[]) {
  (win.hj.q = win.hj.q || []).push(args)
}
win._hjSettings = { hjid: HOTJAR_ID, hjsv: HOTJAR_VERSION }

Here is the idea. When the code calls hj('foo'), the real Hotjar is not there yet, but the stub catches the arguments and pushes them into the hj.q queue. When the real script loads, the first thing it does is read that queue and replay it one by one. So even a script that loads late still handles the calls that happened while it did not exist. No event is lost.

Meta Pixel does the same thing in a slightly bigger way:

const fbq = function () {
  const args = Array.from(arguments)
  if (fbq.callMethod) {
    fbq.callMethod.apply(fbq, args as any)
  } else if (fbq.queue) {
    fbq.queue.push(args as any)
  }
} as any

win.fbq = fbq
if (!win._fbq) win._fbq = fbq
fbq.queue = []
fbq.loaded = false
fbq.version = '2.0'

It works in two modes. If callMethod exists, the real SDK is here, so call it directly. If not, push it into the queue. The win.fbq('init', PIXEL_ID) call that happens before the script arrives goes into the queue, and when the script lands, Pixel uses that init to set itself up.

Everyone sees this pattern in the official GTM and Hotjar snippets, but most people just copy-paste it and move on. Once you understand why it is written this way, you get a nice feeling of confidence: “I can delay the script as long as I want, because the queue has my back.” That confidence is what holds the whole lazy-loading strategy together.

I also made the loaders idempotent. Each one checks if its own script is already in the DOM:

if (document.querySelector(`script[src*="fbevents.js"]`)) return

The onMounted in app.vue does not fire again on SPA navigation anyway, but this still protects against double loading. These small checks save you from a confusing moment in production, when you wonder "why are two Pixel events going out."

reCAPTCHA: only load the heaviest script if there is intent

reCAPTCHA v3 is about half a megabyte, and most pages don’t need it at all. It is only needed when a form is submitted. So I did not put it in the chain above. Instead, I tied it fully to user intent.

It is triggered by three different signals.

First, entering the viewport. The moment the donate button becomes visible, the script starts downloading:

donateButtonObserver = new IntersectionObserver((entries) => {
  if (entries.some((e) => e.isIntersecting)) {
    loadRecaptchaScript()
    donateButtonObserver?.disconnect()
    donateButtonObserver = null
  }
})
donateButtonObserver.observe(el)

The user scrolls, sees the button, and we start fetching the script in the background. By the time they really click it, the loading is usually finished. So the user feels no delay. I trigger the observer once and then disconnect it right away, so it does not keep listening for nothing.

Second, hover, touch, or focus. On the button:

<button
  @mouseenter="prefetchRecaptcha"
  @touchstart.passive="prefetchRecaptcha"
  @focus="prefetchRecaptcha"
>

On desktop, the user usually hovers for 200–500ms before the click — that is an early signal. On mobile, touchstart fires about 50–100ms before the click. focus is for people who use the keyboard. The .passive modifier matters here: it tells Vue to use a native passive: true listener, so the touch handler does not block scrolling.

Third, the fallback. Imagine the user somehow submits the form without triggering any of these signals. Then the script starts loading the moment executeRecaptcha is called. Late, but guaranteed.

I will be honest about one thing. Right now, the waiting logic inside executeRecaptcha polls with setInterval:

const checkInterval = setInterval(() => {
  if (window.grecaptcha) {
    clearInterval(checkInterval)
    resolve(true)
  }
}, 100)

This opens a task on the main thread every 100ms, and that hurts INP. The correct way would be for the loader to expose the script’s onload event as a Promise and await that here. I put it on my refactor list — that small tension between code that works and code that is correct.

reCAPTCHA also has a two-phase loading detail. “The script arrived” and “the SDK is ready” are two different things:

await new Promise<void>((resolve) => {
  if (window.grecaptcha && window.grecaptcha.ready) {
    window.grecaptcha.ready(() => resolve())
  } else {
    resolve()
  }
})

The grecaptcha.ready callback runs exactly when SDK init is done. Without this second check, you hit a race condition in the "the script is there but execute is not ready yet" state.

Managing dataLayer by hand (and why I skipped the library)

There are wrapper libraries for GA4 and GTM, like @gtm-support/vue-gtm. I did not use them. I go straight through dataLayer.push. All I need is to push a few events in the right format, and adding a dependency just for that felt like too much.

There is one GA4 detail to watch out for

const pushToDataLayer = (eventName: string, ecommerceData: GtmEcommerceData) => {
  window.dataLayer = window.dataLayer || []
  window.dataLayer.push({ ecommerce: null })   // clear first
  window.dataLayer.push({ event: eventName, ecommerce: ecommerceData })
}

That { ecommerce: null } line is important. It is in the GA4 docs, but it is easy to miss. If you push two ecommerce events one after another on the same dataLayer, the data merges instead of replacing. So the second event's tag can also read the first event's items array. If you don't reset with null, your Funnel report gets messy when a user adds one campaign to the cart and then immediately adds another. Finding this bug live is not fun, so it is better to add this from the start.

Small but useful touches in nuxt.config

Two of these are not very well known, but they have a clear payoff.

Preconnect, for reCAPTCHA only:

link: [
  { rel: 'preconnect', href: 'https://www.google.com' },
  { rel: 'preconnect', href: 'https://www.gstatic.com', crossorigin: '' }
]

So that by the time reCAPTCHA enters the viewport and the fetch begins, the TCP and TLS handshake is already done. The crossorigin="" on gstatic is there on purpose. Subresources are fetched as CORS requests, and without this attribute the preconnect opens a socket, but the real fetch does not match it and does a fresh handshake. In that case the preconnect was wasted.

**unload=() for BFCache:**

nitro: {
  routeRules: {
    '/**': { headers: { 'Permissions-Policy': 'unload=()' } }
  }
}

This tiny header stops iframes from firing the unload event. Why does it matter? Third-party iframes with old unload handlers break the browser's Back/Forward Cache. Once you remove unload, the page comes back instantly from memory when the user presses the back button, instead of loading again from disk. A free performance win.

Looking back

The whole strategy comes down to this. Strip all third parties out of the server HTML. Load the scripts on the client as late as you can. But to make sure you don’t lose a single event, protect everything with queue stubs. And fetch the heaviest one, reCAPTCHA, only when the user shows real intent. The three pieces support each other — the reason I can load late is that the queues have my back.

While writing this up, I also found a few gaps, to be honest. The most important one: there is currently no cookie consent layer, which means Hotjar and Pixel run without waiting for consent. That is a problem for GDPR and KVKK, so moving them behind a consent store is at the top of my list. JivoSite still uses a protocol-relative URL (//code.jivosite.com/...), which should be an explicit https://. The reCAPTCHA polling should become an onload Promise. And there are two GTM libraries left in package.json that are never imported and should be deleted.

I don’t see these as “failures.” I built a working system, then I sat down, criticized my own code, and wrote down where it can be better. The difference between good code and great code is usually exactly this list — not the absence of gaps, but being able to see them and put them in order.

For the next step, I’m thinking of also sharing the before and after Lighthouse scores. Without numbers, every optimization story stays a little bit in the air.


메타데이터
post_id
6b41389e52ac
slug
loading-third-party-scripts-without-choking-ssr-6b41389e52ac
url
https://medium.com/@erbeyinn/loading-third-party-scripts-without-choking-ssr-6b41389e52ac
canonical_url
https://medium.com/@erbeyinn/loading-third-party-scripts-without-choking-ssr-6b41389e52ac
author_url
https://medium.com/@erbeyinn
status
ok
fetched_at
2026-07-13 06:23:13