Cloudflare Workers SEO

How to make technical SEO changes on Cloudflare Workers — the fetch handler, HTMLRewriter for canonical/hreflang/JSON-LD injection, KV-backed redirects, the Cache API vs edge cache vs Cache-Control, the cloaking boundary, and how Bot Fight Mode can block Googlebot.

First published: Jul 3, 2026 · Last updated: Jul 18, 2026 · Advanced
demand #4 in Edge SEO#310 in Technical SEO#426 on the site
1 evidence signal on this page

Cloudflare Workers SEO is doing technical SEO on Cloudflare's serverless runtime: a Worker only sees requests its route matches, and every one of those enters one fetch handler where you rewrite the request, the response headers, and the response body. Body rewriting runs through HTMLRewriter — the actual mechanism for injecting a canonical tag, fixing hreflang, or adding JSON-LD without a CMS deploy — and it needs to be idempotent against missing, duplicate, and non-HTML cases. Redirects at scale belong in KV or D1; Bulk Redirects/Rules are simpler for small sets, but pick one owner per URL so systems don't conflict. Three separate things share the word cache — the Workers Cache API, the Cloudflare edge cache, and origin Cache-Control — and conflating them is why an injected tag seems to not show up; diagnose by cache key, layer, TTL, and invalidation. The hard rule is cloaking: apply identical logic for Googlebot and users. The most Workers-specific self-inflicted wound is Bot Fight Mode blocking Googlebot on a pipeline your WAF allow-rules don't even reach. Ship every SEO-affecting change with recorded version metadata and a tested rollback, then verify with GSC URL Inspection and the CF-Cache-Status header. See the Edge SEO hub for the general concept.

TL;DR — A Cloudflare Worker only sees requests matched by its configured route, and it intercepts each one in a single fetch handler, where you do three things in sequence: rewrite the request, rewrite the response headers, and rewrite the response body via HTMLRewriter. That’s the real mechanism behind “inject a canonical” or “fix a title” — and it needs to be idempotent, tested against missing, duplicate, and non-HTML responses, not just the happy path. Redirects at scale live in KV (fast key lookup) or D1 (relational); Bulk Redirects/Rules cover small sets more simply, and I generally prefer edge-level redirects over server-level — but pick one owner per URL, since a Worker redirect, a Bulk Redirect, and an origin redirect can all fire on the same path. Three different things share the word “cache” — the Workers Cache API (caches.default), the Cloudflare edge cache, and origin Cache-Control — and confusing them is the usual cause of “my tag didn’t show up”; diagnose staleness by cache key, layer, TTL, and invalidation rather than guessing. Google’s own ETag / If-None-Match / 304 guidance is directly actionable for a Worker that owns the response. The cloaking line: identical logic for every requester. The most Workers-specific self-inflicted wound is Bot Fight Mode, which runs outside the WAF Ruleset Engine, so ordinary “allow” rules don’t reach it. Ship every SEO-affecting change with recorded version metadata, a tested rollback, and a stop condition — then verify with GSC URL Inspection and CF-Cache-Status.

What this article is (and isn’t)

This is the practitioner, code-level companion to the Edge SEO hub. The hub owns the general definition, the platform comparison table (Workers, Akamai, Fastly, Lambda@Edge, Vercel, Netlify), the Snippets-vs-Workers decision, and the full treatment of the cloaking rule. I’m not re-deriving any of that here. This page goes one level deeper into Cloudflare Workers specifically — the runtime this very site’s own worker runs on, wired in through run_worker_first in wrangler.toml — with real APIs rather than “edge compute can inject tags” hand-waving.

A note before the code: Google has no Cloudflare-Workers-specific documentation. The official guidance that governs this (cloaking policy, HTTP caching, CDN crawling) is general and applies to any edge implementation. I’d rather say that plainly than imply a Google doc exists that doesn’t.

How a Worker sits in the request/response path

A Worker is a serverless script running on V8 isolates. Every request it’s routed to enters through a fetch handler. Evidence for this claim A Cloudflare Worker receives HTTP requests through a fetch handler. Scope: Cloudflare Workers handlers. Confidence: high · Verified: Cloudflare Workers: Fetch handler “Routed to” is doing real work in that sentence: a Worker only sees requests that match its configured route or custom domain — everything else never reaches the fetch handler at all. Where two routes could both match the same URL, the more specific pattern takes precedence, so before you trust a Worker’s behavior for a given URL, confirm the route actually matches it and check which deployed version is live on that route (Wrangler environments and gradual rollouts mean the version serving traffic isn’t always the one in your editor). Inside the handler you can do three distinct things, in order:

  1. Rewrite the request before it goes to your origin.
  2. Rewrite the response headers on the way back out.
  3. Rewrite the response body — via HTMLRewriter.

Here’s the minimal shape:

export default {
  async fetch(request, env, ctx) {
    // 1. (optionally) inspect/modify the request
    const response = await fetch(request);   // hit the origin

    // 2. rewrite headers
    const headers = new Headers(response.headers);
    headers.set("X-Robots-Tag", "index, follow");

    // 3. rewrite the body with HTMLRewriter (see next section)
    return new Response(response.body, { ...response, headers });
  },
};

The SALT.agency team, who coined “edge SEO” off Cloudflare Workers research, built their tooling as a filter chain — a request filter, a response filter, and a body filter. That’s the same three-phase pattern; they just named it. Keeping those three phases separate in your head keeps a Worker legible.

Rewriting HTML with HTMLRewriter

HTMLRewriter is Cloudflare’s streaming HTML parser, and it’s the actual API behind every “inject a tag” trick. Evidence for this claim Cloudflare HTMLRewriter provides selector-based handlers that can transform streamed HTML elements. Scope: Cloudflare Workers HTMLRewriter API. Confidence: high · Verified: Cloudflare Workers: HTMLRewriter You register .on(selector, handler) element handlers, and the handler gets getAttribute / setAttribute, prepend / append, setInnerContent, and replace. Because it streams, you’re not buffering the whole document in memory.

Injecting or fixing a canonical tag

class CanonicalHandler {
  constructor(url) { this.url = url; }
  element(el) { el.setAttribute("href", this.url); }
}

const rewriter = new HTMLRewriter()
  .on('link[rel="canonical"]', new CanonicalHandler("https://example.com/preferred/"));

return rewriter.transform(response);

If the page has no canonical at all, you attach a handler to head and append one instead of editing an existing tag. Either way, remember the lesson from the canonicalization side of this: rel=canonical is a hint, not a command — a Worker lets you set it consistently across a whole platform, but Google still decides.

The CanonicalHandler above assumes a tag already exists and the response is HTML. Neither is guaranteed in production, and getting it wrong is how you end up with two canonical tags on one page instead of one. Before you ship a rewrite like this, make it idempotent and test it against:

  • No existing canonical — your handler needs to detect the missing case and append one to head, not silently no-op on link[rel="canonical"] matching nothing.
  • A duplicate or malformed canonical already present — decide whether you remove the stray tag or leave your rewrite to add a second one (the latter is a real bug, not an edge case — duplicate canonicals are a common self-inflicted issue).
  • A non-HTML response — an API route, an image, or a redirect response passed through the same Worker shouldn’t be run through HTMLRewriter at all; scope the transform to routes and content types you’ve actually checked.
  • Running the transform twice on the same response (a retry, a nested fetch) — confirm it doesn’t re-append a second tag.

Adding or correcting hreflang alternates

Same mechanism, driven off config. You append one link[rel="alternate"] per locale to head. If your alternates are per-locale and relational, that config belongs in D1; if it’s a flat lookup, KV is fine. The point is that HTMLRewriter injects them identically for every requester — you’re not branching on user agent.

Injecting JSON-LD structured data

new HTMLRewriter().on("head", {
  element(head) {
    head.append(
      `<script type="application/ld+json">${JSON.stringify(schema)}</script>`,
      { html: true }
    );
  },
});

CPU limits that bite at scale

One competitor claim I’d push back on is “sub-millisecond, no constraints.” The real ceiling is CPU time: 10 ms on the free plan, 30 ms on paid (wall-clock time waiting on fetch doesn’t count — CPU time does). For typical rewrites you’ll never notice. For heavy HTMLRewriter passes over very large pages, it’s a real constraint to design around, not scaremongering.

Redirects at the edge: KV vs D1 vs Rules

I typically prefer to have redirects on the edge (CDN level) over having them on the server — it offloads the work from your origin and applies before the page is ever generated. On Cloudflare specifically, in my Ahrefs guide to redirects for SEO I laid out that you’ve got several options: single or bulk redirects, redirect rules, page rules, or Workers with key-value pairs — or a Worker that modifies headers to add a redirect.

For a Worker-driven table, KV is the natural home: a fast, eventually-consistent key lookup keyed by URL.

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const target = await env.REDIRECTS.get(url.pathname);   // KV namespace
    if (target) return Response.redirect(target, 301);
    return fetch(request);
  },
};

Reach for D1 when redirects are relational (per-locale, per-segment SQL you want to query). And know when a Worker is overkill: for a small, static set of redirects, Cloudflare’s Bulk Redirects or Redirect Rules are simpler and need no code at all. Don’t hand-roll a KV Worker for fifty redirects.

Pick one owner for a given URL and don’t let a Worker redirect, a Bulk Redirect, a Redirect Rule, and an origin redirect all apply to the same path — they’re separate systems that can each fire on the same request, and when more than one matches, you’re debugging precedence instead of a clean redirect. Before you add a redirect anywhere, check whether one already exists for that path in the other systems, and pick the layer based on match complexity (simple 1:1 vs. pattern-based), scale, and who needs to observe or roll it back — a Worker redirect lives in your code and logs; a Bulk Redirect or Rule lives in the dashboard and is easier for a non-developer to audit or revert.

Caching: three different things, one confusing name

This is the section competitor pages skip, and it’s the one that generates the most “why didn’t my change show up” confusion. Three separate layers share the word cache:

  • The Workers Cache APIcaches.default and caches.open(). This is a Worker-scoped programmable cache you read and write in code.
  • The Cloudflare edge cache — the CDN cache that serves your assets. Distinct from the Cache API.
  • Origin Cache-Control — the headers your origin (or your Worker) sets, which influence both of the above and what Googlebot does.

Conflate them and you’ll swear a change didn’t deploy when it’s just being served from a layer you didn’t purge.

When a change genuinely isn’t showing up, don’t guess — diagnose it layer by layer:

  1. Cache key. What request attributes determine whether two requests hit the same cached entry (URL, and sometimes headers or cookies if your cache key includes them)? A rewrite that varies by something not in the cache key can serve the wrong variant.
  2. Which layer served the response. Check CF-Cache-Status (HIT/MISS/EXPIRED/DYNAMIC) to see whether the edge cache answered at all, or the request reached your Worker.
  3. Location/state. Cloudflare’s cache is distributed across data centers — a purge or a fresh deploy doesn’t necessarily invalidate every edge location instantly.
  4. TTL and the rule that set it. Confirm whether a cache rule, a Cache-Control header from your origin, or a header your Worker itself set is controlling the TTL.
  5. Invalidation. Did you purge the specific URL, purge everything, or rely on TTL expiry? A Worker-owned Cache API entry (caches.default) needs its own explicit delete() — purging the CDN cache doesn’t touch it.

What Googlebot does with ETag / If-None-Match / 304

If your Worker generates or rewrites the response, it owns the caching headers — which means Google’s December 2024 HTTP caching guidance is directly actionable for you. Google supports heuristic HTTP caching through ETag/If-None-Match and Last-Modified/If-Modified-Since, strongly recommends ETag because it’s less error-prone, and says that when the crawler’s ETag matches, your server should return a 304 Not Modified with no body. A response-generating Worker can implement exactly that: compute an ETag, compare it against If-None-Match, and short-circuit to a 304 itself — saving compute and giving Googlebot a fast, cacheable signal.

The max-age recrawl tradeoff

Google also says to consider setting Cache-Control: max-age to help crawlers decide when to recrawl. The catch for a Worker that rewrites HTML: an aggressive max-age on a page whose Worker-injected tags just changed can delay Googlebot from seeing the update you just shipped. Don’t slap a long cache lifetime on rewritten HTML and forget about it.

The cloaking boundary, applied to Workers

The hard rule, in Workers terms: run the same logic for every requester. Google’s spam policy defines cloaking as presenting different content to users and search engines to manipulate rankings, and specifically calls out inserting text or keywords only when the requester is a search engine.

A couple of clarifications, because people over-correct here:

  • Inspecting the User-Agent isn’t automatically cloaking. Logging bot traffic, or serving a cached response faster to any client, is fine. The line is a content difference by requester identity, done to manipulate rankings.
  • Page-split A/B testing on Workers is fine. Splitting users by URL and treating every requester the same is legitimate. Splitting by who’s asking — bot vs. human — is not.

A worked example of the safe pattern: this site’s own preview gate is a Worker that 404s any /preview/ path unless a cookie matches a secret. It returns that 404 to everyone without the cookie — Googlebot included. That’s precisely the safe shape: it isn’t hiding one thing from bots and showing another to users; it applies one rule uniformly.

And don’t lean on a bot-only pre-render step even if you build it cleanly on Workers. Google has called dynamic rendering a workaround and not a long-term solution; a Worker that pre-renders only for bots inherits that deprecation.

How a Worker can accidentally block or slow Googlebot

This is the most Workers-specific way to shoot yourself in the foot, and it’s usually not in your Worker code.

Bot Fight Mode runs outside the Ruleset Engine

Bot Fight Mode (and Super Bot Fight Mode) can produce false positives against legitimate crawlers, Googlebot included. The trap: Bot Fight Mode is evaluated on a separate pipeline from the WAF Ruleset Engine, so your ordinary WAF “allow” or “skip” custom rules don’t override it. If Bot Fight Mode is challenging Googlebot, you don’t fix it with an allow-rule — you have to change or disable the mode itself. (Confirm the current mechanics against Cloudflare’s Bot Fight Mode and Super Bot Fight Mode docs before you rely on it — bot products change.)

The verified-bots custom rule pattern

Cloudflare exposes a cf.client.bot field and a verified-bots allow pattern so you can permit known-good crawlers in your custom rules — useful for the WAF side, though (per above) it does not reach Bot Fight Mode.

The CDN itself is neutral-to-positive

To be clear about the myth: Cloudflare-the-CDN doesn’t hurt SEO. Google’s own 2024 Crawling December work notes that Google increases crawl rate when it detects a CDN — but that a CDN can also accidentally block Googlebot via WAF/bot rules, and that a 503 is better than a bot-verification interstitial. The risk is a misconfigured Worker or bot setting, not the infrastructure.

Verifying what Googlebot actually received

After any Worker deploy, confirm what a crawler actually got — don’t assume:

  • GSC URL Inspection → Test Live URL. Fetches the page as Google and shows the rendered HTML, so you can confirm your injected canonical/hreflang/JSON-LD is actually present.
  • Check CF-Cache-Status alongside the HTML. HIT / MISS / EXPIRED tells you whether you’re looking at a fresh Worker response or a cached one — the fastest way to catch a “change didn’t show up” that’s really a cache-layer issue.
  • Fetch as Googlebot directly. Request with Googlebot’s user agent and compare — but remember matching the string proves nothing about identity; verify real Googlebot with reverse + forward DNS against Google’s published ranges (see the Scripts tab).

Deployment hygiene specific to Workers

A successful wrangler deploy tells you the script shipped — it doesn’t tell you Googlebot is getting the right rendered output. Treat every SEO-affecting Worker change as a release with a record, not just a push:

  • Scope your routes. Don’t run a Worker on /* by default. Match it to the paths it needs in your wrangler.toml route patterns so a bug can’t take down your whole site.
  • Check current limits before you promise scale. CPU time, subrequest counts, and script size limits vary by plan and change over time — confirm against Cloudflare’s current limits page before you design a rewrite around a specific ceiling, rather than relying on a remembered number.
  • Record version metadata for the release. Cloudflare’s versions and deployments model tracks the source version, compatibility date, bindings, and routes for each deploy — note which version is live on which route so a “the Worker is doing X” claim is checkable against what’s actually deployed, not what’s in your editor.
  • Version and roll back with Wrangler environments. Ship to a staging environment, gradually roll out by percentage, and keep the ability to revert to the prior version instantly.
  • Use scoped logs to monitor the rollout — with their limits in mind. Cloudflare’s Workers Logs and log tailing can support debugging a gradual rollout, but logs are sampled and retained for a limited window — treat them as scoped evidence for the requests they captured, not a complete record of every crawler visit.
  • Set a stop condition and test the rollback before you need it. Decide up front what observed behavior (error rate, a wrong response on a spot-check, a crawl-rate drop) halts the rollout, and confirm the rollback path actually works rather than assuming it will.
  • Purge cache as part of the deploy. Since three cache layers are in play, make cache purge/invalidation an explicit step of shipping a rewrite, not an afterthought.

A Bing note, and one forward-looking thing

Bing has no Cloudflare/edge-specific guidance either. But because a Worker deploy is instant while crawls aren’t, IndexNow is the natural pairing — fire it the moment a Worker-driven redirect table or tag change ships so Bing (and other participating engines) re-crawl promptly. And worth a glance: Cloudflare shipped edge-enforced canonicalization as a product feature (“Redirects for AI Training”) — verified AI-training crawlers get a 301 to your canonical URL with one toggle. It’s a useful contrast to hand-rolling canonical logic in your own Worker, and a reminder that “serving crawlers something different from users” is a pattern Microsoft has publicly been skeptical of on Cloudflare’s other AI-crawler features — a good gut check on any bot-conditional Worker.

For the broader picture — the platform comparison, Snippets vs. Workers, the dev-queue and governance angles — head back to the Edge SEO hub.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin an expert quote first.