Negotiating the Theme at the Edge with Middleware

Part of SSR Hydration & Fallback Chains. Resolving the theme at the edge gives a themed first paint without asking the origin to render two versions of every page — and it moves the caching problem somewhere it can be managed.

Where the theme decision can be made along the request path A request path from browser through CDN edge to origin, showing the theme being resolved at the edge with a streaming HTML transform, leaving the origin response theme-neutral and fully cacheable. browser Cookie: theme=dark edge middleware reads cookie, rewrites <html> origin one neutral response The origin cache stays single-keyed Only the edge response varies by theme, and the transform is a few bytes of work per request.
Moving the decision to the edge keeps the origin's cache entry singular while still delivering themed HTML on the first byte.

Prerequisites Link to this section

  • A CDN or platform with programmable edge middleware and an HTML rewriting API — Cloudflare Workers, Fastly Compute, Vercel Edge Middleware or equivalent.
  • A theme cookie written by the client when a visitor makes an explicit choice.
  • Origin HTML that renders with a neutral or absent theme attribute, so the transform has something predictable to modify.
  • The client-side resolver still in place, because the cookie is absent on a genuine first visit.

Step-by-Step Implementation Link to this section

Intent: turn an untrusted header into one of exactly three known values.

// middleware.js
const VALID = new Set(['light', 'dark']);

function themeFromRequest(request) {
  const header = request.headers.get('cookie') ?? '';
  const match = /(?:^|;\s*)theme=([^;]+)/.exec(header);
  const value = match ? decodeURIComponent(match[1]).trim() : '';
  return VALID.has(value) ? value : null;      // null means "let the client decide"
}

Why this works: the allow-list is the security boundary. A cookie value is attacker-controlled, and this value is about to be interpolated into an HTML attribute — the one place where an unvalidated string becomes markup. Returning null rather than a default keeps the “no opinion” case distinguishable from an explicit light choice, which matters for the next step.

Step 2 — Rewrite the root element in a streaming transform Link to this section

Intent: set the attribute without buffering the whole document.

export default {
  async fetch(request, env) {
    const theme = themeFromRequest(request);
    const response = await fetch(request);

    if (!theme || !response.headers.get('content-type')?.includes('text/html')) {
      return response;
    }

    return new HTMLRewriter()
      .on('html', {
        element(el) {
          el.setAttribute('data-theme', theme);
          el.setAttribute('style', `color-scheme: ${theme}`);
        },
      })
      .transform(response);
  },
};

Why this works: a streaming rewriter operates on the response as it passes through, so time to first byte is essentially unchanged and memory use does not scale with page size. Setting color-scheme alongside the attribute matters more than it looks: it tells the browser to render form controls, scrollbars and the canvas background in the matching mode from the very first paint, which is the part visitors notice when it is missing.

Step 3 — Set Vary correctly, and keep it narrow Link to this section

Intent: tell shared caches that this response depends on the cookie, without fragmenting the cache by every cookie in the request.

const out = new Response(transformed.body, transformed);
out.headers.set('Vary', 'Cookie');

Better, where the platform supports a custom cache key, is to avoid Vary: Cookie entirely:

// Cache key includes only the resolved theme, not the whole cookie header.
const cacheKey = new Request(
  `${new URL(request.url).toString()}#theme=${theme ?? 'none'}`, request);

Why this works: Vary: Cookie is correct and blunt — it keys the cache on the entire cookie header, so a session identifier or an analytics cookie fragments the cache per visitor and the hit rate collapses. A custom cache key that includes only the resolved theme produces exactly three variants per URL, which is the intended split. Where a custom key is unavailable, normalising the cookie header to just the theme cookie before the cache lookup achieves the same result.

Cache fragmentation under three keying strategies Three approaches compared: Vary on the whole cookie header producing one entry per visitor, Vary on a normalised cookie producing three entries, and a custom cache key producing three entries with no header dependency. Vary: Cookie keys on every cookie session ids included entries per URL: one per visitor effectively uncached Normalised cookie strip all but theme before the lookup entries per URL: three light, dark, none needs edge support Custom cache key theme in the key itself no Vary header at all entries per URL: three explicit and visible the right answer where available
The difference between the first column and the other two is the difference between a working CDN and one that forwards every request to the origin.

Step 4 — Keep the client resolver as the fallback Link to this section

Intent: the cookie is absent on a first visit, and the edge has no way to know the visitor’s OS preference.

<script>
  (function () {
    var root = document.documentElement;
    if (root.getAttribute('data-theme')) return;   // the edge already decided
    try {
      var stored = localStorage.getItem('theme');
      root.setAttribute('data-theme',
        stored === 'light' || stored === 'dark' ? stored :
        (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
    } catch (e) { root.setAttribute('data-theme', 'light'); }
  })();
</script>

Why this works: the early return is what makes the two mechanisms compose rather than fight. When the edge has set the attribute, the script does nothing and there is no possibility of a mismatch; when it has not, the script is the only decision-maker and behaves exactly as it would without any edge involvement.

Intent: the edge can only act on a cookie the client remembered to set.

function persistTheme(value) {
  try { localStorage.setItem('theme', value); } catch (_) {}
  document.cookie =
    `theme=${value}; Path=/; Max-Age=31536000; SameSite=Lax` +
    (location.protocol === 'https:' ? '; Secure' : '');
}

Why this works: writing both storage and the cookie keeps the client and the edge in agreement, which is the precondition for avoiding hydration mismatches. SameSite=Lax is correct here — the cookie needs to be sent on top-level navigations, which is exactly what Lax permits, and there is no reason for a theme cookie to travel with cross-site subrequests.

Step 6 — Exclude what should not be transformed Link to this section

Intent: the rewriter should touch HTML documents and nothing else.

const url = new URL(request.url);
const skip = url.pathname.startsWith('/api/')
  || url.pathname.startsWith('/assets/')
  || request.method !== 'GET';
if (skip) return fetch(request);

Why this works: running a transform over API responses or assets costs time for no benefit and, worse, risks setting a Vary header on a response that should be cached identically for everyone. Excluding by path prefix is cruder than checking content type and considerably cheaper, and both together are what you want in production.

Verification Link to this section

Check the raw bytes first, because that is where the claim lies. curl -H "Cookie: theme=dark" against the edge URL should return HTML whose opening tag already carries the attribute; the same request without the cookie should return neutral HTML. Then check the cache behaviour: issue the same themed request twice and confirm the second is served from cache, and issue requests with different unrelated cookies and confirm they still hit the same cache entry.

Finally check the fallback. Disable JavaScript and load without a cookie: the page should render in the documented default rather than unstyled. Then enable JavaScript, still with no cookie, with the OS set to dark: the client resolver should apply dark before first paint, and no hydration warning should appear.

Which layer decides, by request type Three request cases — returning visitor with a cookie, first visit with no cookie, and a request with scripting disabled — mapped to which layer resolves the theme in each. returning visitor, cookie present edge decides — themed HTML on the first byte, no flash, no script needed first visit, no cookie client resolver decides — the OS preference is only visible there no cookie, no scripting documented default applies — state it explicitly rather than letting source order decide
Three cases, three deciders, no overlap. The early return in the client script is what keeps them from contradicting each other.

Troubleshooting Link to this section

Symptom Likely cause Fix
Cache hit rate collapsed after deploying Vary: Cookie keys on the whole header Use a custom cache key, or normalise the cookie before the lookup
The attribute is set but form controls are still light color-scheme was not set alongside it Set both in the same transform
The theme is correct on the first request and wrong on the second A cached response from before the cookie was set is being served Include the resolved theme in the cache key, not only in the response
The edge and the client disagree The client script runs unconditionally instead of returning early Check for an existing attribute before resolving on the client
The transform runs on API responses No content-type or path exclusion Skip non-HTML and non-GET requests before transforming

Migration Note Link to this section

Moving theme resolution from the origin to the edge is a low-risk change because the two can coexist. Deploy the middleware with the transform disabled, confirm it passes traffic through unchanged, then enable the transform for a fraction of traffic and watch the cache hit rate and the hydration warning count together — those two numbers are what the change is trading between.

If the origin currently renders themed HTML from a cookie, keep it doing so during the transition; the edge transform is idempotent, since setting an attribute that already has the right value changes nothing. Once the edge path is confirmed, the origin can drop its cookie handling and return to serving a single neutral response, which is where the caching benefit actually arrives.

What Belongs at the Edge and What Does Not Link to this section

Theme negotiation is a good fit for edge middleware, and it is worth being explicit about why, because the same reasoning excludes a lot of things that get proposed for the same layer.

The fit is good when three conditions hold. The decision depends only on the request — a cookie, a header, a path — and needs no origin data. The transformation is small and streaming, so it adds negligible latency. And the result partitions the cache into a handful of variants rather than one per visitor. Theme negotiation satisfies all three, which is why it is nearly always a net win.

Personalisation generally fails the third condition. Rewriting a page to include a visitor’s name produces one cache entry per visitor, which is the same as not caching. Feature flags fail it too once there are more than a few, because the variant count is the product of the flags rather than their sum.

Anything requiring origin data fails the first. An edge function that fetches from a database to decide what to render has moved the origin’s latency to the edge and added a hop, which is worse than doing the work at the origin.

The useful test before adding logic at the edge: count the distinct responses the URL can now produce. If the answer is a small constant, it belongs there. If it scales with the number of visitors, it does not — regardless of how convenient the middleware makes it.