Introduction
A public support catalog receives repeated requests for the same language and category. Reusing responses can reduce repeated work, but a cache must never mix customer-specific data or turn an error into cached public content. You will observe uncached generation, add an explicit cache policy, test expiry and targeted invalidation, then deploy and verify its boundaries.
This independent lab starts in /home/labex/project/public-cache with Node.js 22.22.0, project-local Wrangler 4.131.1, Miniflare 4.20260730.0 for isolated assessment and a synthetic response fixture. Use your own learning account and previously taught authorization/deployment/secret workflows. No previous VM, resource, purchased domain, database or paid upgrade is required. Requests count toward normal account usage.
Keep one terminal open. All catalog data and credentials are synthetic. The Cache API contents are local to a serving location; a global network is not a globally replicated cache. Finish by deleting the Worker, removing the local secret and logging out.
Observe Fresh Public Catalog Responses
In this step, you will inspect a supplied synthetic catalog and establish its uncached behavior. The fixture generates a new UUID for each response so reuse is observable without timing guesses or a database.
cd /home/labex/project/public-cache
node --version
npx wrangler --version
cat src/catalog.js
Expect Node.js v22.22.0 and Wrangler 4.131.1. Setup installed exact project dependencies; reproduce an existing installation with its lockfile and npm ci. The assessment runtime also uses Miniflare 4.20260730.0, matching the compatibility date. The fixture varies by language, category and synthetic customer; it can simulate a 503 with X-Demo-Failure: 1. These are test inputs, not real identity credentials.
WORKER_NAME="labex-cache-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<CONFIG
{
"name": "$WORKER_NAME",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"workers_dev": true,
"preview_urls": false
}
CONFIG
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
}
return deliver(catalog(request, language, category), 'BYPASS');
}
};
JS
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
Wait for readiness before requests. Repeat cat dev.log if startup is still in progress. Keep this terminal open so its shell variables remain available.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
Both requests return 200 with audience public and different generation UUIDs. X-Lab-Cache: BYPASS means this handler did not look up or write its cache. Client-facing Cache-Control: no-store keeps the browser/client cache out of the experiment. Use verification before replacing the baseline.
Cache Only Eligible Public Responses
In this step, you will add Cache API lookup and storage. Stop the current dev job shown by jobs; the example assumes job 1.
jobs
kill %1
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
const keyUrl = new URL('/api/catalog', url.origin);
keyUrl.searchParams.set('category', category);
keyUrl.searchParams.set('lang', language);
const key = new Request(keyUrl, {method: 'GET'});
const cache = caches.default;
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
}
// Decide eligibility before lookup: a warm public entry must not mask private work or errors.
const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
.some(name => request.headers.has(name));
if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
const cached = await cache.match(key);
if (cached) return deliver(cached, 'HIT');
const response = catalog(request, language, category);
if (response.status !== 200 || response.headers.has('Set-Cookie')) {
return deliver(response, 'BYPASS');
}
const stored = response.clone();
stored.headers.set('Cache-Control', 'public, max-age=10');
// Await completion here so the next request can observe the write.
await cache.put(key, stored);
return deliver(response, 'MISS');
}
};
JS
The key uses the current origin plus a fixed route, category and language. Parameter ordering is canonical, while both content dimensions remain distinct. Unknown parameters and duplicate dimensions are rejected instead of silently changing key meaning.
Eligibility is checked before lookup. Authorization, Cookie and synthetic customer headers bypass a warm public entry. The failure fixture also bypasses lookup so an error is not hidden by cached success. Only a successful response without Set-Cookie is stored. We clone it because response bodies are streams, give the stored copy a 10-second TTL, and await the write. Returned responses keep no-store; the internal Cache API entry has its own cache policy.
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
Wait for readiness before requests. Repeat cat dev.log if startup is still in progress. Keep this terminal open so its shell variables remain available.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?category=network&lang=en"
curl -i "http://127.0.0.1:8080/api/catalog?lang=fr&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=printer"
Run the first two requests within ten seconds. A first uncached response says MISS; a repeat says HIT and retains the same generation. Reversing query parameter order does not change the key. French and printer variants have the requested dimensions and independent entries. If the TTL expires while reading, repeat a pair promptly; do not assume cache contents live forever.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Cookie: demo=synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
Customer/identity-bearing requests return BYPASS and the appropriate synthetic audience, never another customer's result. The simulated error returns 503 BYPASS even when public data is warm. A later public request still returns public data, not the error. Use verification with the local server running. It also exercises the handler in an isolated local runtime; it does not modify a cloud cache.
Expire and Invalidate a Local Cache Entry
In this step, you will add an authenticated invalidation operation for the same canonical key. This is a local data-center deletion, not a global purge. Stop the actual dev job before editing.
jobs
kill %1
umask 077
PURGE_TOKEN=$(openssl rand -hex 24)
printf 'PURGE_TOKEN=%s\n' "$PURGE_TOKEN" > .dev.vars
cat .gitignore
Keep the synthetic secret out of Git, public configuration, URLs and logs. It protects this lab's DELETE operation; it is not a Cloudflare API token.
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
const keyUrl = new URL('/api/catalog', url.origin);
keyUrl.searchParams.set('category', category);
keyUrl.searchParams.set('lang', language);
const key = new Request(keyUrl, {method: 'GET'});
const cache = caches.default;
if (request.method === 'DELETE') {
if (!env.PURGE_TOKEN) return Response.json({error: 'purge_unconfigured'}, {status: 503});
if (request.headers.get('Authorization') !== `Bearer ${env.PURGE_TOKEN}`) {
return Response.json({error: 'unauthorized'}, {status: 401, headers: {'Cache-Control': 'no-store'}});
}
const invalidated = await cache.delete(key);
return Response.json({invalidated, scope: 'this-location'}, {
headers: {'Cache-Control': 'no-store', 'X-Lab-Cache': 'BYPASS'}
});
}
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET, DELETE'}});
}
// Decide eligibility before lookup: a warm public entry must not mask private work or errors.
const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
.some(name => request.headers.has(name));
if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
const cached = await cache.match(key);
if (cached) return deliver(cached, 'HIT');
const response = catalog(request, language, category);
if (response.status !== 200 || response.headers.has('Set-Cookie')) {
return deliver(response, 'BYPASS');
}
const stored = response.clone();
stored.headers.set('Cache-Control', 'public, max-age=10');
// Await completion here so the next request can observe the write.
await cache.put(key, stored);
return deliver(response, 'MISS');
}
};
JS
DELETE validates the credential before calling cache.delete with the same GET key used for lookup/storage. The returned Boolean says whether an entry existed here. Unauthorized deletion must leave it untouched. Requests to another location may still encounter their own entry.
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
Wait for readiness before requests. Repeat cat dev.log if startup is still in progress. Keep this terminal open so its shell variables remain available.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer $PURGE_TOKEN"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
An unauthorized DELETE returns 401. A valid DELETE returns scope: this-location and normally invalidated: true for a still-live entry. False is also meaningful if the short TTL already expired. The next GET returns MISS with a fresh generation. To demonstrate true, run a GET immediately before the authorized DELETE.
sleep 11
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
After eleven seconds, a new MISS demonstrates expiry without an explicit deletion. Use verification with the server running: an isolated runtime checks reuse, dimension separation, private/error exclusion, rejected deletion, successful targeted deletion, preservation of a different key and expiration. These controlled local assertions provide repeatable evidence without assuming global cache state.
The Cache API documentation explains its data-center scope, response-header behavior and cache.delete. Cache API and platform caching that skips Worker execution are separate mechanisms.
Deploy and Check Cache Boundaries
In this step, you will deploy the completed handler to your learning account. Stop the actual local job, authorize this fresh VM, then inspect account identity.
jobs
kill %1
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_tail:read
Complete the displayed device link/code in your signed-in browser, review the unchanged permissions and Background Access, and select your learning account. Wait for terminal success.
npx wrangler whoami --json
Confirm the intended account name. Replace YOUR_ACCOUNT_ID with its actual ID below, retaining your unique name.
cat > wrangler.jsonc <<CONFIG
{
"name": "$WORKER_NAME",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"workers_dev": true,
"preview_urls": false,
"account_id": "YOUR_ACCOUNT_ID"
}
CONFIG
npx wrangler deploy
npx wrangler secret bulk .dev.vars
npx wrangler secret list
The local secret file is not uploaded by deploy; the explicit bulk command creates PURGE_TOKEN as secret_text. Allow a short propagation interval after deployment. Copy the actual public URL from Wrangler below.
APP_URL="https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev"
curl -i "$APP_URL/api/catalog?lang=en&category=network"
curl -i "$APP_URL/api/catalog?category=network&lang=en"
curl -i "$APP_URL/api/catalog?lang=fr&category=network"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
Public responses must have the requested language/category and public audience. Same-location repeats within the TTL can show HIT and retain a generation; another location or expiry can legitimately produce MISS. Do not assert globally shared contents from two requests. Private requests must always bypass, and the failure must be 503 BYPASS.
In the same Dashboard account, open Compute → Workers & Pages and confirm the exact Worker and its workers.dev URL. Use verification to check ownership, the deployed secret binding and response boundaries. It performs no cloud invalidation. The invalidation behavior was tested locally; cache.delete is not a global purge mechanism. If deployment is still propagating, wait briefly and repeat the response checks; investigate a persistent mismatch rather than accepting it.
Delete the Disposable Worker
In this step, remove this lab's deployment while still authorized. Confirm the unique name and account, then delete only this Worker.
cat wrangler.jsonc
npx wrangler delete
At the matching name prompt, press the single key y. Wrangler 4.131.1 may report the known legacy Workers Sites KV authentication diagnostic after deletion. Do not broaden permissions or treat that error as proof. Refresh Dashboard and use verification: a successful authenticated inventory must show this exact Worker absent. Preserve the learning account and its subdomain. Deleting the Worker is not a claim that every cache entry was globally purged; the synthetic entries have a ten-second TTL and no running application should remain.
Remove the Local Secret and Disconnect
In this step, remove the local disposable credential after deletion is verified, then disconnect this VM.
rm .dev.vars
unset PURGE_TOKEN
npx wrangler logout
npx wrangler whoami --json
Require explicit loggedIn: false; the unauthenticated structured command may exit nonzero. Use verification and end the VM. Browser login can remain available. Logging out or ending the VM does not delete a cloud deployment for you.
Summary
You replaced uncached catalog generation with explicit public-response caching, preserved language/category key separation, and bypassed private and failed requests before lookup. You tested short-lived entries and authenticated invalidation in a controlled local runtime, then verified the deployed application's identity and response boundaries without assuming globally shared cache contents.
The stored copy's TTL and the caller's cache policy serve different purposes. Deliberate eligibility, complete keys and observable response generations make that distinction testable. You removed the disposable deployment and local credential before disconnecting the VM.

