◉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.

Cloudflare3cachingXserverimage delivery2indie developer12Antigravity376

✦ 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 $15 for lifetime access
View Membership →

Related Articles

⬡ Integrations2026-06-09
Rebuilding Wallpaper Image Delivery Around Resolution Buckets — Letting an Antigravity Agent Own Conversion and Validation
Every new device resolution quietly makes a wallpaper app heavier. I stopped shipping one master image to every device and rebuilt delivery around resolution buckets, WebP/AVIF, and an edge redirect — then handed conversion and validation to an Antigravity agent. Real code and thresholds included.
▣ App Dev2026-07-05
Keep the Self-Debugging Agent Away From Live Ads: Three Layers Against AdMob Invalid Traffic
When Antigravity 2.0's real-browser self-debug renders a live AdMob unit, every pass counts as an impression, and Google may read it as invalid traffic. Here is a three-layer setup, with measurements, that keeps the agent from ever touching a production ad.
▣ App Dev2026-05-26
Unifying In-App Review Prompts Across 5 Apps with Antigravity Editor — A Few Days of Notes
Notes from a few days spent unifying SKStoreReviewController trigger conditions across five iOS wallpaper apps I run as an indie developer, using Antigravity Editor's multi-file editing to bring the logic onto one shared coordinator.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links