Skip to content

Instantly share code, notes, and snippets.

@kurtextrem
Last active March 17, 2025 09:54
Show Gist options
  • Save kurtextrem/0a0d426976016937837f6b48710c0699 to your computer and use it in GitHub Desktop.
Save kurtextrem/0a0d426976016937837f6b48710c0699 to your computer and use it in GitHub Desktop.
Tame GTM & 3p scripts to improve INP

Tame GTM & 3p scripts to improve INP

Add this to before you load any 3p (especially GTM) in the document.

What it does is when it executes:

  • for click, auxclick, mousedown, keyup and submit, installs a document level addEventListener override that intercepts added listeners if it's likely they from from a 3p (-> based on the 3rd argument passed to the fn)

On the document load event (so that it executes after GTM etc.):

  • for the same events, installs a document.body level override
  • overrides dataLayer.push and gtag() to yield first
  • overrides history.pushState and history.replaceState to yield first

Explainer for what we achieve with the document/document.body listeners:

  • the document one overrides capturing listeners - by overrding addEventListener (so at event registration time)
  • the document.body one the non-capturing - by stopping propagation at body (before it reaches document), and then re-dispatching the event (cloned)

Shortcomings:

  • it doesn't fight the hashchange, popstate, HTMLFormElement.prototype.submit override and also not the .innerText call that causes a reflow if you have individualElementId listeners
  • I only tested this with a React root that is NOT attached to the body element (this is important, as it ensures we don't intercept Reacts event listeners - else we pretty much yield before doing anything, defeating the metrics purpose)

Yield method:

  • yieldUnlessUrgent slightly modified from Philip Walton (added pagehide) + await-interaction-response

GTM event listener analysis

By using the snippet from log-listeners.js & DevTools overrides, I've monitored which 3p scripts add which listeners and how:

  • GTM adds a listener to document right when it loads, with the 3rd param of addEventListener set to false <-- this is the listener that also does .innerText to set the gtm.elementText data
  • Then, for "individualElementIds" GTM adds another listener on document with the third param set to true (this is the capturing listener)
  • It also listens to hashchange, popstate, pageshow
  • FB & GTM override history.pushState and history.replaceState
  • lftracker (some random 3p thing) adds to document too, with the 3rd param set to true
  • GA adds a document listener with the 3rd param set to false
  • FB / Meta tracker adds a document listener with the 3rd param set to {capture: true, once: false, passive: true}

GTM also does this when it loads:

var f = HTMLFormElement.prototype.submit;
HTMLFormElement.prototype.submit = function() {
  d(this);
  f.call(this)
}
const originalAddEventListener = Element.prototype.addEventListener
// Override the addEventListener method
Element.prototype.addEventListener = function(type, listener, options) {
console.log(`Adding event listener for event type: ${type}`, listener, options, this);
// Call the original addEventListener method
originalAddEventListener.call(this, type, listener, options);
}
const originalAddEventListener1 = document.addEventListener;
document.addEventListener = function(type, listener, options) {
console.log(`-- Adding event listener for event type: ${type}`, listener, options);
// Call the original addEventListener method
originalAddEventListener1.call(this, type, listener, options);
}
// turns on override logs
const DEBUG = false
/** A set to keep track of all unresolved yield promises */
const pendingResolvers = new Set<VoidFunction>()
/** Resolves all unresolved yield promises and clears the set. */
function resolvePendingPromises() {
for (const resolve of pendingResolvers) resolve()
pendingResolvers.clear()
}
// eslint-disable-next-line compat/compat -- we check for the existence of the properties
const scheduler = window.scheduler
const canUseYield = scheduler && "yield" in scheduler
const canUsePostTask = scheduler && "postTask" in scheduler
/**
* Returns a promise that, if the document is visible, will resolve in a new
* task in the next frame. If the document is not visible (or changes to
* hidden prior to the promise resolving), the promise is resolved immediately.
*
* Modified from Philip Walton's original snippet.
*/
function yieldUnlessUrgent(important = false) {
return new Promise<void>(resolve => {
pendingResolvers.add(resolve)
if (document.visibilityState === "visible") {
// visibilitychange + pagehide is needed to reliably (±97%) detect when the page is hidden cross-browser
// see https://nicj.net/beaconing-in-practice-fetchlater/#beaconing-in-practice-fetchlater-onload-or-pagehide-or-visibilitychange
document.addEventListener("visibilitychange", resolvePendingPromises)
document.addEventListener("pagehide", resolvePendingPromises)
// await-interaction-response without a setTimeout fallback, as the fallbacks are the event listeners above
requestAnimationFrame(async () => {
const resolveFn = () => {
pendingResolvers.delete(resolve)
resolve()
}
if (important) {
if (canUseYield) {
// @ts-expect-error TS(2353)
await scheduler.yield()
resolveFn()
} else if (canUsePostTask) {
// @ts-expect-error TS(2353)
void scheduler.postTask(resolveFn)
} else {
setTimeout(resolveFn, 0)
}
} else {
// if it isn't important, we can use a timeout with lowest prio (delay=1) instead.
setTimeout(resolveFn, 1)
}
})
return
}
// Still here? Resolve immediately.
resolvePendingPromises()
})
}
type DataLayerPush = (...items: object[]) => boolean
type DataLayer = Omit<object[], "push"> & {
push: DataLayerPush
}
function cloneEvent(event: Event) {
// @ts-expect-error TS(2339): We already cloned this event
if (event.__originalEvent) return event
// @ts-expect-error TS(2351): TS doesn't know that event.constructor is a function
const newEvent = new event.constructor(event.type, event)
// the following need to be set to read-only or they get overridden when dispatching
const keysToDefine = ["target", "srcElement", "offsetX", "offsetY", "layerX", "layerY", "toElement"] as const
for (const key of keysToDefine) {
if (key in event) {
Object.defineProperty(newEvent, key, {
writable: false, // need to prevent write, else this gets overriden when dispatching.
value: event[key],
})
}
}
newEvent.__originalEvent = event
if (DEBUG) {
// biome-ignore lint/suspicious/noConsole: debug
console.log(event, newEvent)
const readOnlyProps = [
"__originalEvent",
"isTrusted",
"currentTarget",
"eventPhase",
"defaultPrevented",
"composed",
"timeStamp",
"bubbles",
"cancelable",
"cancelBubble",
]
const differentProps: string[] = []
const missingProps: string[] = []
for (const prop in event) {
if (typeof event[prop] === "function" || readOnlyProps.includes(prop)) continue
if (newEvent[prop] === undefined) {
missingProps.push(prop)
} else if (event[prop] !== newEvent[prop]) {
differentProps.push(prop)
}
}
if (differentProps.length || missingProps.length) {
// biome-ignore lint/suspicious/noConsole: debug
console.log(`Different: ${differentProps.join(", ")}`, `Missing: ${missingProps.join(", ")}`)
}
}
return newEvent
}
function replaceGTM() {
const origLayer = window.dataLayer as DataLayer | undefined
const origPush = origLayer?.push
if (!origPush) return
// must be a (non-async) function, not an arrow function, because we need to bind the original `this` context
// the GTM dataLayer push function returns `true` if the push was successful, we just assume it is.
// the `...args` is needed so the call results in the exactly same result as the original
function yieldingPush(...args: unknown[]) {
void yieldUnlessUrgent().then(function innerPush() {
// in case we override the native Array#push here, we need to set the original array as `this` to not cause runtime errors
// biome-ignore lint/style/noNonNullAssertion: we know it exists from the lines above
origPush!.apply(origLayer!, args)
})
return true
}
origLayer.push = yieldingPush
return yieldingPush
}
let yieldingGTMFn: Function | undefined
function overrideDataLayer(eventName?: string) {
const hasOverriddenGTM = yieldingGTMFn === window.dataLayer?.push
if (DEBUG) {
// biome-ignore lint/suspicious/noConsole: debug
console.log(eventName, hasOverriddenGTM)
}
if (!hasOverriddenGTM) {
yieldingGTMFn = replaceGTM()
// we don't override GA/gtag as they are just wrappers around dataLayer.push
}
}
// Intercept capturing event listeners:
const originalAddEventListener = document.addEventListener
const typesToIntercept = ["click", "auxclick", "mousedown", "keyup", "submit"] as const
type EventType = (typeof typesToIntercept)[number]
document.addEventListener = function (
type: string,
listener: EventListenerOrEventListenerObject,
options: boolean | AddEventListenerOptions | undefined
) {
if (
typesToIntercept.includes(type as EventType) &&
(options === true || (typeof options === "object" && options.capture))
) {
// biome-ignore lint/suspicious/noConsole: debug
if (DEBUG) console.log(`Overriding ${type} listener`, listener)
// Note: This listener also receives dispatches from our document.body interceptor.
// This means, sometimes it might yield twice. This is ok, as it can potentially split
// 3rd-party-listeners across multiple frames.
// Tiny "TODO": We could explicitly 'waterfall' those listeners:
// yield -> schedule next task -> yield -> schedule next task instead of
// yield -> all run in one task
originalAddEventListener.call(
this,
type,
async function overriddenEventListener(this: unknown, e) {
const event = cloneEvent(e) // clone at the time of interception
await yieldUnlessUrgent(true) // might be user visible and not only GTM listeners
if (typeof listener === "function") {
listener.call(this, event)
} else {
listener.handleEvent(event)
}
},
options
)
return
}
// if not capturing, we can just call the original addEventListener function
originalAddEventListener.call(this, type, listener, options)
}
// readystatechange runs exactly before DOMContentLoaded, which is needed to ensure we call stopPropagation early (first listener added runs first)
document.addEventListener("readystatechange", () => {
if (document.readyState !== "interactive") return
const body = document.body
async function interceptEvent(e: Event) {
e.stopPropagation()
const event = cloneEvent(e) // clone at the time of interception
await yieldUnlessUrgent(true) // might be user visible and not only GTM listeners
document.dispatchEvent(event)
}
// Intercept `document` level listeners that are not capturing.
for (const element of typesToIntercept) {
body.addEventListener(element, interceptEvent)
}
// override dataLayer.push
overrideDataLayer(DEBUG ? "readystatechange" : undefined)
})
// check if something naughty has overridden them again
// TODO: Those listeners are a bit naive, a better way would be to use a MutationObserver to check for script insertions, or
// to at least add an event listener on all <script> tags
document.addEventListener("DOMContentLoaded", () => {
overrideDataLayer(DEBUG ? "DOMContentLoaded" : undefined)
})
window.addEventListener("load", () => {
overrideDataLayer(DEBUG ? "load" : undefined)
})
@dangayle
Copy link

This is fantastic, but could you also document what user issue this solves so the context is clear?

@kurtextrem
Copy link
Author

This is fantastic, but could you also document what user issue this solves so the context is clear?

I added an h1 quoting the gist title

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment