How I optimized Conversion Tracking of Google Analytics ? (against Core Web Vitals)
- Part 1: The GA4 Performance Paradox: How Default Analytics Destroy Core Web Vitals (And How to Fix It)
- Part 2: The Meta Pixel Performance Penalty: How Default Tracking Kills Core Web Vitals (And How to Fix It)
- Part 3: The AdSense Performance Trap: How Default Ad Codes Ruin Core Web Vitals (And How to Fix It)
- Part 4: How I optimized Conversion Tracking of Google Analytics ? (against Core Web Vitals)
- Part 5: How to Optimize Meta Pixel Tracking Without Destroying Core Web Vitals
If you’ve ever had to balance the demands of a marketing team with the harsh realities of Google’s Core Web Vitals, you know the struggle. Marketers need granular conversion data to measure campaign success, but the tracking scripts they ask you to install are notorious for destroying page performance — specifically Total Blocking Time (TBT) and Interaction to Next Paint (INP).
Recently, I was tasked with tracking various Call-to-Action (CTA) clicks across a web application. We needed to track when users clicked on WhatsApp links, Phone numbers, Facebook Messenger widgets, and standard contact pages.
I went through a few iterations to get this right, trying to find the perfect balance between data accuracy, DOM efficiency, and network performance. Here is the journey of how I evaluated my options and landed on the ultimate hybrid solution.
The Dilemma: Three Different Approaches 🔗
When I first approached the problem, I mapped out three potential implementations.
Implementation #1: The Event Delegation Approach 🔗
My first instinct was to prioritize DOM efficiency. Instead of attaching a listener to every single button on the page, I used a single global listener on the document and checked for clicks on links.
document.addEventListener('click', function (event) {
const link = event.target.closest('a[href]');
if (!link || typeof gtag !== 'function') return;
const url = link.href.toLowerCase();
let ctaType = '';
if (url.startsWith('tel:')) ctaType = 'phone';
else if (url.includes('wa.me')) ctaType = 'whatsapp';
// ... string matching logic
});
The Verdict: The logic here was highly scalable. Event delegation means it only uses one listener (O(1) memory footprint), and it perfectly captures dynamically injected elements. The problem? I was still loading the heavy gtag.js library synchronously in the <head>, which meant my Core Web Vitals were taking a hit on initial load.
The full implementation I found on 3 Mak company website was:
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXX');
</script>
<script>
document.addEventListener('click', function (event) {
const link = event.target.closest('a[href]');
if (!link || typeof gtag !== 'function') return;
const url = link.href;
const normalizedUrl = url.toLowerCase();
let ctaType = '';
if (normalizedUrl.startsWith('tel:')) ctaType = 'phone';
else if (normalizedUrl.includes('wa.me') || normalizedUrl.includes('api.whatsapp.com')) ctaType = 'whatsapp';
else if (normalizedUrl.includes('facebook.com/3makupvc') || normalizedUrl.includes('m.me/3makupvc')) ctaType = 'facebook';
else if (normalizedUrl.includes('kartbusiness.com/cards/48')) ctaType = 'business_listing';
else if (normalizedUrl.includes('/contact')) ctaType = 'contact_page';
if (!ctaType) return;
gtag('event', 'cta_click', {
cta_type: ctaType,
link_url: url,
link_text: link.textContent.trim().slice(0, 100),
page_location: window.location.href
});
});
</script>
Implementation #2: The Lazy Loading Approach 🔗
To fix the Core Web Vitals issue, I wrote a second implementation that deferred the Google Analytics script until the browser was idle.
function loadAnalytics() {
var script = document.createElement("script");
script.async = true;
script.src = "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX";
document.head.appendChild(script);
}
if ("requestIdleCallback" in window) {
window.requestIdleCallback(loadAnalytics, { timeout: 2000 });
}
The Verdict: The network performance was excellent. By using requestIdleCallback, I unblocked the main thread and saved my LCP and INP scores.
However, the tracking logic I paired it with was deeply flawed. I waited for DOMContentLoaded and used querySelectorAll to attach individual event listeners to every single CTA node. Not only does this waste memory, but if a CTA was rendered late via AJAX or a modal, it wouldn’t be tracked at all.
The full implementation I found on Jo UPVC company website was:
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
<!-- Queue analytics immediately, but defer the third-party request until the page is idle. -->
<script type="text/javascript" defer>
function loadAnalytics() {
var script = document.createElement("script");
script.async = true;
script.src = "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX";
document.head.appendChild(script);
}
if ("requestIdleCallback" in window) {
window.requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
window.addEventListener("load", loadAnalytics, { once: true });
}
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
document.addEventListener('DOMContentLoaded', function () {
var rules = [
{
selector: 'a[href*="facebook.com/101256695158275/"]',
event: 'facebook_link_click',
category: 'Social',
label: 'Facebook Page'
},
{
selector: 'a[href*="facebook.com/upvc.windows.doors.egypt"]',
event: 'facebook_link_click',
category: 'Social',
label: 'Facebook Page'
},
{
selector: 'a[href*="wa.me/201007723435"]',
event: 'whatsapp_link_click',
category: 'Contact',
label: 'WhatsApp Direct'
},
{
selector: 'a[href^="tel:01007723435"]',
event: 'phone_call_click',
category: 'Contact',
label: 'Phone Call'
},
{
selector: 'a[href*="api.whatsapp.com/send"]',
event: 'whatsapp_widget_click',
category: 'Contact',
label: 'WhatsApp Floating Widget'
},
{
selector: 'a[href*="m.me/upvc.windows.doors.egypt"]',
event: 'messenger_widget_click',
category: 'Contact',
label: 'Messenger Floating Widget'
},
{
selector: 'a[href*="m.me/101256695158275"]',
event: 'messenger_widget_click',
category: 'Contact',
label: 'Messenger Floating Widget'
}
];
rules.forEach(function (rule) {
var elements = document.querySelectorAll(rule.selector);
elements.forEach(function (element) {
element.addEventListener('click', function () {
if (typeof gtag === 'function') {
gtag('event', rule.event, {
'event_category': rule.category,
'event_label': rule.label,
'value': 1
}
);
}
if (window.dataLayer && Array.isArray(window.dataLayer)) {
window.dataLayer.push({
'event': rule.event,
'event_category': rule.category,
'event_label': rule.label
}
);
}
});
});
});
});
</script>
Implementation #3: The Flawed Fallback 🔗
My third option was a mix of standard synchronous script loading with the same flawed querySelectorAll logic from Implementation #2. It lacked the lazy-loading performance benefits and still suffered from the fragile DOM binding.
The full implementation I naively wrote was:
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/javascript" defer>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX'); // Analytics
gtag('config', 'AW-XXXXXXXXXX'); // Ads
document.addEventListener('DOMContentLoaded', function () {
var rules = [
{
selector: 'a[href*="facebook.com/SWEAbanoubHanna"]',
event: 'facebook_link_click',
category: 'Social',
label: 'Facebook Page'
},
{
selector: 'a[href*="wa.me/20"]',
event: 'whatsapp_link_click',
category: 'Contact',
label: 'WhatsApp Direct'
},
{
selector: 'a[href^="tel:01"]',
event: 'phone_call_click',
category: 'Contact',
label: 'Phone Call'
},
{
selector: 'a[href*="api.whatsapp.com/send"]',
event: 'whatsapp_widget_click',
category: 'Contact',
label: 'WhatsApp Floating Widget'
},
{
selector: 'a[href*="m.me/SWEAbanoubHanna"]',
event: 'messenger_widget_click',
category: 'Contact',
label: 'Messenger Floating Widget'
}
];
rules.forEach(function (rule) {
var elements = document.querySelectorAll(rule.selector);
elements.forEach(function (element) {
element.addEventListener('click', function () {
if (typeof gtag === 'function') {
gtag('event', rule.event, {
'event_category': rule.category,
'event_label': rule.label,
'value': 1
});
}
if (window.dataLayer && Array.isArray(window.dataLayer)) {
window.dataLayer.push({
'event': rule.event,
'event_category': rule.category,
'event_label': rule.label
});
}
});
});
});
});
</script>
The Ultimate Solution: The Optimized Hybrid Approach 🔗
I realized I needed the network loading strategy of Implementation #2 combined with the event delegation logic of Implementation #1.
By merging the two, I achieved a script that protects Core Web Vitals without losing a single piece of conversion data.
Here is the final, optimized approach:
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
<script type="text/javascript">
// 1. Initialize dataLayer queue immediately so tracking works even before the library loads
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
// 2. Defer heavy third-party script loading until the main thread is idle
function loadAnalytics() {
const script = document.createElement("script");
script.async = true;
script.src = "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX";
document.head.appendChild(script);
}
if ("requestIdleCallback" in window) {
window.requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
window.addEventListener("load", loadAnalytics, { once: true });
}
// 3. Use Event Delegation for O(1) memory footprint and dynamic DOM support
document.addEventListener('click', function (event) {
const link = event.target.closest('a[href]');
if (!link) return;
const url = link.href.toLowerCase();
let ctaType = '';
if (url.startsWith('tel:')) ctaType = 'phone';
else if (url.includes('wa.me') || url.includes('api.whatsapp.com')) ctaType = 'whatsapp';
else if (url.includes('facebook.com/') || url.includes('m.me/')) ctaType = 'facebook';
else if (url.includes('kartbusiness.com/')) ctaType = 'business_listing';
else if (url.includes('/contact')) ctaType = 'contact_page';
if (!ctaType) return;
// gtag is guaranteed to be a function here, queuing if the library is still downloading
gtag('event', 'cta_click', {
cta_type: ctaType,
link_url: link.href,
link_text: link.textContent.trim().slice(0, 100),
page_location: window.location.href
});
});
</script>
This approach is used on UPVC EG website . And it is the best implementation I know of.
Why this approach wins on all fronts 🔗
- Zero Data Loss: Notice that
window.dataLayer = []and thegtag()function are initialized immediately. If a user clicks a WhatsApp link in the first 500 milliseconds before the main GA4 script finishes downloading, the event is safely queued in memory. As soon as the library loads, the queue is processed. - Perfect Network Performance: Deferring the actual
gtag.jsdownload viarequestIdleCallbackprevents the tracking script from competing with critical assets (like hero images and CSS) during the initial page load. - Bulletproof Logic: Using
event.target.closest('a[href]')on the document level means I never have to worry about dynamically injected React/Vue components or AJAX modals. The listener catches everything that bubbles up. - Clean GA4 Schema: Instead of scattering a dozen different custom events (
whatsapp_click,phone_click), this approach uses a singlecta_clickevent with acta_typeparameter. This makes building reports in GA4 significantly easier.
Optimizing analytics doesn’t have to mean sacrificing data. By understanding how the browser’s main thread and event bubbling work, you can keep both your marketing team and your Lighthouse scores perfectly happy.
I hope you enjoyed reading this post as much as I enjoyed writing it. If you know a person who can benefit from this information, send them a link of this post. If you want to get notified about new posts, follow me on YouTube , Twitter (x) , LinkedIn , and GitHub .
- Part 1: The GA4 Performance Paradox: How Default Analytics Destroy Core Web Vitals (And How to Fix It)
- Part 2: The Meta Pixel Performance Penalty: How Default Tracking Kills Core Web Vitals (And How to Fix It)
- Part 3: The AdSense Performance Trap: How Default Ad Codes Ruin Core Web Vitals (And How to Fix It)
- Part 4: How I optimized Conversion Tracking of Google Analytics ? (against Core Web Vitals)
- Part 5: How to Optimize Meta Pixel Tracking Without Destroying Core Web Vitals