Clearing the Preflight Cache During Development
Symptom: the server now allows PATCH, the deploy log confirms it, curl proves it — and the browser keeps printing the error you already fixed:
Access to fetch at 'https://api.lumenpost.test/v1/messages' from origin
'https://app.lumenpost.test:5173' has been blocked by CORS policy: Method PATCH is not
allowed by Access-Control-Allow-Methods in preflight response.
Reloading does not help. Restarting the dev server does not help. Editing the config again and redeploying does not help, because the browser is not asking the server anything.
Root Cause
The browser stored the answer to a previous OPTIONS request and is applying it without going back to the network. That stored entry says PATCH is not permitted, and it will keep saying so until its TTL expires or something evicts it. Nothing you do on the server can reach it: the preflight cache is browser-side state, it is not addressed by Cache-Control, and — critically — it is not cleared by a hard reload, because the hard reload path only bypasses the HTTP cache for the resources the page fetches. Once the entry exists, the edit-reload loop you are running is a loop with the network removed from it.
This page belongs to Browser Preflight Cache Limits, which covers what each engine stores, for how long, and what evicts it.
Prerequisite State
- The server-side fix is already deployed and reachable — this page is about the browser, not the config.
curlis available, so you can compare what the server says with what the browser believes.- DevTools can be opened on the failing page and left open while you iterate.
- You know which browser profile the failing tab belongs to. A stale entry lives in one profile’s partition and will not appear in another.
Step-by-Step Fix
Step 1 — Prove the entry is stale before touching anything else
The whole diagnosis is one comparison: does the server return the corrected answer to a direct request?
curl -sSi -X OPTIONS https://api.lumenpost.test/v1/messages \
-H 'Origin: https://app.lumenpost.test:5173' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: content-type' \
| grep -iE '^HTTP/|^access-control-'
If access-control-allow-methods contains PATCH here but the browser still refuses, the server is correct and the browser is holding an old entry. If PATCH is missing here too, stop — this is a server problem and clearing caches will waste an afternoon.
Step 2 — Unblock yourself immediately with Disable cache
The fastest lever needs no clearing at all. In Chrome, Edge and Firefox, opening DevTools and ticking Disable cache on the Network panel makes the browser skip the stored preflight entry for as long as DevTools stays open. Every non-simple call issues a fresh OPTIONS, so you see the server’s current answer on every iteration.
This is the right mode for the whole time you are actively editing CORS configuration. It costs one extra round trip per call locally and removes the entire class of confusion. In Safari the equivalent is Develop → Disable Caches, which behaves the same way while Web Inspector is open.
Step 3 — Clear the entry outright when you need a clean profile
When you need the normal, cache-enabled path to be correct — testing the TTL itself, or handing the build to someone else — clear the entry. The levers differ per browser, and most of the ones developers reach for first do nothing.
| Lever | Chrome / Edge | Firefox | Safari | Clears the preflight entry? |
|---|---|---|---|---|
| Normal reload | yes | yes | yes | no |
| Hard reload / cache-bypass reload | yes | yes | yes | no |
| DevTools Disable cache while open | yes | yes | via Develop → Disable Caches | bypassed, not cleared |
| DevTools → Application → Storage → Clear site data | yes | via Storage panel | via Storage panel | yes |
| Settings → Clear browsing data → Cached images and files | yes | “Cached Web Content” | Develop → Empty Caches | yes |
chrome://net-internals/#sockets → Flush socket pools |
yes | not applicable | not applicable | yes |
| Open a private window | yes | yes | yes | not needed — the partition starts empty |
| Quit and relaunch the browser | yes | yes | yes | yes, the store is memory-only |
| Toggle Wi-Fi off and on | yes | yes | yes | yes, network change flushes it |
The two rows worth memorising are the first two. Neither reload path clears preflight state, and both are what a developer tries first — which is exactly how twenty minutes disappear into re-deploying a server that was already correct.
Step 4 — Stop creating the entry in development at all
Clearing is a workaround; not caching is the fix. Emit Access-Control-Max-Age: 0 when the environment is local or a preview build, and a real TTL everywhere else. One conditional, in one place, and the whole failure mode disappears from the team’s day.
// cors-plugin.js — Fastify 4/5
'use strict';
const DEV = process.env.NODE_ENV !== 'production';
const ALLOWED = new Set([
'https://app.lumenpost.test:5173',
'https://app.lumenpost.dev',
]);
module.exports = async function corsPlugin(fastify) {
fastify.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin;
reply.header('Vary', 'Origin');
if (!origin || !ALLOWED.has(origin)) return;
reply.header('Access-Control-Allow-Origin', origin);
reply.header('Access-Control-Allow-Credentials', 'true');
if (request.method !== 'OPTIONS') return;
reply.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// 0 in dev so an edit is visible on the very next call; 600 in production.
reply.header('Access-Control-Max-Age', DEV ? '0' : '600');
return reply.code(204).send();
});
};
If your front end talks to the API through the dev server’s proxy rather than directly, the same conditional belongs there instead — the browser only ever sees the proxy’s headers, so that is the layer whose TTL matters.
// vite.config.js — proxy the API and neutralise any cached preflight in dev
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 5173,
proxy: {
'/v1': {
target: 'https://api.lumenpost.test',
changeOrigin: true,
secure: false,
configure(proxy) {
proxy.on('proxyRes', (proxyRes) => {
proxyRes.headers['access-control-max-age'] = '0';
});
},
},
},
},
});
Note that proxying /v1 through the dev server usually makes the call same-origin, which removes the preflight entirely — a legitimate development strategy, and also a reason local testing can miss a real CORS bug. Keep at least one path that talks to the API cross-origin, exactly as production does.
Step 5 — Move the key when you cannot clear
If the failing browser is not yours — a colleague’s machine, a stakeholder’s tablet — you cannot ask them to open DevTools. Change the request URL instead. A stored entry is keyed on the URL, so a new path or an extra query parameter is guaranteed to miss every entry in every engine.
// During an active debugging session only. Remove before merging.
const bust = import.meta.env.DEV ? `?cachebust=${Date.now()}` : '';
await fetch(`https://api.lumenpost.test/v1/messages${bust}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
});
This is a debugging tool, not a pattern to ship: a per-call unique URL means every call is a cache miss for everyone, forever. The durable version of the same idea is a version prefix that changes when the policy changes.
Step 6 — Give the team a one-command reset
The levers in Step 3 are all manual, and manual steps get skipped. On a shared project it is worth adding a scripted reset that launches a throwaway browser profile pointed at the local app — a cold profile has an empty preflight store by definition, and nothing in the developer’s real profile is touched.
#!/usr/bin/env bash
# scripts/dev-browser.sh — a disposable Chrome profile with no cached preflight state
set -euo pipefail
PROFILE="$(mktemp -d)"
trap 'rm -rf "$PROFILE"' EXIT
exec /usr/bin/google-chrome \
--user-data-dir="$PROFILE" \
--no-first-run \
--auto-open-devtools-for-tabs \
"https://app.lumenpost.test:5173/"
Each run starts from nothing, so the first OPTIONS for every endpoint goes to the network and reflects whatever the server currently says. Because the profile is deleted on exit, it also avoids the slower failure this page describes in reverse: a long-lived development profile that has accumulated entries from three different branches of the same API.
If the front end registers a service worker, add one more step before you conclude anything. A worker that survived the last reload can serve the whole request from its own cache, in which case the OPTIONS never happens for a reason that has nothing to do with the preflight store. Unregister it from Application → Service Workers, or tick Bypass for network, and re-test.
A local setup usually has four places a stale answer can be sitting, and each one has its own lever. Walking the path left to right tells you which one you are actually fighting:
The order matters. Clearing the API’s CDN before checking the preflight store is the most common way to spend an hour on the wrong layer, because every hop to the right of the stale one is invisible from the browser.
Verification
curl — the server’s current answer, with no browser state involved:
curl -sSi -X OPTIONS https://api.lumenpost.test/v1/messages \
-H 'Origin: https://app.lumenpost.test:5173' \
-H 'Access-Control-Request-Method: PATCH' \
| grep -iE '^HTTP/|^access-control-allow-methods|^access-control-max-age'
Expected in development: HTTP/2 204, access-control-allow-methods containing PATCH, and access-control-max-age: 0.
DevTools — confirm the entry is gone and stays gone:
Security Boundary Note
Access-Control-Max-Age: 0 is safe to ship to development and preview environments and wrong to ship to production, but the more consequential detail is the allowlist beside it. Development configurations accumulate permissive entries — a bare *, a reflected Origin, an extra http:// variant for convenience — and those survive into production far more often than the TTL does. Keep the origin allowlist explicit and environment-scoped, and never reflect an arbitrary Origin just to make a local port work; the safe patterns for local development are collected in Safely Allowing localhost Origins in Development. Equally, remember that clearing your own cache proves nothing about a user still holding a grant issued before a policy narrowing — that has to be enforced on the actual request.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Assuming a hard reload clears the preflight entry | The stale answer keeps blocking the call, so the server gets re-edited and re-deployed without effect | Tick Disable cache, or clear site data; the reload path never touches this store |
| Debugging in a private window and concluding the bug is fixed | The private partition starts empty, so it always shows the corrected policy while normal users stay broken | Confirm the fix in the normal profile before closing the ticket |
Leaving Access-Control-Max-Age: 0 in the production configuration |
Every non-simple request pays a full preflight round trip, doubling request count and latency | Gate the zero behind an explicit environment check with a real TTL as the default |
| Proxying the API through the dev server and testing only that path | The call becomes same-origin locally, so no preflight is exercised and CORS bugs surface first in production | Keep one development path that reaches the API cross-origin |
FAQ
Does a hard reload clear the preflight cache?
No. A hard reload bypasses the HTTP cache for the documents and subresources the page loads, but it does not touch the preflight store, which is keyed separately. This is the single most common reason a developer concludes the server fix did not deploy. Use Disable cache in DevTools, which does bypass the stored entry, or clear site data for a permanent removal.
Why does the same call work in a private window but not a normal one?
A private window uses its own partition with an empty preflight store, so it re-runs the OPTIONS request and gets your corrected answer. The normal window is still holding the entry created before the fix. That contrast is itself the diagnosis: the server is right and the cache is stale. It is also why a private window is a bad place to confirm a fix — it can only ever show you the server’s current state.
Should I ship Access-Control-Max-Age: 0 to production as well?
No. Zero forces an OPTIONS round trip before every non-simple call, which is exactly the cost the header exists to remove. Gate the zero behind an environment check so local and preview environments iterate freely while production keeps a real TTL. Choosing that production number is a separate exercise, covered in Cache Duration Tuning & Max-Age.