Engineering noteResearch & Engineering
Part of: Astro on Workers
Worker First Is Not Middleware First: Astro on Cloudflare
In our Astro setup, we got 404s for old image URLs. We trace why we needed redirects before the adapter, even with Worker first.
The programme
On this page
On 6 October 2026, all 14 old image URLs we checked returned 404 in production, although we had redirects for them in Astro middleware. We had already enabled run_worker_first = true. The requests reached our Worker, but the adapter and Astro returned our prerendered 404 page without running middleware. We moved the redirects into the Worker entry, before the adapter.
The placement matters whenever a redirect or header must cover built files, prerendered pages and missing paths as well as on-demand routes. There are two routing decisions to follow: Cloudflare decides whether to invoke the Worker; once invoked, the adapter decides whether to render an Astro route. We traced both in Astro 7.2.10, @astrojs/cloudflare 14.2.4 and Wrangler 4.107.0, as installed on 15 October 2026. The results below apply to those versions with a prerendered 404 page.
This is the request-routing part of our Astro website architecture. The wider platform is described in How TEEI runs its platforms on Cloudflare Workers.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
First decision: does Cloudflare invoke the Worker?
Cloudflare’s Static Assets routing defaults to assets first: when a URL resolves to a built file, the asset layer answers without invoking Worker code. With assets.run_worker_first = true, the Worker runs before that lookup. We first deployed the website as a Worker on 9 May 2026, next to its Pages project and before any traffic moved, and use this setting to retain the request order we had on Pages; Pages ran Functions ahead of assets by default.
For assets-first routing, the compatibility date and flags also affect requests that do not match a file. From compatibility date 2025-04-01, the default is for a request carrying Sec-Fetch-Mode: navigate to prefer the asset layer even when no asset matches. With our reproduction’s not_found_handling = "404-page", that returns the built 404 page instead of invoking the Worker. A browser navigation to an on-demand route can therefore fail while plain curl succeeds.
The date selects the default. The explicit flags assets_ and assets_ enable or disable that navigation behaviour. With run_worker_first = true, it has no effect.
A browser-only 404, reproduced
Our local project has a prerendered page, an on-demand page and a prerendered 404 page. The Worker adds x-worker: ran; middleware adds x-middleware: ran to responses that pass back through it. On 15 October 2026 we sent each path two HEAD requests in wrangler dev: one with Sec-Fetch-Mode: navigate, one without it.
| Setting | Compatibility date | /static/ (prerendered) | /dynamic (on demand) | /nope/ (no route) |
|---|---|---|---|---|
run_worker_first = true | 2024-09-23 | 200, Worker | 200, Worker and middleware | 404, Worker |
run_worker_first = false | 2024-09-23 | 200, neither | 200, Worker and middleware | 404, Worker |
run_worker_first = false | 2025-04-02 | 200, neither | Navigation: 404, neither. Plain: 200, Worker and middleware | Navigation: 404, neither. Plain: 404, Worker |
run_worker_first = true | 2025-04-02 | 200, Worker | 200, Worker and middleware | 404, Worker |
The third row isolates the browser-only failure: the navigation header changes /dynamic from a rendered 200 into an asset-layer 404. All rows use not_found_handling = "404-page" and the default navigation flags; we did not compare "none". These probes identify routing, not preservation of the HEAD method: the adapter’s asset helpers receive request.url rather than the original Request.
The second row also disproves a comment we kept until 15 October: Worker first is not required for Astro SSR. The on-demand route renders with false at compatibility date 2024-09-23, the date in our configuration read that day. We corrected the comment. This reproduction does not establish the cause of the historical 404s mentioned in it; we found no request trace for those, and our date predates the navigation change.
Second decision: does the adapter render a route?
Once the Worker runs, it can answer in its own entry or call the adapter. Reaching the adapter still leaves three possible outcomes: it returns a built file, renders an on-demand route with middleware, or asks Astro for a prerendered error page without middleware. The deciding branches in @astrojs/cloudflare 14.2.4 are:
Adapted for publication. We omit the session setup and the development-mode branch and remove the function’s indentation; the retained lines are otherwise unchanged.
const staticAsset = matchStaticAsset(app.manifest, request.url, env);
if (staticAsset) return staticAsset;
let routeData = void 0;
routeData = app.match(request);
if (!routeData) {
const asset = await fallbackToAssets(request.url, env);
if (asset) return asset;
}
const locals = createLocals(context);
const waitUntil = context.waitUntil.bind(context);
let response = await app.render(request, {
routeData,
locals,
waitUntil,
prerenderedErrorPageFetch: createErrorPageFetch(env),
clientAddress: getClientAddress(request)
});
And its two helpers, from the same package:
Verbatim excerpt.
function matchStaticAsset(manifest, requestUrl, env) {
const { pathname } = new URL(requestUrl);
if (manifest.assets.has(pathname)) {
return env.ASSETS.fetch(requestUrl.replace(/\.html$/, ""));
}
return void 0;
}
async function fallbackToAssets(requestUrl, env) {
const asset = await env.ASSETS.fetch(
requestUrl.replace(/index.html$/, "").replace(/\.html$/, "")
);
if (asset.status !== 404) {
return asset;
}
return void 0;
}
A built file, such as a hashed CSS file under /_astro/, is in manifest.assets, so matchStaticAsset returns it before app.match runs. A prerendered page such as /about/ misses there, because the manifest holds /about/index.html and the comparison is exact. app.match misses too, because Astro leaves prerendered routes out of request-time matching, so fallbackToAssets returns the built HTML. Only an on-demand route, page or endpoint, reaches app.render with a route and runs its middleware.
The path with no route is the one that surprised us. It falls through both helpers, and the asset binding’s 404 is only a routing signal: fallbackToAssets returns nothing, so handle() continues into app.render without a route. With our prerendered 404 page, Astro fetches the built 404.html through prerenderedErrorPageFetch, keeps the 404 status and does not run middleware. With an on-demand 404 page, Astro would run middleware for it instead. That is what happened to our 14 image URLs: none had an Astro route, so the adapter returned the prerendered 404 page and our middleware’s redirect map never ran.
Astro’s middleware guide makes error-page behaviour the adapter’s choice. Middleware for prerendered pages runs at build time. In the configuration traced here, request-time middleware covers on-demand routes; the built files, prerendered pages and prerendered 404 response need an earlier place for request-time redirects or headers.
Headers split the same way. Cloudflare applies _headers rules to static asset responses, not to “responses generated by your Worker code”. With assets first, a header that must be on every response needs _headers for files and the Worker for the rest. With Worker first, we can add headers in one place if we wrap every response from the entry, including adapter responses and early returns; a header added after the adapter’s handler covers only what that handler answers. The reproduction in the appendix, which has no early returns, adds a marker header there, and it is on the prerendered page, the on-demand page and the 404 alike.
Moving redirects into the Worker entry
At build time, we generate a Worker entry with the adapter, then wrap that output in a small entry of our own that runs before Astro sees the request. This is its fetch:
Adapted for publication. Selected branches show the sitemap and redirect handling before the adapter and the repair after it. Other branches, among them the byte-range video step, the code comments and the scheduled handler are omitted; the retained lines are unchanged.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const pathname = url.pathname;
if (pathname === '/sitemap.xml') {
return Response.redirect(new URL('/sitemap-index.xml', url.origin).toString(), 308);
}
const redirectTarget = lookupRedirect(pathname);
if (redirectTarget) {
return Response.redirect(redirectKeepingQuery(redirectTarget, url), 301);
}
const res = await _astroHandler.fetch(request, env, ctx);
return repairFallbackRedirect(res, url);
},
};
_astroHandler is the entry the adapter built; repairFallbackRedirect handles a separate case on the way out. The sitemap branch shows why this needs Worker first: with assets first, an old sitemap.xml left in the build would be served as a file before the redirect could run.
lookupRedirect reads maps we generate at build time. We copy page redirects from middleware and share one module for old image redirects, so we use one source for each redirect in both layers. For retired prerendered pages, we can also generate a map from the build, behind a build switch that is off by default.
The same placement also covers two other cases we encountered. On 7 October 2026, two Ukrainian Skills Academy addresses served fallback pages that forwarded after two seconds, while a Spanish address returned 404. On 10 October we added a 301 for all three addresses to the Worker entry. On 12 October, Ahrefs listed 476 retired pages we still built as 200 responses with a meta refresh; a middleware redirect could not replace a prerendered response at request time. We did not measure lost visits. The separate build-side problem is covered in Why we moved Astro redirects out of the build.
Each change came with a test against the entry we generate, which checks that old addresses redirect before a stand-in for Astro is called and that other paths still reach it; the reproduction below exercises the adapter. We rely on the results recorded in the commits of 6, 10 and 12 October 2026 and did not rerun the tests for this article.
Choose the routing order for the behaviour you need
Every file becomes a Worker invocation. With true, the Worker runs for each stylesheet, image and font, while Cloudflare’s pricing page says “Requests to static assets are free and unlimited”. We have not measured what Worker-first routing costs us.
The list form is not a drop-in middle way. The binding reference lets run_worker_first take route patterns, with ! to exclude asset folders, and the single-page application guide says the list “disables the automatic Sec-Fetch-Mode: navigate detection”. In the local Wrangler 4.107.0 code we read, with "404-page" and a built 404.html, a missing path that matches no pattern gets the 404 page without the Worker running. The patterns would need to cover every built-page redirect and missing path the Worker must handle. We have not used the list form or run it in our reproduction.
Use assets first when built files need no Worker behaviour. It serves them without a Worker invocation. Test browser navigation to on-demand routes with the deployed compatibility date and flags; a plain curl request is not the same routing case.
Development and production can route differently. In the setup examined on 15 October 2026, we ran astro dev with assets first, because with Worker first the dev route matcher took over Vite’s own module and source URLs. We tested redirects that needed Worker first against the wrapped entry; the assets-first dev setup could not exercise that routing order.
Use Worker first when code must run before any asset answer. An old file can otherwise win over a redirect for the same path. Put that redirect before the adapter; reserve Astro middleware for behaviour that belongs to rendering an on-demand route.
Appendix: reproduce it in your own project
We wrote this project for the article on 15 October 2026; it contains nothing of TEEI’s. Save each block in an empty folder under the name above it.
./package.json
Verbatim excerpt.
{ "name": "repro-worker-first", "private": true, "type": "module",
"scripts": { "build": "astro build" },
"dependencies": { "astro": "7.2.10", "@astrojs/cloudflare": "14.2.4", "wrangler": "4.107.0" } }
./astro.config.mjs
Verbatim excerpt.
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare() });
./wrangler.jsonc
Verbatim excerpt.
{
"name": "repro-worker-first",
"main": "./src/worker.ts",
"compatibility_date": "2024-09-23",
"compatibility_flags": ["nodejs_compat"],
"assets": { "binding": "ASSETS", "directory": "./dist/client", "run_worker_first": true, "not_found_handling": "404-page" }
}
./src/worker.ts
Verbatim excerpt.
import { handle } from '@astrojs/cloudflare/handler';
export default {
async fetch(request: Request, env: any, ctx: any) {
const res = await handle(request, env, ctx);
const out = new Response(res.body, res);
out.headers.set('x-worker', 'ran');
return out;
},
};
./src/middleware.ts
Verbatim excerpt.
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (_ctx, next) => {
const res = await next();
const out = new Response(res.body, res);
out.headers.set('x-middleware', 'ran');
return out;
});
./src/pages/static.astro
Verbatim excerpt.
---
export const prerender = true;
---
<h1>prerendered</h1>
./src/pages/dynamic.astro
Verbatim excerpt.
---
export const prerender = false;
---
<h1>on demand</h1>
./src/pages/404.astro
Verbatim excerpt.
---
export const prerender = true;
---
<h1>not found</h1>
The probe runs wrangler dev against the configuration the adapter generated, with run_worker_first and the compatibility date replaced, and prints the status and markers for each path and request type.
./probe.sh
Adapted for publication. We omit the comments. This version edits the generated configuration as JSON instead of with the sed line we ran on 15 October, and prints the values it reads back from the written file, so a formatting change cannot leave the old settings in place while the output names the new ones. It refuses to start if port 8797 already answers, waits until Wrangler reports that it is ready, and stops with the last lines of Wrangler’s log if the server exits or is not ready within 60 seconds (PROBE_WAIT changes the limit). The first argument sets run_worker_first, the second the compatibility date.
The table above comes from the probe we recorded on 15 October 2026, not from this version. On 22 October 2026 we ran this version, byte for byte, against wrangler dev in a copy of the reproduction above, with Astro 7.2.10, @astrojs/cloudflare 14.2.4, Wrangler 4.107.0 and workerd 2026-10-12. All four cases printed the same six rows as the recorded probe. We also checked that it stops with an error and prints no measurement rows when the port is already taken, when the Worker cannot start and when the wait runs out.
#!/usr/bin/env bash
set -euo pipefail
RWF="${1:?true|false}"; CD="${2:?compatibility_date}"
HOST=127.0.0.1; PORT=8797; WAIT="${PROBE_WAIT:-60}"
node --input-type=module - "$RWF" "$CD" <<'NODE'
import fs from 'node:fs';
const [workerFirst, date] = process.argv.slice(2);
if (!['true', 'false'].includes(workerFirst)) throw new Error('Expected true or false');
const config = JSON.parse(fs.readFileSync('dist/server/wrangler.json', 'utf8'));
config.assets = { ...config.assets, run_worker_first: workerFirst === 'true' };
config.compatibility_date = date;
fs.writeFileSync('dist/server/wrangler.probe.json', JSON.stringify(config, null, 2));
const written = JSON.parse(fs.readFileSync('dist/server/wrangler.probe.json', 'utf8'));
if (written.assets.run_worker_first !== (workerFirst === 'true') || written.compatibility_date !== date) {
throw new Error('wrangler.probe.json does not hold the requested values');
}
console.log(`run_worker_first=${written.assets.run_worker_first} compatibility_date=${written.compatibility_date}`);
NODE
if (exec 3<>"/dev/tcp/$HOST/$PORT") 2>/dev/null; then
echo "Port $PORT already answers. Stop that server first, or the probe would measure it." >&2
exit 1
fi
LOG="$(mktemp)"
set -m
npx wrangler dev --config dist/server/wrangler.probe.json --port "$PORT" --ip "$HOST" < /dev/null > "$LOG" 2>&1 &
PID=$!
set +m
cleanup() {
local status=$?
kill -- "-$PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- "-$PID" 2>/dev/null || break; sleep 1; done
kill -KILL -- "-$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
[ "$status" -ne 0 ] || rm -f "$LOG"
}
trap cleanup EXIT
fail() { echo "$1. Last lines of the Wrangler log ($LOG):" >&2; tail -n 20 "$LOG" >&2; exit 1; }
for i in $(seq 1 "$WAIT"); do
kill -0 "$PID" 2>/dev/null || fail "wrangler dev exited before it was ready"
grep -F 'Ready on' "$LOG" | grep -qF "//$HOST:$PORT" && break
[ "$i" -lt "$WAIT" ] || fail "wrangler dev was not ready after $WAIT s"
sleep 1
done
curl -s -o /dev/null "http://$HOST:$PORT/static/" || fail "wrangler dev reported ready, but port $PORT does not answer"
for p in /static/ /dynamic /nope/; do
for h in "Sec-Fetch-Mode: navigate" "X-Plain: 1"; do
printf '%-10s %-26s ' "$p" "[$h]"
curl -sI -H "$h" "http://$HOST:$PORT$p" | tr -d '\r' | grep -iE '^HTTP|^x-worker|^x-middleware' | sed -E 's#HTTP/1.1 ##' | tr '\n' ' ' || fail "No answer from $p"
echo
done
done
kill -0 "$PID" 2>/dev/null || fail "wrangler dev exited during the probe"
Install and build, then run all four cases from the table:
npm install
npx astro build
bash probe.sh true 2024-09-23
bash probe.sh false 2024-09-23
bash probe.sh true 2025-04-02
bash probe.sh false 2025-04-02
The last probe leaves the browser-only failure configuration in place. After it exits, start npx wrangler dev --config dist/server/wrangler.probe.json --port 8797 --ip 127.0.0.1 and open /dynamic on that port. Compare that navigation with plain curl and curl with Sec-Fetch-Mode: navigate. Expect a 200 with both markers for the plain request and a 404 with neither for the navigation request. Then change to Worker first and rerun: both request types should reach the on-demand route, while /static/ and /nope/ should still lack the middleware marker.
The site’s generated configuration read on 15 October 2026 had the same asset settings and compatibility date as the reproduction, with the nodejs_compat_v2 and nodejs_als flags instead of nodejs_compat.
Continue in the Astro series
Return to the homepage case to follow one response into a visible update. Continue with pruning fallback redirect pages and answering their old URLs for the separate decisions about build output and redirects before the Worker.
Frequently asked questions
Does Astro middleware run for prerendered pages on Cloudflare Workers?
Not at request time with astro 7.2.10 and @astrojs/cloudflare 14.2.4, as installed on 15 October 2026. The adapter answers files and prerendered pages from the asset binding without running middleware, even when run_worker_first is true.
Is run_worker_first required for Astro server rendering, and why does a route return 404 only in the browser?
It is not required: in our reproduction, an on-demand route rendered with run_worker_first false and a compatibility date of 2024-09-23. With run_worker_first false, a compatibility date of 2025-04-01 or later and the default flags, requests with Sec-Fetch-Mode: navigate go to the asset layer and get the 404 page when no file matches. curl sends no such header by default, so its request reaches the Worker. Setting run_worker_first to true removes the effect.
Where should redirects live so they work for every path?
In the Worker entry, before the Astro handler, with run_worker_first set to true. With these versions and a prerendered 404 page, a redirect that lives only in Astro middleware is skipped for files, prerendered pages and paths with no route.
Does the _headers file apply to responses from my Worker?
No, only to static asset responses. With run_worker_first set to true, one wrapper around the whole Worker entry can add a header to every response, including any your own code returns before it calls the adapter.