Guushu Studio / Notes

Cloudflare Worker redirect not working? Assets are served first

Cloudflare Worker redirect not working on some paths? Static assets answer before your script runs. 2 fixes: run_worker_first, or Single Redirects at the zone.

By · · updated

My www host was supposed to 301 everything. For 19 days it returned 200 on every page that actually mattered, and I found out from Bing Webmaster Tools, not from a test. Cloudflare Worker redirect not working on some paths but fine on others? Check whether those paths exist as static assets. Workers Static Assets answers first, and your script never sees the request.

The fix I ended up with is two Single Redirects at the zone, which run before the Worker. There's also a config switch, run_worker_first, with a cost on the Free plan that's easy to miss.

Why does my Worker redirect skip some paths?

Because a matching static asset wins. When a Worker has both assets and a script, Cloudflare first tries to serve an asset for the request. The script only runs when nothing in the assets directory matches. So a host-level redirect in fetch() works for paths that don't exist as files and silently does nothing for the ones that do.

That's the documented order on the Worker script routing page: "Cloudflare will first attempt to serve static assets if one matches the incoming request." Nothing in it is a bug. It's the reason static asset requests are free.

How Bing ended up indexing www copies of my notes

My studio home page is an assets-only Worker with one job in the script: send www somewhere else. The config looked like this, and the comment in it was my own belief at the time:

// static assets are free; only the www 301 goes through the script
"assets": { "binding": "ASSETS", "directory": "./public", "not_found_handling": "404-page" },
"routes": [
  { "pattern": "example.com", "custom_domain": true },
  { "pattern": "www.example.com", "custom_domain": true }
]
export default {
  fetch(request: Request, env: Env) {
    const url = new URL(request.url);
    if (url.hostname.startsWith("www.")) {
      return Response.redirect(`${env.WWW_REDIRECT_TARGET}${url.pathname}${url.search}`, 301);
    }
    return env.ASSETS.fetch(request);
  },
};

The unit tests for that function passed. They call fetch() directly, so they can't see the asset layer.

In production, www/, www/notes/<slug>/, www/about/ and www/sitemap.xml all existed in public/, so all of them came back 200 with the apex content. On Sep 29 Bing had two of those copies in its index, a note and the about page. Only paths with no file behind them, like old article URLs, got the 301.

Fix 1: run_worker_first, and what it costs on the Free plan

The [run_worker_first](https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first) setting makes the script run before asset matching. Set it to true and every request goes through fetch(), or pass an array of path patterns:

"assets": {
  "binding": "ASSETS",
  "directory": "./public",
  "run_worker_first": true
}

Read the billing and limitations page before you do this on Free. Requests that match run_worker_first always invoke the script and count as Worker requests. Past the free daily limit they get a 429 instead of falling back to the asset.

For my case that trade was bad. Almost every request to the apex is an asset hit that costs nothing, and turning this on would have counted all of them against the Worker quota just to catch www. The patterns are paths, too, so I couldn't scope it to one hostname.

Fix 2: Single Redirects in front of the Worker

Single Redirects run at the zone before the request reaches the Worker, and they don't touch Workers quota. The Free plan allows 10 rules per zone. I used two, in the http_request_dynamic_redirect phase:

  1. www paths that belong to the apex site (/notes, /about, the locale folders) go to the apex, same path.
  2. Every other www path goes to the old site's new host, same path.

Rule 1, trimmed to two prefixes:

{
  "expression": "(http.host eq \"www.example.com\") and (starts_with(http.request.uri.path, \"/notes\") or starts_with(http.request.uri.path, \"/about\"))",
  "action": "redirect",
  "action_parameters": {
    "from_value": {
      "status_code": 301,
      "preserve_query_string": true,
      "target_url": { "expression": "concat(\"https://example.com\", http.request.uri.path)" }
    }
  }
}

Rule 2 is the same shape with only the host check. Order matters: the execution order section says the first matching redirect wins, so the specific rule goes first.

I pushed both with one API call. The token needs the zone permission named Single Redirect: Edit:

curl -sX PUT \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets/phases/http_request_dynamic_redirect/entrypoint" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  --data @www-redirects.json

Then I checked a few paths with a browser user agent, because my zone blocks bare curl:

for p in / /notes/ "/about/?x=1" /favicon.svg; do
  curl -s -o /dev/null -A "Mozilla/5.0" -w "$p %{http_code} %{redirect_url}\n" "https://www.example.com$p"
done

On my zone today, / and /favicon.svg 301 to the old site, and /notes/ and /about/?x=1 301 to the apex with the query kept.

Keep public/ and the redirect rules in sync

Rule 1 has a weak spot. Add a new top-level folder to public/, forget to add its prefix, and www serves a 200 copy of it again.

So the rules live in the repo as an export of the live ruleset, and a test reads both:

const dirs = readdirSync("public", { withFileTypes: true })
  .filter((d) => d.isDirectory())
  .map((d) => d.name);
for (const d of dirs) {
  expect(rules[0].expression).toContain(`starts_with(http.request.uri.path, "/${d}")`);
}

It can't prove the zone matches the file. It does stop the most likely mistake, which is a new folder nobody thought about. The same idea, reading live zone settings and comparing them to what you expect, runs through my Cloudflare zone security checklist.

The legacy host I left alone

I hit the same thing two weeks earlier when I moved a product to a new subdomain. The old host 301s through middleware in the app, and /og.png and /favicon.svg on the old host still return 200 today, because they're assets.

I didn't fix that one. The old host no longer returns any HTML, so there's no duplicate page to index, just a few image files. If it ever matters, it's one more Single Redirect.

Redirect rules live in the zone, not in the repo, and that kind of setting is what the daily drift check I'm sketching would diff. It's a one-question form today, not a product: guard.guushu.com/zone-audit.