On a rental booking site I was working on, the mobile hero was deliberately as cheap as I could make it: one still image set as a background-image in CSS, with no JavaScript involved at all. The desktop version was far heavier and drove every image from JavaScript, but that was a different code branch. The mobile branch was a single CSS rule. Simple, cheap, and supposedly impossible to get wrong.
Then the client sent a screenshot from their phone: the hero was empty. Not broken, not badly cropped. Empty.
The symptom: mobile only, production only
Desktop was fine. Locally, mobile was fine too. The only broken combination was mobile plus a production build. When I attached the phone to remote debugging and opened the Network tab, the cause was sitting there in plain sight:
GET /assets/css/dist/imagesequences-webp/bgimage_0001.webp
404 Not FoundI read it twice. The path was wrong, but wrong in a strange way. The images lived in assets/imagesequences-webp/, and I had never typed dist anywhere near an image URL. What I had written in assets/css/hero.css was this:
.hero-bg {
background-image: url("../imagesequences-webp/bgimage_0001.webp");
}From assets/css/, ../ climbs one level to assets/, then enters the image folder. Correct. I checked it three times and started to feel stupid, because the source file clearly said the right thing.
My cache reflex flickered for a second and died on its own, because the wrong string was not an older version of anything I had ever written. Look at its shape: dist sits in the middle of an image URL, and dist only ever existed as the name of a build output folder. No hand typed that combination. Which means the path was assembled, and only two parties could have assembled it: the browser while resolving a relative URL, or the build while writing the stylesheet. Both turned out to have a hand in it.
Root cause: the minifier rewrote the URL
I started with the second party, because it was the easiest to prove: open the CSS that production really serves, not the source. It read like this:
.hero-bg{background-image:url(imagesequences-webp/bgimage_0001.webp)}The ../ was gone. Not a typo of mine, not a bad upload. That prefix was removed by cssnano in the PostCSS pipeline, as part of URL normalisation during minification.
And this is where two separate problems stacked into a single 404.
The first one is the most basic CSS mechanic there is, and the easiest to forget: url() inside a stylesheet resolves relative to the location of that stylesheet, not to the URL of the HTML page. As long as the served file was assets/css/hero.css, ../ pointed exactly where I wanted. But my build never served that file. It emitted assets/css/dist/hero.min.css, one folder deeper. The base of the calculation had already shifted before the minifier touched anything.
The second one is cssnano dropping the ../. So instead of a path that was off by one level, I got a path glued straight onto the output folder. assets/css/dist/ plus imagesequences-webp/bgimage_0001.webp, which is precisely the string in the Network tab.
That also explains why the breakpoint mattered at all. Only the mobile branch handed its image URL to CSS. The desktop branch received its frame base path from JavaScript, and JavaScript never passes through a CSS minifier. The single code path that gave the build a chance to rewrite a URL was the one that only ran on small screens.
The fix: take the URL out of CSS
The quick options were obvious. I could disable URL normalisation in the cssnano preset, or write a root absolute path that is immune to the file moving. Both work.
But each has its own price. Turning URL normalisation off means the hero depends on a config option with no visible connection to the hero, and whoever bumps a dependency next can flip it back without knowing what they broke. A root absolute path really is immune to the file moving, but the URL still sits inside CSS and still passes through the minifier on every build. Both close the 404 while leaving the image URL somewhere that someone else is allowed to rewrite.
So I removed the background-image rule from CSS entirely. The frame base path now lives in the markup as a data attribute, and the hero script sets it:
<div class="hero-bg" data-frame-base="/assets/imagesequences-webp/"></div>// hero-frames.js
function initHero(isMobile) {
const hero = document.querySelector(".hero-bg");
if (!hero) return;
const base = hero.dataset.frameBase;
if (isMobile) {
hero.style.backgroundImage = `url("${base}bgimage_0001.webp")`;
return;
}
// desktop: run the frame sequence from the same base
}The mobile branch now reads from exactly the same source of truth as the desktop branch, a single base path declared in one place. No image URL passes through PostCSS anymore, so there is nothing left for the build to normalise, shorten, or rebase.
There is still a price, and I paid it knowingly. The base path is still root absolute, so if the site is ever mounted in a subdirectory, that value has to change. The difference is that the value is now a visible attribute in the markup instead of a string buried inside minified CSS. And because the mobile background is set from JavaScript, the hero now depends on that script actually running. If the script fails, I am back to an empty hero, exactly the symptom the client reported.
A quick way to confirm you hit the same thing
If an asset 404s on a path that looks close to what you wrote but not identical, do not keep staring at the source file. Read the output:
grep -o 'url([^)]*)' assets/css/dist/hero.min.cssCompare that with the source. If they differ, the problem is in the build, not in your CSS. And before blaming the minifier, check whether the output folder sits at the same depth as the source folder. Very often that alone is enough to break every relative path inside the file.
What I took away
url()in CSS is resolved from the stylesheet's own location. The moment a build moves that file into another folder, every relative path inside it shifts with it.- Minifiers are allowed to rewrite URLs. cssnano normalises relative paths, and the result can differ from what you typed.
- A bug that appears at one breakpoint only usually points at a different code path, not at responsive styling.
- A wrong path that is not a typo is a clue in itself. If no human would ever have typed that string, something assembled it, and that something is usually the build.
- Fragile asset paths are better declared once in markup or JavaScript than handed to a pipeline that is entitled to change them.