Scheduling dataLayer Events to optimize the INP

Deferring GTM events until layout stabilizes for imprved INP values

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2025-05-02

The INP problem with dataLayer.push() 

If you have ever worked with Google Tag manager you know that  dataLayer.push() is used to send data or events to the Data Layer. It’s widely used, deeply integrated, and rarely questioned. But assuming it always executes at the right moment is optimistic at best. 

Let's take a deep dive and take a look at what actually happens when we 'just use' dataLater.push() without any scheduling. Below there is a network trace of one of the world's largest news sites. I have highlighted all GTM related activity and with 90ms total execution time the Interaction to Next Paint fails with a total INP value of 263ms.

datalayer push inp


The solution, prioritize paint, then push to the datalayer!

The solution is simple and elegant and based on INP best practice. What if, instead of trying to run all our code at once, we first run the most important code immediately, update the layout and only then run our less important code (like pushing events to the dataLayer). 

Let's see what happens when we apply a yield pattern to our dataLayer. This is the network trace for the exact same site and exact same interaction as above. The only difference: I have yielded to the main thread by scheduling the dataLayer push after our next Animation frame.

datalayer push inp yeilded

As you can see the same interaction that was just failing is now passing the Core Web Vitals with ease and all the data is still being sent to the dataLayer. The only difference is that GTM related scripts are now executed after the browser has updated the layout. That means you visitor will get immediate visual feedback after interaction with the site and does not have to wait for back-end data to be processed and send!

The code

The code works by overriding the default dataLayer.push() function with a modified function that pushes the data to the dataLayer after a layout update has been done.

// --- INP Yield Pattern Implementation ---

// This helper ensures that a function only runs after the next paint (or safe fallback)
async function awaitPaint(fn) {
    await new Promise((resolve) => {
        // Fallback timeout: ensures we don’t hang forever if RAF never fires
        setTimeout(resolve, 200); 

        // Request the next animation frame (signals readiness to paint)
        requestAnimationFrame(() => {
            // Small delay to ensure the frame is actually painted, not just queued
            setTimeout(resolve, 50);
        });
    });

    // Once the paint (or fallback) happens, run the provided function
    if (typeof fn === 'function') {
        fn();
    }
}

// --- Applying the pattern to Google Tag Manager dataLayer.push ---

// Ensure dataLayer exists
window.dataLayer = window.dataLayer || [];

if (typeof window.dataLayer.push === 'function') {
    // Preserve the original push function
    const originalDataLayerPush = window.dataLayer.push;

    // Override dataLayer.push to defer execution until after paint
    window.dataLayer.push = function (...args) {
        awaitPaint(() => {
            // Call the original push with its arguments after yielding to paint
            originalDataLayerPush.apply(window.dataLayer, args);
        });
    };
}

The first function awaitPaint(fn) is a helper function. It delays execution of a function until after the next frame is actually painted:

  • requestAnimationFrame() schedules something to run right before the next paint.
  • But just scheduling isn’t enough, you want to run after the paint.
  • That’s why it adds a small setTimeout(resolve, 50) inside the RAF.
  • The outer setTimeout(resolve, 200) is a fail-safe, so it doesn't hang if something blocks the Animation Frame.

Once this promise resolves, it executes the callback function  fn (if provided).

The next code is to patch dataLayer.push()

  • It wraps any call to dataLayer.push() so that it yields to the next paint before pushing.
  • This avoids blocking render-critical tasks when tracking events (e.g., GTM or analytics) right after a user interaction.

Why this helps the Interaction to Next Paint

INP (Interaction to Next Paint) measures the delay between a user interaction and the next visual updateIf you immediately run something heavy (like tracking or logging) right after a click, it blocks rendering. By deferring the tracking code until after the browser paints, this pattern ensures users get fast visual feedback before any heavy work begins.

Why Not Just Use a Fixed Delay, idle Callback or Scheduler?

A hardcoded timeout guesses at when rendering finishes. An Idle Callback runs when the browser is idle, not when it has painted. And the scheduler does not have a built in hook to run after paint.

The INP yield pattern does not guess, it uses a browser-native signal (requestAnimationFrame) with a minimal safety net (the timeout).

This approach is adaptive, accurate, and clean. The original dataLayer.push is untouched, only its timing improves.

Trade-offs

Of course everything you do will come at a cost. In this case this adds a predictable micro-delay of roughly 50–250 ms before events hit GTM. If a visitor were to leave the page within this micro delay data might not be sent!

For most analytics and marketing contexts, this is inconsequential. For ultra-low-latency scenarios (real-time bidding, financial dashboards) this might be a really poor idea. Otherwise, the gain in metric accuracy far outweighs the cost.

Need your site lightning fast?

Join 500+ sites that now load faster and excel in Core Web Vitals.

Let's make it happen >>

  • Fast on 1 or 2 sprints.
  • 17+ years experience & over 500 fast sites
  • Get fast and stay fast!
Scheduling dataLayer Events to optimize the INPCore Web Vitals Scheduling dataLayer Events to optimize the INP