The mobile PageSpeed score of this very site was 77: a 4.3-second LCP and a 5.1-second Speed Index. Not a great look for a developer’s portfolio. Here is what was actually slowing it down, how we found it, and what fixed it – no “just install a caching plugin”, only causes, numbers and concrete changes.
Start by measuring, not guessing
Rule one: don’t fix things blindly. PageSpeed Insights is handy, but it has daily quotas and its results drift between runs. So we ran the same engine locally (Lighthouse, mobile mode) and took the median of three runs. The most useful part of the report isn’t the overall score – it’s the LCP breakdown by phase.
It pointed straight at the problem: the server answered in 0.2 s, while 2.4 s went to “element render delay”. In other words, the page had long finished loading, but its largest element – the hero heading – still wasn’t visible.
Cause #1: the heading was waiting for JavaScript
The hero section had a nice GSAP entrance animation. But in CSS the heading, subtitle and buttons were set to opacity: 0, and a script revealed them. The chain looked like this:
- download the JS bundle with GSAP;
- wait for
DOMContentLoaded; - run a timeline in which the heading started after a 0.6 s delay and animated for another second.
You won’t notice that on a fast laptop. On the throttled mobile 4G that Lighthouse emulates, it adds two and a half seconds to LCP.
The fix: move the entrance from GSAP to a plain CSS animation. It starts with the very first paint and waits for nothing. The heading now appears first with no delay, the rest follows in a cascade. GSAP stays where it genuinely earns its weight – the 3D logo tilt that follows the cursor.
.hero__name { animation: hero-in 1s cubic-bezier(.215,.61,.355,1) both; }
@keyframes hero-in {
from { opacity: var(--hero-from, 0); transform: translateY(var(--hero-y, 0)); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.hero__name { animation: none; }
}
The trap: opacity: 0 and LCP
After the first change the score went up, but LCP barely moved. The report now said the “largest element” was… a navigation link in the header. The reason: Chrome does not consider fully transparent elements as LCP candidates. Our CSS animation started at opacity: 0, so the heading simply dropped out of the race.
The fix is one line: the heading’s animation starts at opacity: 0.01. The difference is invisible to the eye, but the browser counts the element from the first frame. That is exactly what the --hero-from variable in the snippet above is for.
If LCP refuses to budge after your optimizations, check whether your main element starts fully transparent. It’s one of the most common causes – and one of the easiest to miss.
Cause #2: CSS and JS were served uncompressed
The second finding was on the server. Ubuntu’s default nginx.conf has gzip on, but the gzip_types line is commented out. As a result only HTML gets compressed, while CSS and JavaScript go out raw. The two render-blocking stylesheets cost roughly 0.9 s each on mobile.
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/css application/javascript application/json
image/svg+xml application/manifest+json text/xml;
The main stylesheet dropped from 23 KB to 5.7 KB, the GSAP bundle from 68 KB to 27 KB. We also enabled HTTP/2: one multiplexed connection instead of a queue of twelve requests over several. These two server changes alone brought First Contentful Paint from 2.0 s down to 1.2 s.
Cause #3: analytics on the critical path
The Google Analytics tag weighs about 179 KB, and nearly half of it is unused at startup. We now load it after the load event, when the browser is idle (requestIdleCallback). The important detail: dataLayer is created immediately, so events fired before the tag arrives aren’t lost – the tag processes the queue as soon as it loads.
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
addEventListener('load', () => {
(window.requestIdleCallback || setTimeout)(() => {
const s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX';
document.head.appendChild(s);
});
});
The honest trade-off: visitors who bounce within the first second won’t show up in your stats. For a portfolio that’s an acceptable price; for a shop running paid ads, weigh it separately.
A small one you can see in CLS
The hero logo had no width and height attributes, so the page shifted slightly while loading (CLS 0.011). Two attributes later, the shift was gone.
Results
| Metric | Before | After server fixes | Final |
|---|---|---|---|
| Performance score | 77–80 | 82 | 98 |
| First Contentful Paint | 2.0 s | 1.2 s | 1.1 s |
| Largest Contentful Paint | 4.3 s | 3.7 s | 1.3 s |
| Speed Index | 5.2 s | 5.1 s | 3.9 s |
| Total Blocking Time | up to 190 ms | 191 ms | 29 ms |
| Cumulative Layout Shift | 0.011 | 0 | 0 |
Speed Index stayed in the amber zone – that’s the cost of the cascading hero animation, and we kept it on purpose.
Takeaways
- Look at the LCP phases, not just the score. “Render delay” almost always means the element is waiting for JS or a font.
- Don’t hide your main content until scripts load. Entrance animations in CSS, JavaScript for interactivity.
- Check the server: are CSS and JS compressed, is HTTP/2 on? Five minutes of work, a second saved.
- Load analytics after the page, but create the
dataLayerqueue right away. - Measure the median of several runs – a single run can be off by ten points.