API Debugging intermediate 12 min

Debug a Cloudflare Workers Assets Redirect or 404 Before Deploy

Trace an unexpected 307 trailing-slash redirect and the wrong 404 page on Cloudflare Workers Static Assets, then fix html_handling, not_found_handling, and _redirects before you deploy.

Scenario

A small Astro blog moves from a traditional host to Cloudflare Workers Static Assets. astro.config.mjs sets trailingSlash: "never", so every canonical tag and sitemap entry is slashless: https://example.com/about, https://example.com/blog/first-post. The build uses Astro's default directory format, and wrangler.toml points an [assets] block at ./dist with not_found_handling = "404-page".

wrangler deploy succeeds. Then two reports arrive. First, Search Console flags /about as "Page with redirect": every request answers 307 Temporary Redirect with Location: /about/, so the canonical URL points at a redirect. Second, a mistyped link to /blog/nope shows the plain site-wide 404 page instead of the blog's own 404, which has a search box and a list of recent posts. Neither problem shows up in astro preview, because the Astro dev server is not Cloudflare's asset router.

The asset router answers every request in three layers, always in this order:

  1. _redirects is checked first. A matching rule wins even when a real file exists at that path.
  2. html_handling maps the path to an HTML file and decides whether the URL should gain or lose a trailing slash.
  3. not_found_handling runs only when nothing above matched.

Both reports come from layers 2 and 3. The goal is to see which probe produced each response, using the real file list, before the next deploy.

Broken input

# astro.config.mjs
trailingSlash: "never"            # canonical URLs have no trailing slash
build.format:  "directory"        # the default: about.astro -> about/index.html

# wrangler.toml
[assets]
directory = "./dist"
not_found_handling = "404-page"
# html_handling is not set, so it defaults to "auto-trailing-slash"

# find dist -type f | sed 's#^dist##'
/index.html
/about/index.html
/blog/index.html
/blog/first-post/index.html
/blog/404/index.html              # from src/pages/blog/404.astro
/404.html                         # from src/pages/404.astro
/_astro/app.D4e5f6g7.js
/_redirects
/_headers

# dist/_redirects
/posts/:slug   /blog/:slug   301
/old-about     /about        301
/feed.xml      /rss.xml
/legacy        /blog         999

# Observed in production
GET /about       -> 307  Location: /about/
GET /blog/nope   -> 404  (generic site 404, not the blog one)
GET /old-about/  -> 404

Astro treats only the root src/pages/404.astro as a special 404 file. A nested src/pages/blog/404.astro is an ordinary page, so the directory format writes it to /blog/404/index.html, not /blog/404.html.

Goal

Reproduce all three responses in Cloudflare Workers Assets Routing Simulator, read the trace to find which layer answered and which file probe hit or missed, and pick the wrangler.toml, build, and _redirects changes that produce slashless 200s, the section-level 404 page, and redirects that match real inbound links.

Tool sequence

  1. Run find dist -type f | sed 's#^dist##' after astro build and paste the output into the file list. The sed part strips the dist prefix so every line starts with /, the way the asset router sees paths. Paste dist/_redirects and dist/_headers into their own boxes. Set not_found_handling to 404-page and leave html_handling on its default, auto-trailing-slash.
  2. Request /about. The result is 307 with Location: /about/, and the trace shows the reason: /about ✗, /about.html ✗, then /about/index.html ✓. Under auto-trailing-slash, a page stored as index.html inside a folder is served at the folder URL, the one that ends in /, and every other spelling of that URL is redirected there. That is consistent with how the files are laid out. It conflicts with the slashless canonical URLs, though.
  3. Switch html_handling to drop-trailing-slash and request /about again. It is now 200 from /about/index.html, and /about/ returns 307 to /about. The other two modes are worth one look each: force-trailing-slash sends every HTML page to its slash form, including pages stored as about.html, and none serves files only at their exact path, so /about becomes a 404 because no file is literally named about. Pick the mode that matches the URL style your canonical tags and sitemap already use.
  4. Request /blog/nope. The trace shows how 404-page looks for a 404 page: after the page probes miss, it checks /blog/404.html ✗ and then /404.html ✓. It looks only for a file named exactly 404.html in the requested path's own directory and then in each parent directory up to the root. /blog/404/index.html is never checked. Edit the file list so the blog 404 sits at /blog/404.html, the place the router looks, and the same request serves /blog/404.html with status 404. In the real project, add a post-build step such as mv dist/blog/404/index.html dist/blog/404.html.
  5. Set not_found_handling to single-page-application briefly and request /blog/nope again: 200, served from /index.html. Use this mode for client-routed apps only. For a multi-page site it hides every broken link behind a success code. Set it back to 404-page.
  6. Read the _redirects warnings. /legacy /blog 999 is rejected with Cloudflare's own message: valid codes are 200, 301, 302, 303, 307, and 308. Deploys don't fail on rejected lines. Wrangler only prints a warning, and the rule is missing in production.
  7. Request /old-about/, with a trailing slash, the form the old host linked to. It returns 404, because a rule without * or :name must match the path exactly, and /old-about is not /old-about/. Add a second line for the slash form, or use /old-about/* /about 301 if deeper paths moved too. A * splat matches the rest of the path across segments, available in the target as :splat. A :name placeholder such as :slug matches exactly one segment.
  8. Check the rule counts. /old-about and /feed.xml look static, but they come after /posts/:slug, so Cloudflare counts them as dynamic rules. A rule counts toward the 2,000-rule static limit only if it appears before the first * or :placeholder rule. After that, every rule counts toward the 100-rule dynamic limit. Once a file goes past 100 dynamic rules, Cloudflare drops the rest of the file. Move the plain one-to-one redirects to the top.
  9. Request /_astro/app.D4e5f6g7.js to check that _headers applies a long-lived Cache-Control header to hashed build assets, and request /old-about?ref=x to see that the query string carries over to the Location header.

Checkpoint output

# wrangler.toml after the fix
[assets]
directory = "./dist"
html_handling = "drop-trailing-slash"
not_found_handling = "404-page"

# dist/_redirects after the fix (static rules first)
/old-about     /about        301
/old-about/    /about        301
/feed.xml      /rss.xml      301
/legacy        /blog         301
/posts/:slug   /blog/:slug   301

GET /about            200  served /about/index.html
GET /about/           307  Location: /about
GET /blog/nope        404  served /blog/404.html   probes: /blog/404.html ✓
GET /old-about/       301  Location: /about        _redirects line 2 (static)
GET /old-about?ref=x  301  Location: /about?ref=x  _redirects line 1 (static)
GET /posts/hello      301  Location: /blog/hello   _redirects line 5 (dynamic)

Rules: 4 / 2000 static, 1 / 100 dynamic, 0 invalid

Every canonical URL now returns 200 directly. Unknown blog URLs get the blog's 404 page with a real 404 status, and every _redirects line parses.

Common mistakes

  • Mismatched trailing slashes and canonical URLs. If Astro, Next.js, or Hugo emits slashless canonical tags while html_handling is left on auto-trailing-slash with a directory build, every canonical URL answers with a 307. Search engines then see a redirect where they expect a page. Set html_handling to the URL style you publish, not the other way around.
  • 404.html at the wrong depth or under the wrong name. 404-page checks only files named exactly 404.html, starting in the requested path's directory and moving up one directory at a time. A file at /blog/404/index.html, /blog/not-found.html, or /404/index.html is never picked. If no 404.html is found at any level, the visitor gets an empty 404.
  • SPA mode on a site that isn't an SPA. single-page-application returns /index.html with status 200 for every unknown path. Broken links stop showing up in crawlers, uptime monitors, and analytics, and search engines may report soft 404s.
  • Assuming the file wins over a redirect. _redirects is evaluated before any file lookup, so a stale rule for a path you later published as a real page keeps redirecting.
  • Trusting that every _redirects line made it. Wrong token counts, unsupported status codes, duplicate source paths, and the loop-causing /* /index.html rule are skipped with a warning while the deploy succeeds. For SPA fallback, use not_found_handling = "single-page-application" instead of /* /index.html.
  • Mixing static and dynamic rules at random. Each * or :placeholder line moves every later line into the 100-rule dynamic quota. Past 100, the rest of the file is ignored. Keep exact-path redirects at the top.

Related tools and lessons