Scheduling dataLayer Events to optimize the INP
Deferring GTM events until layout stabilizes for imprved INP values

TL;DR: Improving INP by Optimizing Google Tag Manager
The Problem: Standard Google Tag Manager (dataLayer.push()) calls, especially when triggered immediately by user interactions (like clicks or taps), can delay the browser's ability to show visual updates. This negatively impacts the Interaction to Next Paint (INP) score because the browser is forced to process GTM tasks before rendering the visual feedback for that interaction.
The Solution: We can defer these dataLayer.push() calls until after the browser has painted the next frame. This prioritizes rendering immediate visual feedback for the user. The fix involves a small JavaScript snippet that modifies the default dataLayer.push() behavior to incorporate this deferral.
The Benefit: This approach commonly results in a 20ms to 100ms reduction in INP for our clients, often transforming failing Core Web Vitals scores into passing ones. Users experience a noticeably snappier interface. While data collection for GTM is slightly delayed (typically 50-250ms), this is an acceptable trade-off for most analytics and marketing purposes.
Addressing INP Challenges Caused by Google Tag Manager Execution
For one of our clients, we observed a 100ms reduction in their Interaction to Next Paint (INP) metric by simply re-scheduling when the dataLayer.push() function executes after a user interaction. This improvement was achieved using an easy-to-apply and test JavaScript "drop-in" replacement that prioritizes rendering.
Table of Contents!
- TL;DR: Improving INP by Optimizing Google Tag Manager
- The INP problem with dataLayer.push()
- The solution, prioritize paint, then push to the datalayer!
- Applying the Code
- Testing Made Easy: Global Override
- Why this helps the Interaction to Next Paint
- Why Not Just Use a Fixed Delay, idle Callback or Scheduler?
- Trade-offs
The INP problem with dataLayer.push()
If you've worked with Google Tag Manager (GTM), you're familiar with dataLayer.push()
. It's the standard method for sending data or events to the Data Layer, enabling tags to fire. It's widely used, deeply integrated into many site functionalities, and its performance implications are rarely questioned. However, assuming it always executes at the optimal moment for user experience can be problematic.
When dataLayer.push()
is called directly within an event handler for a user interaction (e.g., a button click), it typically executes synchronously. This means any GTM tags configured to fire based on that event will also attempt to execute immediately and block the main thread before a layout update. This blocking prevents the browser from quickly rendering the visual changes expected from the user's interaction (e.g., opening a menu, showing a loading spinner), leading to a poor INP score.
Let's examine what happens. The performance trace below, from a major news website, highlights GTM-related activity following a user interaction. In this instance, the GTM tasks took approximately 90ms to execute and with an overall INP value of 263ms this interaction fails the this Core Web Vitals!
The solution, prioritize paint, then push to the datalayer!
The solution is both simple and elegant, aligning with INP optimization best practices: prioritize the user's perception of speed. Instead of executing all code (interaction handling, visual updates, and GTM tracking) synchronously, we should:
- Execute the critical code for the visual update immediately.
- Allow the browser to paint these visual changes.
- Then, execute less critical code, like pushing events to the dataLayer.
This approach is often called "yielding to the main thread." Let's see the impact when we apply this yielding pattern to dataLayer.push()
calls on the same site and for the exact same interaction as before. The only difference is that we've scheduled the dataLayer.push()
to occur after the browser has had a chance to render the next frame using requestAnimationFrame
.
As you can see, the same interaction that previously failed now comfortably passes the Core Web Vitals. All the necessary data is still sent to the dataLayer. The crucial difference is that GTM-related scripts now execute after the browser has updated the layout in response to the user's action. This means your visitor gets immediate visual feedback, enhancing their experience, rather than waiting for tracking scripts to process.
Applying 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.
Await Paint helper function
This helper function uses requestAnimationFrame
to schedule a callback to run after the browser has painted the next frame.
// --- 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(); } }
Implementation example
This is an example of a React utility function that automatically schedules the dataLayer push.
export const pushToDataLayer = (event: string, data: Record<string, any> = {}): void => { // Ensure dataLayer exists if (typeof window !== 'undefined') { window.dataLayer = window.dataLayer || []; // wait for paint awaitPaint(() => { // Push event and data to dataLayer window.dataLayer.push({ event, ...data, timestamp: new Date().toISOString() }); }); } }; // Usage in a React component: // import { useState, useEffect } from 'react'; // import { pushToDataLayer } from '../utils/analytics'; // function ProductCard({ product }) { // const [isWishlisted, setIsWishlisted] = useState(false); // // Track wishlist changes // useEffect(() => { // if (isWishlisted) { // pushToDataLayer('addToWishlist', { // productId: product.id, // productName: product.name, // productPrice: product.price // }); // } // }, [isWishlisted, product]); // return ( // <div className="product-card"> // <h3>{product.name}</h3> // <p>${product.price}</p> // <button onClick={() => setIsWishlisted(!isWishlisted)}> // {isWishlisted ? '♥' : '♡'} {isWishlisted ? 'Wishlisted' : 'Add to Wishlist'} // </button> // </div> // ); // }
Testing Made Easy: Global Override
To quickly test this pattern across your entire site or for existing dataLayer.push() implementations without refactoring each one, you can globally override the dataLayer.push() function.
Important: Place this script high in the <head> of your HTML, immediately after the GTM container script is loaded. This ensures your override is in place as soon as possible.
<script type="module"> // Ensure dataLayer exists (standard GTM snippet part) window.dataLayer = window.dataLayer || []; // --- INP Yield Pattern Helper --- async function awaitPaint(fn) { return new Promise((resolve) => { const fallbackTimeout = setTimeout(() => { if (typeof fn === 'function') { fn(); } resolve(); }, 200); requestAnimationFrame(() => { setTimeout(() => { clearTimeout(fallbackTimeout); if (typeof fn === 'function') { fn(); } resolve(); }, 50); }); }); } // --- Applying the pattern to Google Tag Manager dataLayer.push globally --- if (window.dataLayer && typeof window.dataLayer.push === 'function') { // Preserve the original push function const originalDataLayerPush = window.dataLayer.push.bind(window.dataLayer); // Override dataLayer.push window.dataLayer.push = function (...args) { // Using an IIFE to use async/await syntax if preferred, // or directly call awaitPaint. (async () => { await awaitPaint(() => { // Call the original push with its arguments after yielding to paint originalDataLayerPush(...args); }); })(); // Return the value the original push would have, if any (though typically undefined) // For GTM, the push method doesn't have a meaningful return value for the caller. // The primary purpose is the side effect of adding to the queue. }; console.log('dataLayer.push has been overridden to improve INP.'); } </script>
Why this helps the Interaction to Next Paint
INP measures the latency from a user interaction (e.g., click, tap, key press) until the browser paints the next visual update in response to that interaction. If you synchronously execute resource-intensive tasks, such as GTM event processing and tag firing, immediately after an interaction, you block the browser's main thread. This prevents rendering of the visual feedback the user expects. By deferring non-critical JavaScript execution like GTM tracking until after the browser has painted the visual updates, this pattern ensures users receive fast visual feedback, significantly improving the INP score.
Why Not Just Use a Fixed Delay, idle Callback or Scheduler?
- Fixed
setTimeout(delay)
: Using a hardcoded delay (e.g., setTimeout(..., 100)) is essentially guessing when rendering will complete. It's not adaptive; it might be too long (delaying tracking unnecessarily) or too short (still blocking paint). requestIdleCallback
: This API schedules work when the browser is idle. While useful for background tasks, it doesn't guarantee execution promptly after a specific interaction's visual update. The callback might run much later or, during busy periods, not at all before the user navigates away.- Generic Schedulers (
postTask
etc.): While the browser'spostTask
scheduler offers prioritization,requestAnimationFrame
is specifically tied to the rendering lifecycle. TheawaitPaint
helper leverages this by usingrequestAnimationFrame
as a signal that the browser is preparing to paint, and then adds a minimal delay to fire after that paint is likely complete.
Trade-offs
Every optimization has potential trade-offs.
- Micro-delay in Data Collection: This technique introduces a predictable micro-delay (roughly 50-250ms, depending on browser load and the specific timeouts used in awaitPaint) before event data hits Google Tag Manager.
- Risk of Data Loss on Quick Exit: If a visitor triggers an event and then leaves the page within this micro-delay window (before the deferred dataLayer.push executes), that specific event data might not be sent. For critical events where this risk is unacceptable (e.g., immediately before a redirect or page unload), alternative tracking mechanisms like navigator.sendBeacon might be considered for those specific events, though this is outside the scope of the dataLayer.push override.
For most standard analytics and marketing tracking, this slight delay is inconsequential and a worthwhile trade-off for the significant improvement in user-perceived performance and INP scores. However, for ultra-low-latency scenarios (e.g., some types of real-time bidding interactions directly tied to GTM events, or highly sensitive financial dashboards where millisecond precision in event logging is paramount), this approach might not be suitable.
Otherwise, the gain in INP performance and user experience typically far outweighs the minimal data collection latency.Otherwise, the gain in INP performance and user experience typically far outweighs the minimal data collection latency.
Need your site lightning fast?
Join 500+ sites that now load faster and excel in Core Web Vitals.
- Fast on 1 or 2 sprints.
- 17+ years experience & over 500 fast sites
- Get fast and stay fast!

