◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-09-29Advanced

Purging Cloudflare Didn't Bring Back the New Wallpapers: the Stale JPEGs Were Held by XPageSpeed on the Origin

An operations record from a wallpaper app: images overwritten at the same URL kept arriving stale even after a Cloudflare purge. Covers the three-way local/origin/edge probe that names the guilty layer, when prefix purge beats per-URL purge, and how to switch off Xserver's XPageSpeed in .htaccess without a 500.

Cloudflare7caching4Xserverimage deliveryindie developer19Antigravity414

✦ Premium Article

It was the middle of September, the morning after I had merged a new batch of ukiyo-e wallpapers into the delivery tree. I opened the app on a phone and scrolled through the new IDs. The new prints were there. But a handful of existing IDs — the ones I had re-cropped and re-trimmed the paper edges on — were still showing the previous version. Not one of those fixes had reached the device.

The overwrite had completed. The agent's report ended with "uploaded." And the images were still old. I opened the Cloudflare dashboard and purged the URLs in question. So far, this was the usual routine.

What stopped me was that the same old images kept coming after the purge. I had emptied the edge and the pictures were still stale — and at that point I had to admit that the delivery path contained a layer I had never counted.

The delivery path had a layer I wasn't counting

As an indie developer I serve the wallpaper images from a storage domain on Xserver, a shared host, with Cloudflare's edge in front of it. Images are meant to never change once published, so the origin returns Cache-Control: public, max-age=31536000, immutable. A one-year promise not to rewrite.

I broke that promise myself. I overwrote images at the same ID, at the same URL. Changing the ID would have avoided all of this, but favorites and notifications in the app reference images by ID, so replacements are done in place by policy.

The path I had in my head was three stages: local file → origin → edge → app. In reality the origin had a stage of its own. Xserver's XPageSpeed was rewriting static JPEGs into optimized copies and serving those copies under the original URL. Cloudflare cannot see that stage at all.

LayerWho holds itWhen it goes staleHow to tell
LocalStaging tree on my machineMissed upload, failed mergeFile missing or size differs
Origin optimizerXserver's XPageSpeedKeeps serving the optimized copy after an overwriteetag: W/"PSA-…", Last-Modified disappears
Origin, rawApache static fileShould be fresh right after the overwriteFresh when fetched with ?cb=
EdgeCloudflareImmutable and not yet purgedcf-cache-status: HIT with an age

Laid out as a table it looks obvious. I had simply never counted the second row.

Replacing "uploaded" with a three-way comparison

The first thing I changed was the check itself. Until then, after an upload I would open a few images in a browser and look. Looking only tells you what the edge returned once; it says nothing about what the origin is holding.

So I added a small script that fetches the same image over three routes and compares them. The whole trick is to append ?cb=$RANDOM and see the origin's true face. If the cache-busted response differs from the plain one, something between them is stale.

#!/usr/bin/env bash
# Fetch the same image plain and cache-busted; print headers and body size side by side
# Usage: ./probe.sh 1353   (wallpaper ID)
set -eu
ID="$1"
BASE="https://storage.example.com/ios/wallpaper_apps/ukiyo-e/6_5inch/wallpaper"
URL="${BASE}/wallpaper${ID}.jpg"
LOCAL="./_out_ukiyo-e/ios/6_5inch/wallpaper/wallpaper${ID}.jpg"
 
probe() {
  # -D dumps response headers to a file; the body is only counted, never kept
  local u="$1"
  curl -sS -D /tmp/h.$$ -o /tmp/b.$$ "$u"
  local size; size=$(wc -c < /tmp/b.$$ | tr -d ' ')
  printf '%-8s size=%-8s cf=%-8s age=%-6s etag=%s lm=%s\n' \
    "$2" "$size" \
    "$(grep -i '^cf-cache-status:' /tmp/h.$$ | awk '{print $2}' | tr -d '\r')" \
    "$(grep -i '^age:' /tmp/h.$$ | awk '{print $2}' | tr -d '\r')" \
    "$(grep -i '^etag:' /tmp/h.$$ | cut -d' ' -f2- | tr -d '\r')" \
    "$(grep -i '^last-modified:' /tmp/h.$$ | cut -d' ' -f2- | tr -d '\r')"
  rm -f /tmp/h.$$ /tmp/b.$$
}
 
printf '%-8s size=%s\n' local "$(wc -c < "$LOCAL" | tr -d ' ')"
probe "$URL" edge
probe "${URL}?cb=$RANDOM" origin

Three lines, and the kind of accident reads off them. If local and origin (cache-busted) sizes differ, the upload was missed. If the origin is fresh but the edge is stale with cf=HIT and an age, a purge will fix it. And if the cache-busted size is still the old one and the etag begins with W/"PSA-, the optimizer on the origin is holding it. That third case is what I was looking at in September.

I've written before about not trusting an agent's completion report, in With scheduled agents, I now look for runs that never happened before I look for failures, but this time the angle is different. The report was correct. The overwrite really had finished. After a correct report, a layer nobody could see was still serving the previous version.

✦

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
✦When an overwritten image keeps arriving stale, you'll be able to line up the local file, the origin, and the edge and name which layer is holding the old bytes
✦You'll recognize the response headers that mean a Cloudflare purge cannot help, and reproduce the safe order — switch off the origin-side optimizer first, then purge again — in your own setup
✦Before reaching for Purge Everything and sending your whole catalog back to the origin, you'll be able to pick prefix or per-URL purge from the count alone, and skip the morning where you discover the old images and start over
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

▣ App Dev2026-10-15
I Swapped an Immutable Asset at the Same URL, and Only the Browser Kept the Old Copy
Overwrite a long-cached JS file at the same URL and neither a CDN purge nor a refresh will make the browser fetch it again. Four delivery styles — same URL, no-cache, query string, content-hash filename — compared in Chromium, plus a complete build-step script that adds the hash.
▣ App Dev2026-11-12
Check Web Font Delivery with an Origin Header, Too: How a Fallback Font Went Unnoticed on My Site for 13 Days
For 13 days, the Latin web font on my own site kept returning 404 and every page was drawn in a fallback face. A plain curl returned 200; only the browser's request, which carries an Origin header, received the stale 404. Here is a script that compares three kinds of request, a setting that keeps long cache lifetimes off error responses, and how to pick a purge that reaches every variant.
▣ App Dev2026-10-20
My PHP API Returned no-store and Cloudflare Passed Everything Through: Overriding Only the Edge TTL
The list API behind my wallpaper apps returned no-store, so putting Cloudflare in front of it changed nothing: every request still reached the origin. This note covers a Cache Rule that overrides only the edge TTL, a small script that proves it works, and a deploy step that pairs upload and purge so stale responses don't linger.