You know those days where a tiny bug eats half your brainpower, and then when you finally solve it, you feel like both an idiot and a wizard? Yeah. This is one of those stories.
The Setup: Performance Optimization Gone Wrong
Like any good frontend engineer, I wanted to optimize stylesheet loading. So instead of letting stylesheets block render, I tagged them like this:
<link rel="stylesheet" media="print" href="/assets/css/base.css" />
The trick here: browsers skip applying media="print"
styles until you explicitly flip it to all
. That way, the stylesheet downloads in the background, but doesn’t block rendering.
And then I had a little script to flip the switch:
document
.querySelectorAll('link[rel="stylesheet"][media="print"]')
.forEach(link => {
const enableMedia = () => {
link.media = 'all'
link.removeEventListener('load', enableMedia)
}
if (link.sheet) {
enableMedia()
} else {
link.addEventListener('load', enableMedia)
}
})
Beautiful, right? Except … nothing happened. Stylesheets never applied. My page looked naked. Not in a good way 😜.
The Bug: CSP Meets Timing
First instinct: CSP. I had a pretty strict Content Security Policy with script-src 'self' 'strict-dynamic'
. Inline hacks were out, so the loader script was external.
But then I noticed something curious: my script was being deferred.
<script defer src="/static/js/stylesheetloader.js"></script>
defer
means “wait until HTML parsing is done before running this script”. By then, the browser had already seen my <link media="print">
tags, queued them, and … didn’t trigger load
the way I expected. My script showed up too late to catch the party.
The Fix: Async to the Rescue
The fix was almost laughably simple:
<script async src="/static/js/stylesheetloader.js"></script>
Switching defer
to async
made the script run as soon as it was available, before the render pipeline locked things down. Boom 💥. Stylesheets applied perfectly.
Sometimes all it takes is one attribute flip to go from “WTF why is CSS broken” to “I am a genius.”
Why This Works
Let’s zoom out:
-
defer
scripts wait until after HTML parsing is complete, keeping order intact. -
async
scripts run as soon as they’re downloaded, ignoring order.
In my case, I didn’t care about execution order. I just needed the loader to run ASAP. Async was the perfect fit.
Lessons Learned
-
Performance hacks are sharp knives. Using
media="print"
for async stylesheets works, but you need to manage timing carefully. - CSP adds invisible complexity. With inline scripts off the table, your timing depends entirely on how you load external JS.
-
defer
vsasync
actually matters. Don’t just cargo-cultdefer
everywhere. Think about when you need the script to run.
Generalizing the Problem
This isn’t just about my stylesheet loader. It’s a broader lesson about script execution timing under CSP:
- If you have critical scripts that need to act before rendering decisions (like stylesheet toggles, dynamic meta updates, etc.), use
async
or preload strategies. - If execution order matters (like multiple polyfills), then
defer
is safer. - If you’re battling CSP, externalize your hacks, don’t rely on inline event handlers (
onload="..."
).
Alternatives Worth Considering
-
<link rel="preload">
: preload stylesheets and apply later. - Modern bundlers (Rsbuild, Vite): can inline critical CSS and lazy-load the rest.
- JS-based style injection: less common, but some teams prefer runtime style injection for full control.
Wrap-up
Sometimes, a bug isn’t about broken code. It’s about tiny mismatches in timing and browser behavior. This one was a perfect storm of CSP, defer
, and stylesheet loading tricks.
I wasted hours chasing ghosts, but in the end, flipping one attribute fixed everything. The kind of debugging adventure that keeps you humble … and slightly amused.
So next time your CSS looks naked, check your <script>
attributes. 😉
Top comments (0)