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.
I had known for a while that the list API behind my wallpaper apps was heavier than it should be. Each category list is long, and every time the app opens, a PHP script on the origin rebuilds the same content and sends it back.
I had already put Cloudflare in front of image delivery, so I assumed the API was covered too. It wasn't. cf-cache-status never showed HIT, and fetching the same list repeatedly went to the origin every time. The CDN was sitting there without ever acting as a cache.
The culprit was a plain Cache-Control: no-store coming from PHP. This is a common state for an indie developer's API, so here is the fix, written under one constraint: do not change anything on the device side.
Two separate reasons caused the pass-through
First I wanted to know why HIT never appeared. There were two reasons stacked on each other.
The first: by default Cloudflare caches static file extensions such as images and CSS, and JSON returned from a .php endpoint is not among them. The second: the origin declared no-store. When a session starts, PHP adds Cache-Control: no-store, no-cache, must-revalidate by default. Even an API that never uses sessions gets the same header if a shared include happens to call session_start().
So both the extension and the header pushed the response toward "the edge keeps nothing". Fixing only one of them is not enough.
Three options, chosen by whether they reach the device
I considered three approaches.
Approach
Edge
Device (app)
My call
Change origin Cache-Control to public, max-age=…
Works
Works (stale responses also stick on devices)
Rejected
Override only Edge TTL with a Cache Rule
Works
Unchanged
Chosen
Rewrite responses in a Worker
Works
Depends on the rewrite
Overkill, rejected
What decided it was whether the change reaches the device. Shipped app versions keep running on devices of users who haven't updated. If I changed the origin's Cache-Control, those old versions would also be told "don't refetch for a while", and a response already stored on a device is something I can never remove from my side. Overriding only the edge TTL leaves the device's instructions as they were and increases only what the edge absorbs.
One more precondition: an API whose clients read fields by position can only append fields at the end. I wrote about keeping that promise in a separate article. It is worth confirming that promise still holds before adding any caching in front of such an API.
✦
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 tell within minutes whether your own API is passing straight through a CDN, using a short script that lines up cf-cache-status and age across repeated requests
✦You will be able to choose the design that overrides only the edge TTL and leaves the origin's Cache-Control alone, so you avoid dragging already-shipped app versions into a device-side caching change before you ever apply it
✦You can close the stale-response window that caching opens by bundling upload and purge into one step, saving the time you'd otherwise spend answering 'I updated it but the app still shows the old list' reports
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.
In Cache Rules, set Edge TTL to override_origin (ignore the origin's instruction and use the number of seconds I give) and Browser TTL to respect_origin (pass the origin's instruction to the device untouched). I limited the target to the one path that returns the heavy list. The TTL value depends on your environment; as an example I use 300 seconds (5 minutes).
# Apply settings to the Cache Rules entrypoint (phase: http_request_cache_settings)# Caution: PUT replaces ALL rules in this phase. Save the current ones first.ZONE=$CF_ZONE_IDURL="https://api.cloudflare.com/client/v4/zones/$ZONE/rulesets/phases/http_request_cache_settings/entrypoint"# 1) Always save the "before" state (decide how to roll back first)curl -sS "$URL" -H "Authorization: Bearer $CF_API_TOKEN" > before-cache-rules.json# 2) Add a rule scoped to the heavy list onlycurl -sS -X PUT "$URL" -H "Authorization: Bearer $CF_API_TOKEN" \ -H "Content-Type: application/json" --data '{ "rules": [{ "description": "list API: edge ttl only", "expression": "(http.host eq \"api.example.com\" and starts_with(http.request.uri.path, \"/v1/list\"))", "action": "set_cache_settings", "action_parameters": { "cache": true, "edge_ttl": { "mode": "override_origin", "default": 300 }, "browser_ttl": { "mode": "respect_origin" } } }]}'
That PUT replaces existing rules is easy to miss. In a zone that already has other Cache Rules, open the saved file first and include the rules you want to keep in rules before sending. With the saved copy, rolling back is just sending its rules again the same way.
I also kept the match expression narrow. Responses that differ per user, or that return Set-Cookie, are excluded from the path condition. Serving one person's response to another is not a risk worth trading for speed.
Verify by fetching the same URL repeatedly
Looking at the app screen after the change tells you nothing. Look at the response headers. I wrote a small script that fetches one URL several times with a pause and lines up cf-cache-status and age.
// edge-ttl-check.mjs usage: node edge-ttl-check.mjs <URL> [count] [interval ms]const [url, nArg = '4', waitArg = '1500'] = process.argv.slice(2);if (!url) { console.error('Please pass a URL'); process.exit(2); }const n = Number(nArg), wait = Number(waitArg);const sleep = ms => new Promise(r => setTimeout(r, ms));const rows = [];for (let i = 1; i <= n; i++) { const t0 = performance.now(); const res = await fetch(url, { cache: 'no-store', headers: { 'accept-encoding': 'identity' } }); const body = await res.arrayBuffer(); // read the body so timing is meaningful rows.push({ '#': i, status: res.status, 'cf-cache-status': res.headers.get('cf-cache-status') ?? '(none)', age: res.headers.get('age') ?? '-', 'cache-control': res.headers.get('cache-control') ?? '(none)', bytes: body.byteLength, ms: Math.round(performance.now() - t0), }); if (i < n) await sleep(wait);}console.table(rows);const hits = rows.filter(r => r['cf-cache-status'] === 'HIT').length;if (rows.every(r => r['cf-cache-status'] === '(none)')) { console.log('Verdict: no cf-cache-status header. Not going through Cloudflare, or another layer answers.');} else if (hits === 0) { console.log(`Verdict: no HIT in ${n} requests. The response may be passing through (no-store etc.).`);} else { console.log(`Verdict: ${hits} of ${n} were HIT. The edge is holding the response.`);}
There are three patterns to read. Before the change, every row shows DYNAMIC or no HIT, and ms barely moves. After the change, the first row is MISS, later rows are HIT, and age grows in seconds. And the cache-control column stays the same before and after. If that column didn't change, you have evidence that you gave the device no new instruction.
I ran this script from my own machine and confirmed that the cf-cache-status and age columns line up as expected. On your own API, look once at how much ms shrinks between MISS and HIT. If the gap is small, that path may not be worth caching at all.
The cost: stale responses for as long as the TTL
If the edge holds a response, then right after you update the origin, the edge keeps returning the old list until the TTL expires. With the example above that is up to 5 minutes.
The first thing I guarded against was leaving purge out of the update routine. I add wallpapers, upload them, and the app doesn't show them; then comes the "it's still not reflected" message. I'd rather not receive that one. So I made edit, upload and purge run as one set.
#!/usr/bin/env bash# publish-and-purge.sh usage: ./publish-and-purge.sh [--dry-run] <API path> ...# If the upload fails, do not reach the purge (set -e)set -euo pipefailDRY=0if [ "${1:-}" = "--dry-run" ]; then DRY=1; shift; fi[ "$#" -ge 1 ] || { echo "Pass at least one path to purge" >&2; exit 2; }: "${API_ORIGIN:?e.g. https://api.example.com}": "${CF_ZONE_ID:?}"; : "${CF_API_TOKEN:?}"# 1) Upload (replace with your own deploy command)if [ "$DRY" -eq 1 ]; then echo "[dry-run] upload skipped"; else ./upload.sh; fi# 2) Turn paths into URLs. Different query strings are different cache entries,# so pass exactly what the app requests.files=$(printf '"%s%s",' $(for p in "$@"; do echo "$API_ORIGIN" "$p"; done))body="{\"files\":[${files%,}]}"if [ "$DRY" -eq 1 ]; then echo "[dry-run] POST purge_cache $body"; exit 0; fi# 3) Treat anything but success:true as failureout=$(curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json" --data "$body")echo "$out" | grep -q '"success":true' || { echo "Purge failed: $out" >&2; exit 1; }echo "Purged: $*"
Run --dry-run first to see the JSON that would be sent. Passing a path like /v1/list.php?cat=1 produces {"files":["https://api.example.com/v1/list.php?cat=1", …]}, which I confirmed locally.
Purge by URL removes only URLs that match including the query string. If your API uses a different query per category, pass every category URL affected by the update. Miss one and a single category stays stale, which costs time to track down. For narrowing down which layer is stale, the three-way comparison from an earlier article applies as is.
Five checks before applying it
Confirm both reasons for the pass-through. The extension, no-store, or both. Fixing one will not produce HIT.
Decide first whether device-side instructions will change. As long as shipped app versions exist, be careful about changing the origin's Cache-Control.
Start with a narrow path. Begin with the single heaviest list and widen after you see the effect.
Exclude per-user responses and anything returning Set-Cookie.
Save the previous settings and write the rollback before applying. Rollbacks are what people get wrong when they're in a hurry.
A prompt for the agent
If you hand this job to an agent, put the backup and the purge pairing in the request. This is the wording I use:
Add exactly one Cloudflare Cache Rule.- Target: only paths starting with /v1/list on api.example.com- Edge TTL: override_origin (300 seconds); Browser TTL: respect_origin- First save the current entrypoint ruleset to before-cache-rules.json- PUT replaces existing rules, so include the saved rules in what you send- Afterwards run edge-ttl-check.mjs 4 times and report that the cache-control column did not change- Do not touch other paths or other zones
"Don't touch anything else" and "report that it didn't change" are the two lines that pay off later. A configuration change isn't finished when you report that it worked; it is finished when you have checked what you did not change.
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.