◉ANTIGRAVITY LABJP
Articles/Tips & Best Practices
✦ Tips & Best Practices/2026-10-19Advanced

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.

history APIlazy loadingrelative pathsPlaywright4indie development22Antigravity389

✦ Premium Article

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 reload
history.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 image
import threading, http.server, socketserver, functools
from playwright.sync_api import sync_playwright
 
Handler = 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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

✦ Tips2026-10-15
The Images Weren't Broken, My Way of Checking Them Was: Doubting the iframe First, Then Reading currentSrc
A record of misdiagnosing healthy images as unloaded because I audited them through an iframe in a separate tab. In a hidden frame, naturalWidth reads 0 at a rendered width of 0, and lazy images never start. Here are the numbers I reproduced locally, an audit script that reads currentSrc and rendered width on the real page, and the trap of bulk-replacing CSS values with a regex.
✦ Tips2026-10-17
When Search Console Says "Page with redirect": How I Sorted Which Redirects to Fix and Which to Leave Alone
A record of triaging Search Console's "Page with redirect" report on my own site. Normalising http to https, www, trailing slashes and language routing is expected; chains, loops and destinations that error or carry noindex need work. Includes a Python script that walks each hop and classifies it, and a way to catch language-dependent redirects that a plain request never sees.
✦ Tips2026-09-22
Don't Leave Your Chat Records to the Export Button — Three Routes for the Night It Does Nothing
Three ways to keep a record of your Antigravity conversations when the Export button does nothing at all: how to tell a broken button from a silent success, how to locate the conversation file in your own environment by modification time, and how to decide what to keep before the session ends.