DEV Community

NOGUCHILin
NOGUCHILin

Posted on • Edited on

Hidden in 26,695 characters of Additional CSS: the rule that broke every mobile header

Sometimes a performance problem isn't a lack of optimization. Sometimes it's a ghost that has been haunting your stylesheet for years.

I was recently brought in to diagnose a persistent Core Web Vitals issue on a WordPress site. Largest Contentful Paint was sitting between 5.4 and 5.8 seconds on throttled mobile and would not move. The previous developers had tried the usual things: optimizing images, adding preload tags, tuning caching. None of it helped.

When I looked at the LCP breakdown, the timing of the paint event didn't match the download of the header image we were supposed to be seeing. It matched the completion time of a completely different image file.

The investigation

The HTML was correct. The <link rel="preload"> tags were well formed and pointed at the right optimized image. The network panel showed that image downloading quickly.

But on mobile, the site wasn't displaying it.

I started stripping away layers:

  • Disabled custom plugins? Still there.
  • Checked the theme's page builder settings? Correct image selected.
  • Looked for rogue JavaScript swapping the src? None found.

Finally I looked at the one place you hope you never have to: Appearance > Customize > Additional CSS.

26,695 characters of "quick tweaks"

In WordPress, the Customizer has an "Additional CSS" field. It is meant for small adjustments. On this site it had grown, over years, into a single block of 26,695 characters. (That is measured, not estimated — and getting that number turned out to be its own trap, which I'll show you at the end.)

Buried in it was a rule shaped like this:

.touch #page-header .row-background.background-element {
    background-image: url(https://old-staging-host.example/wp-content/uploads/header-photo.jpg) !important;
}
Enter fullscreen mode Exit fullscreen mode

Four things made it lethal:

  1. .touch — it only fired on touch devices, so nobody testing on a desktop would ever see it.
  2. #page-header — it targeted an ID used on every single page of the site.
  3. background-image — a CSS background is invisible to the browser's preload scanner, so it is discovered late.
  4. !important — no theme setting or optimization plugin could override it.

And the URL pointed at an old, abandoned staging server that nobody had thought about in years.

Because this CSS was injected globally, every mobile visitor to every page was rendering the wrong header image — one that lived on someone else's server and had never been optimized.

Why it defeated every previous attempt

This one rule explained the whole mystery:

  • The preload tags "didn't work" because they preloaded the correct image, while the CSS made the browser fetch and paint a different, heavier one.
  • Image optimization plugins couldn't touch it, because the image wasn't on this site at all.
  • It was invisible on desktop by construction: the selector starts with .touch.

That last point is worth sitting with. The bug was written in a way that guarantees the person checking their work will not see it.

The fix

I deleted the rule.

LCP went from a 5.4–5.8s baseline to 3.6–4.3s, across the whole site, because Additional CSS is global — removing it fixed every page at once. That is a floor of about 1.5 seconds and a ceiling of about 2.2, depending on which end of each range you compare. I'm giving you both ends rather than the flattering pair, because single runs on a page like this scatter, and one cherry-picked before/after is how people end up believing things that aren't true.

No new plugin. No new optimization. One deletion.

Check your own — and the trap that will tell you there's nothing there

WordPress prints the Additional CSS field inline in your page source, in a <style id="wp-custom-css"> block. So you can measure it from outside, without logging in.

But if you do the obvious thing — read it out of the DOM — you may get a very reassuring, very wrong answer:

document.querySelector('#wp-custom-css').textContent.length;   // 0
Enter fullscreen mode Exit fullscreen mode

Zero. The element is right there and it is empty.

That is what I got on this site. The Additional CSS had not gone anywhere; a caching plugin's "remove unused CSS" feature had rewritten the cached page, emptied that block, and folded the rules it decided to keep into a generated stylesheet of its own. Reading the live DOM tells you about the optimizer's output, not about what is actually stored in your site.

Fetch the un-cached page instead:

// paste in the browser console on your own site
const url = location.origin + location.pathname + '?nocache=' + Date.now();
const html = await fetch(url, { cache: 'reload' }).then(r => r.text());
const css = (html.match(/<style[^>]*id=["']wp-custom-css["'][^>]*>([\s\S]*?)<\/style>/) || [, ''])[1];

console.log(css.length + ' characters of Additional CSS');
console.log((css.match(/!important/g) || []).length + ' !important');
console.log([...new Set((css.match(/https?:\/\/[^)'" ]+/g) || [])
  .filter(u => !u.includes(location.hostname)))]);
Enter fullscreen mode Exit fullscreen mode

Side by side on the same page, same minute, that was the difference:

HTML size Additional CSS
Normal request (served from cache) 576,036 chars 0
Same URL with a cache-buster 177,012 chars 26,695

The last line of the snippet is the one that matters. It lists every external URL your "quick tweaks" still point at. If a staging host, an old domain, or a former agency's server shows up there, you have found the same class of bug.

And notice what the first table row means: the tool installed to make the site fast is also the reason nobody found this for years. It hid the junk drawer.

The lesson

When your optimizations aren't moving the needle, stop adding more optimizations.

Look at what actually painted. If the element rendering isn't the element you optimized, your problem isn't speed — it's that something is overriding you, and you haven't found it yet.

And check the Additional CSS box. It's the junk drawer of WordPress, and it does not show up in any audit tool's checklist.


I do this kind of technical detective work constantly. If you're tired of guessing why your site is slow, I packaged my exact audit checklist into SpeedKit for Claude Code.

Top comments (0)