Does Analytics Slow Down Your Website? How We Went From Lighthouse 66 to 94
- The four runs
- Run 1: the obvious fixes
- Run 2: make the fonts non-blocking, make the image smaller
- Finding what was actually in the way
- Why "async" isn't enough
- Run 3: start analytics after the page has loaded
- The trade-off
- What we'd tell ourselves before starting
- How to track page speed over time (so this doesn't happen silently)
We build an SEO monitoring tool. So it was a little embarrassing to run Lighthouse on our own landing page and get this:
- Performance: 66
- Largest Contentful Paint: 5.0 s (red — Google's "good" threshold is 2.5 s)
- Cumulative Layout Shift: 0.185 (also bad — the threshold is 0.1)
- Total Blocking Time: 210 ms
The page is not complicated. One hero image, some text, a few logos, a FAQ. No framework, no client-side rendering. And yet it took five seconds on emulated mobile to show its main image.
We fixed it in four Lighthouse runs over one afternoon. The interesting part is not the final score. It's that the two fixes everyone recommends first — preload the hero image and make it smaller — barely moved LCP. The fix that did was something we didn't suspect at all: our analytics.
The four runs
All runs are Lighthouse mobile (emulated Moto G Power, simulated slow 4G), on the same URL, on the same day.
| Run | What we changed | Performance | FCP | LCP | TBT | CLS |
|---|---|---|---|---|---|---|
| 0 | Baseline | 66 | 2.6 s | 5.0 s | 210 ms | 0.185 |
| 1 | Fonts as <link>, preload hero, image dimensions, defer on our script, cache headers |
76 | 2.6 s | 5.3 s | 30 ms | 0.016 |
| 2 | Fonts loaded async, hero image 66 KB → 33 KB | 81 | 0.8 s | 5.1 s | < 200 ms | < 0.1 |
| 3 | Analytics start after page load, below-the-fold logos lazy | 94 | 0.8 s | 2.6 s | < 200 ms | < 0.1 |
For runs 2 and 3 we only recorded that TBT and CLS stayed in the green zone, so the table shows the threshold instead of an exact number.
Look at the LCP column. Three rounds of "standard" fixes: 5.0 → 5.3 → 5.1 seconds. Then one change: 2.6 seconds.
Run 1: the obvious fixes
Lighthouse listed the usual suspects. We fixed all of them.
Images without width and height. The hero image had no width/height attributes, so the browser didn't know how much space to reserve. When the image arrived, everything below it jumped down. That was almost the entire CLS score.
<img src="/static/hero-400.webp" width="400" height="299" fetchpriority="high" alt="...">
CLS went from 0.185 to 0.016. This is the cheapest performance fix in existence and it's still the most commonly missed one.
A synchronous script. Our small telemetry script was loaded with a plain <script src> at the end of the body. Adding defer let the parser continue without waiting. After this round TBT was down from 210 ms to 30 ms — we changed several things at once, so we can't credit defer alone.
Google Fonts via @import. This one is sneaky. We loaded Inter with an @import at the top of an inline <style> block. That creates a chain: the browser parses HTML, finds the style block, finds the @import, fetches the font CSS, finds the @font-face rules, fetches the font files. Each step waits for the previous one. We replaced it with a regular <link> plus preconnect:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
Preload the hero image. The LCP image was only discovered when the parser reached the <img> tag. A preload in <head> lets the browser start the download immediately.
Cache headers. Our static files were served without Cache-Control. Now they get public, max-age=2592000, immutable. This helps repeat visitors, not a first-load Lighthouse run, but Lighthouse flagged 169 KB of savings, so we fixed it.
Result: Performance 66 → 76, CLS and TBT fixed. LCP: 5.0 → 5.3 s. Slightly worse, within run-to-run noise. The preload did nothing visible.
Run 2: make the fonts non-blocking, make the image smaller
A stylesheet in <head> still blocks rendering, even when it's a <link> and not an @import. The browser won't paint text until it has the CSS. For a font file that's not acceptable, so we loaded it asynchronously:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"></noscript>
The page now paints immediately with the system font and switches to Inter when it arrives. First Contentful Paint went from 2.6 s to 0.8 s.
We also noticed the hero image was a 600 px file displayed at 400 px at most. We made a 400 px version (66 KB → 33 KB) and served the original only to high-density screens via srcset.
Result: Performance 81. FCP 0.8 s. LCP: 5.1 s.
At this point the page started painting in under a second, the LCP image was half the size and preloaded with high priority — and the image still took five seconds to show up. Something else was in the way.
Finding what was actually in the way
First we confirmed which element was the LCP. You can ask the browser directly:
new PerformanceObserver(list => {
list.getEntries().forEach(e => console.log(e.element, Math.round(e.startTime)));
}).observe({ type: 'largest-contentful-paint', buffered: true });
It was the hero image (about 43,000 px² on a phone screen, slightly larger than the subtitle paragraph). So the question became: why does a 33 KB preloaded image take seconds to arrive?
Then we listed every request the page made in its first moments:
performance.getEntriesByType('resource').map(r => [r.name, Math.round(r.startTime)]);
Right next to the hero image, in the same few milliseconds, the page was starting:
- PostHog: the loader, the session recorder, dead-click autocapture, surveys, web-vitals
- Google Analytics (
gtag.js) - six partner logos further down the page
Lighthouse had been telling us this all along under "Reduce unused JavaScript — 196 KiB". We had read that as "some bytes are wasted". The real problem was different: those bytes were downloading at the same time as the LCP image.
Why "async" isn't enough
Our Google Analytics tag already had async. PostHog's snippet injects its script as async too. Async means "don't block HTML parsing". It does not mean "wait". The download starts right away and competes for the same network connection.
Lighthouse's mobile test simulates a slow 4G connection — about 1.6 Mbps. At that speed, roughly 200 KB of analytics JavaScript is more than a second of network time, shared with everything else that's loading. A 33 KB image isn't slow by itself. It's slow when it shares the pipe with six times its weight in scripts.
Run 3: start analytics after the page has loaded
The fix was to wrap the analytics bootstrap in a load listener, so nothing starts downloading until the page — including the hero image — has finished:
<script>
window.addEventListener('load', function () {
// PostHog: paste the standard snippet here, then:
posthog.init('phc_YOUR_KEY', { api_host: 'https://eu.i.posthog.com' });
// Google Analytics: inject gtag.js only now
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
document.head.appendChild(s);
});
// The dataLayer queue is cheap; keep it immediate so nothing is lost
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
We also added loading="lazy" to the logos below the fold, so they stop competing for bandwidth in the first second.
Result: Performance 94. LCP 2.6 s. Then we checked in the browser that analytics still works: the load event fired at 584 ms, PostHog and gtag started at 585 ms, and pageviews arrived in both tools.
The trade-off
Starting analytics after load has a cost, and you should decide consciously whether you accept it:
- A visitor who leaves before the page finishes loading is not counted. On a fast connection that window is well under a second. On a slow phone it's longer.
- Session recordings start a moment later, so the first fraction of a second isn't recorded.
For a marketing site where we care about what people do after they see the page, that's an easy yes. For a site where every bounce must be counted, you might prefer a lighter analytics setup instead — for example, turning off PostHog's session recording and autocapture on landing pages.
What we'd tell ourselves before starting
- Find the LCP element first, then look at what loads next to it. We spent two rounds optimizing the image itself. The image was never the problem.
- "Async" third-party scripts still compete for bandwidth. Async isn't free.
- The fixes with the biggest effect were the cheapest. Width/height on one image fixed CLS. One
loadlistener halved LCP. - The Lighthouse score is a lab number. Google ranks pages using field data from real Chrome users (Core Web Vitals), not the Lighthouse score. A new site like ours doesn't have enough traffic for field data yet, so the lab test is the best signal we have — but the goal is fast pages for real users, not a green circle.
- LCP 2.6 s is still just above the 2.5 s "good" line. We're not done.
How to track page speed over time (so this doesn't happen silently)
None of our problems were new. The @import, the missing image dimensions, the analytics competing with the hero — they had all been there for weeks. Nobody noticed because nobody looked. A single Lighthouse run tells you how a page is today. It doesn't tell you when it got worse, or what caused it.
What actually works:
- Test the same URLs on a schedule, not just when you remember. Your homepage and your top landing pages, mobile strategy, daily or weekly. The PageSpeed Insights API is free with a key.
- Track the metrics, not just the score. LCP, CLS and TBT tell you what broke. The score only tells you that something did.
- Expect noise. Individual runs vary by a few hundred milliseconds — our "worse" LCP in run 1 was noise. Look at trends over several days, not one run.
- Log what you change. When the line moves, you want to see "added a chat widget on Tuesday" right next to it. That's how you connect a regression to its cause in minutes instead of days.
We wrote a longer guide on this: Page Speed Monitoring: What to Track and How Often.
Seodile runs PageSpeed checks for your pages on a schedule, keeps the history, and shows your own events — deploys, new scripts, redesigns — on the same chart, so a regression comes with its likely cause attached.
Start a free audit → — no credit card, no setup.
Related
- Page Speed Monitoring: What to Track and How Often — the metrics that matter and how often to measure them
- Technical SEO Audit Checklist: 21 Things to Check on Every Page — speed is one of them
- 5 Website Changes That Kill SEO Rankings Overnight — the silent changes worth monitoring
- Why Did My SEO Rankings Drop? — when a regression has already cost you traffic
Try Seodile Free
Run a technical SEO audit in minutes. Monitor content changes 24/7. Track rankings across Google, Yandex, and Bing.
Get Started Free