After I Rewrote the URL, Only the Not-Yet-Loaded Images Returned 404: Checking replaceState and Relative Paths
After rewriting the URL with history.replaceState when moving from a list to a detail view, only the images that had not loaded yet returned 404. Native loading=lazy broke too. Here are a minimal test page, a check script, and three fixes compared.
I added a small change so that moving from a list of works to a single work would rewrite the URL without reloading the page. The address bar showed the detail URL, and the back button behaved as expected.
Then some images that should appear as I scrolled returned 404. The ones visible at first load were fine, so I nearly waved the check through.
What follows is how I found that the base for relative paths had moved to the rewritten URL, the results I reproduced locally, and how three fixes differ.
The symptom appeared only on images that had not loaded yet
The broken images had one thing in common. Images already on screen at load were fine; only the ones I scrolled toward failed.
In the developer tools, the failed requests pointed at the wrong place. The path was one directory level deeper than where the files live, so naturally nothing was there.
That ruled out caches and CDNs. The question became which URL the relative path had been resolved against. I once wrote up the order in which to suspect caches when a swapped asset kept showing its old version, but here the request URL itself was different, which is where the triage started.
A minimal page that reproduces it
I stripped the page down to what matters: one image on screen, a gap, then two images that load later.
One uses the native loading="lazy"; the other swaps data-src into src with an IntersectionObserver. Right before they load, the page rewrites the URL to a deeper path with history.replaceState.
<!doctype html><html><head><meta charset="utf-8"><title>replaceState repro</title></head><body><!-- On screen: starts loading before the rewrite --><img id="eager" src="img/a.jpg" width="100" height="100"><div style="height:3000px">spacer</div><!-- Two kinds of later-loading images --><img id="native" loading="lazy" src="img/b.jpg" width="100" height="100"><img id="js" data-src="img/c.jpg" width="100" height="100"><script>// Simulates moving from /gallery/ to /gallery/item/12/ without a reloadhistory.replaceState(null, '', '/gallery/item/12/');const io = new IntersectionObserver(es => es.forEach(e => { if (!e.isIntersecting) return; const img = e.target; if (img.dataset.src) img.src = img.dataset.src; // relative path is resolved here io.unobserve(img);}));document.querySelectorAll('img').forEach(i => io.observe(i));window.scrollTo(0, document.body.scrollHeight);</script></body></html>
I served it as /gallery/fix.html, opened it in Playwright's Chromium, and counted which URLs were actually requested. I read response URLs, status codes and naturalWidth rather than looking at the screen.
# run.py: print response URLs, statuses and the final state of each imageimport threading, http.server, socketserver, functoolsfrom playwright.sync_api import sync_playwrightHandler = functools.partial(http.server.SimpleHTTPRequestHandler, directory="site")srv = socketserver.TCPServer(("127.0.0.1", 8811), Handler)threading.Thread(target=srv.serve_forever, daemon=True).start()with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() seen = [] page.on("response", lambda r: seen.append((r.url.split("8811")[1], r.status))) page.goto("http://127.0.0.1:8811/gallery/fix.html") page.wait_for_timeout(1500) # let lazy loading finish print("url =", page.url.split("8811")[1]) print("responses=", [s for s in seen if "img" in s[0]]) print("imgs =", page.evaluate( "[...document.images].map(i => [i.id, i.currentSrc.split(location.host)[1], i.naturalWidth])")) browser.close()
The point of this script is to identify how an image broke from the pair of requested URL and naturalWidth, not from appearance. An image whose naturalWidth stays at 0 failed to load.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can check in a few minutes, with a small test page, whether images that load later will survive a URL rewrite, before you ship the change
✦When only some images return 404, you can recognise from the failed request URLs that the base for relative paths moved, and skip the detour of suspecting caches and CDNs
✦You can compare the side effects of pinning absolute paths versus using a base element, and write the fix that suits your page into your instructions to an agent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the state before any fix, run in headless Chromium on my machine.
Image
Requested URL
Status
eager (on screen)
/gallery/img/a.jpg
200
native (loading=lazy)
/gallery/item/12/img/b.jpg
404
js (data-src)
/gallery/item/12/img/c.jpg
404
The surprise was loading="lazy". I had assumed a src written in the HTML was already resolved before loading. In fact, a deferred image was resolved against the base URL at the moment loading started, so it was not only my own swap code that was affected.
Also, the on-screen image was untouched. If you check only the top of the page, you will miss this. Remembering that only images loaded after the rewrite break makes the reproduction easier to build.
This was confirmed in Chromium only. I have not checked other browsers, so please run the same test page in your target environments.
Three fixes, tried side by side
I applied each fix to the same page and read the result with the same script.
<!-- Fix A: pin every image path to an absolute URL before rewriting --><script>document.querySelectorAll('img').forEach(i => { if (i.dataset.src) { i.dataset.src = new URL(i.dataset.src, document.baseURI).href; } else { i.setAttribute('src', new URL(i.getAttribute('src'), document.baseURI).href); }});history.replaceState(null, '', '/gallery/item/12/'); // rewrite after pinning</script><!-- Fix B: fix the base with a base element in head --><head><base href="/gallery/"></head><!-- Fix C: write image paths as root-absolute paths from the start --><img data-src="/gallery/img/c.jpg" width="100" height="100">
Fix A and Fix B both returned 200 for all three images. Order is the crux of A: pin before replaceState. If you read document.baseURI afterwards, it already points to the new URL.
Fix C is the simplest and the first choice when images are managed in one place. If many existing pages use relative paths, rewriting them all costs more.
A script that counts the breakage on the real page
Knowing the cause on a minimal page does not prove your real page breaks the same way. So I wrote a script that opens the real page, triggers the URL-rewriting action, scrolls to the bottom, and counts broken images.
# audit.py: after the rewrite, count images that failed to loadimport sysfrom playwright.sync_api import sync_playwrightBASE = sys.argv[1] # e.g. http://127.0.0.1:8811/gallery/TRIGGER = sys.argv[2] # selector that triggers the rewrite, e.g. a.work-linkwith sync_playwright() as p: b = p.chromium.launch() page = b.new_page(viewport={"width": 375, "height": 700}) failed = [] page.on("response", lambda r: failed.append((r.status, r.url)) if r.status >= 400 else None) page.goto(BASE) page.click(TRIGGER) # replaceState / pushState runs here for _ in range(12): # scroll in steps to trigger lazy loading page.mouse.wheel(0, 600) page.wait_for_timeout(250) broken = page.evaluate( "[...document.images].filter(i => i.complete && i.naturalWidth === 0).length") total = page.evaluate("document.images.length") print(f"images={total} broken={broken} http_errors={len(failed)}") for s, u in failed[:10]: print(s, u) b.close() sys.exit(1 if broken or failed else 0)
Because the exit code carries pass or fail, you can tell an agent to keep fixing until the script exits with 0. Nobody has to watch what it changes; the stopping point is set by two numbers, total images and broken images.
On the repro page, two of three images failed with 404 (about 67%) and the on-screen one returned 200. After Fix A or Fix B all three returned 200 and broken=0.
Side effects to know before using the base element
A base element is a short fix, but it affects every other link on the page. An in-page link such as href="#section" is resolved against the base, so it can navigate to a different page unintentionally.
I want to avoid a fix that causes a different symptom, so on pages with a limited number of images I choose A or C. I use a base element only when I can audit how every link is written.
A url() inside a stylesheet is resolved against the CSS file's location, so this rewrite does not affect it. If your images load as CSS backgrounds, look for another cause first.
Which fix to choose: my recommendation
What I noticed after trying all three is that the right choice depends less on which is correct and more on where image paths are managed. This is the order I follow on my own indie-development sites.
If image paths are written in one place, standardise on Fix C (root-absolute paths). It is the hardest to break
If relative paths are scattered across existing pages, insert Fix A once, just before the URL-rewriting code
Use Fix B only as a last resort, and only when you can verify how every in-page link is written
Whichever you choose, run the audit script after the change and confirm broken=0 before publishing
I recommend starting with a few lines of Fix A, confirming the audit script passes, and moving toward Fix C when you have time. That costs less rework than rewriting everything in a hurry.
Instructions to give an agent for this check
If you only ask Antigravity to "check that images display", it may look at the first image on screen and stop. These three points aligned the scope of the check for me:
Include images that only load after scrolling, after the history-rewriting action
Output the response URL, status code and naturalWidth, not appearance
For any 404, state how many directory levels the URL has shifted from the real location
Doing the image check itself inside an iframe on another tab causes a different misdiagnosis. I wrote that up in checking currentSrc on the real page, so please read it alongside this one.
Pitfalls
Order of rewriting: pin to absolute paths before replaceState or pushState. Afterwards the base has already moved
Back button: when popstate returns to the list, relative paths on the list side go back to the original base, so verify those images separately
srcset and sizes: srcset candidates resolve against the same base. When pinning with Fix A, include each URL in srcset
Do not stop at one check: look at the on-screen image, native loading="lazy" and your own swap method, each before and after the rewrite
What to try next
Find one place in your own pages that rewrites the URL. Insert the few lines of Fix A just before it and run the check script above to see whether the 404s disappear. That alone prevents most of this class of problem.
I keep this test page as a tool for chasing small behavioural mismatches by numbers rather than by eye. Having one page of this shape ready makes the next triage faster.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.