Using Surrogate Keys on Fastly Compute

This guide is part of Tag and Surrogate-Key Cache Invalidation at the Edge. It walks through one concrete task: tagging Fastly-cached responses with the Surrogate-Key header and purging them — hard or soft — when the underlying content changes.

The problem

You serve a catalog from Fastly Compute. An article is updated, and it appears on its own page, in two category feeds, and on the homepage. You need every cached copy that depends on that article gone — or revalidated — but you cannot list the affected URLs, and a full-service purge would dump the entire cache onto your origin.

Root cause: surrogate keys are Fastly’s first-class invalidation primitive

Fastly was built around surrogate keys. At write time you attach a Surrogate-Key header — a space-separated list of labels — to each cacheable response. Fastly indexes the response under each label. At mutation time you call the purge API for a key, and Fastly evicts (hard) or revalidates (soft) every response carrying it, across the entire network, typically within about 150 ms. Unlike some platforms, this is native and not plan-gated. On Fastly Compute the response is produced by your edge code, which must be edge-safe TypeScript — Web APIs only, no Node built-ins — and the purge call runs off the request hot path. The space-separated format is the one syntactic gotcha: Cloudflare and Netlify use commas for Cache-Tag, but Fastly’s Surrogate-Key uses spaces.

Fastly Surrogate-Key purge Responses carry space-separated Surrogate-Key labels; a purge by key hard-evicts or soft-revalidates every response carrying it. /article/9 Surrogate-Key: article-9 cat-3 /home Surrogate-Key: article-9 purge key article-9 (soft?) hard: evict now soft: mark stale
One key purge reaches every response tagged with it; soft purge marks stale instead of evicting.

Step 1: Attach Surrogate-Key on Fastly Compute

In your Compute handler, set the space-separated Surrogate-Key header on cacheable responses. Fastly strips it before delivery and indexes each key.

// Edge-safe: Web APIs only, no Node built-ins.
const keys = {
  article: (id: string) => `article-${id}`,
  category: (id: string) => `cat-${id}`,
} as const;

function tagResponse(response: Response, keyList: string[]): Response {
  const res = new Response(response.body, response);
  // Surrogate-Key is SPACE-separated on Fastly (not comma).
  res.headers.set("Surrogate-Key", keyList.join(" "));
  // Surrogate-Control governs edge TTL independently of the client Cache-Control.
  res.headers.set("Surrogate-Control", "max-age=300");
  return res;
}

async function handleArticle(id: string, categoryId: string): Promise<Response> {
  const origin = await fetch(`https://origin.example.com/article/${id}`);
  return tagResponse(origin, [keys.article(id), keys.category(categoryId)]);
}

Step 2: Build a purge client with hard/soft support

Purge a key via POST /service/{service_id}/purge/{surrogate_key}. Add the Fastly-Soft-Purge: 1 header to mark entries stale instead of evicting them, which pairs with stale-while-revalidate to avoid an origin stampede. Wrap in bounded retry; purges are idempotent.

interface PurgeEnv {
  FASTLY_SERVICE_ID: string;
  FASTLY_API_TOKEN: string; // secret with purge permission
}

async function purgeKey(
  env: PurgeEnv,
  key: string,
  opts: { soft?: boolean } = {},
): Promise<boolean> {
  const url = `https://api.fastly.com/service/${env.FASTLY_SERVICE_ID}/purge/${encodeURIComponent(key)}`;

  for (let attempt = 1; attempt <= 4; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        "Fastly-Key": env.FASTLY_API_TOKEN,
        "Accept": "application/json",
        ...(opts.soft ? { "Fastly-Soft-Purge": "1" } : {}),
      },
    });

    if (res.ok) return true;
    if (res.status >= 400 && res.status < 500 && res.status !== 429) return false;
    const backoff = Math.min(2 ** attempt * 100, 2000);
    await new Promise((r) => setTimeout(r, backoff + Math.random() * 100));
  }
  return false;
}

Step 3: Trigger the purge on mutation, off the hot path

Only mutations purge, and the purge must not block the write. The Compute runtime exposes a background-task mechanism analogous to waitUntil; forward the mutation, then schedule the purge.

async function onMutation(
  request: Request,
  env: PurgeEnv,
  background: (p: Promise<unknown>) => void,
): Promise<Response> {
  const url = new URL(request.url);
  const match = url.pathname.match(/^\/api\/articles\/([^/]+)$/);
  if (!match || !["PUT", "PATCH", "DELETE"].includes(request.method)) {
    return fetch(request);
  }

  const id = match[1];
  const response = await fetch(request); // forward to origin
  // Soft purge so readers keep getting a fast (stale) page while it revalidates.
  background(purgeKey(env, keys.article(id), { soft: true }));
  return response;
}

Step 4: Configuration and secrets

Define the service and token through your Fastly Compute configuration. The token must carry the purge scope and nothing broader. Store it as a secret in your deployment config (a Fastly dictionary/secret store or your CI secret manager), never inline in code:

# fastly.toml (excerpt)
name = "catalog-compute"
language = "javascript"
service_id = "<your-service-id>"

[setup.secret_stores.config]
  [setup.secret_stores.config.entries.FASTLY_API_TOKEN]
    description = "Token with purge permission"

Step 5: Verify eviction

After a hard purge, the next request to an affected URL should be a MISS. Fastly exposes cache state via the X-Cache / X-Served-By response headers; confirm X-Cache flips from HIT to MISS. After a soft purge the next request is still served (stale) while a background revalidation runs, so verify by checking the content refreshed on the second request rather than the first.

Local vs production divergence

Behavior Local (Compute dev server) Production
Surrogate-Key indexing No real edge cache; keys not indexed Indexed network-wide
Purge API call Hits the real API if a token is set; affects production Same endpoint, real effect
Soft purge behavior Not observable locally Serves stale once, revalidates in background
Propagation time n/a ~150 ms network-wide

Validate key attachment and purge request shape with unit tests; validate eviction against a real service by watching X-Cache.

Vitest validation

import { describe, expect, it, vi } from "vitest";
import { purgeKey } from "./purge";

const env = { FASTLY_SERVICE_ID: "svc1", FASTLY_API_TOKEN: "tok" };

describe("purgeKey", () => {
  it("posts to the per-key purge endpoint with the token", async () => {
    const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
    vi.stubGlobal("fetch", fetchMock);

    await purgeKey(env, "article-9");

    const [url, init] = fetchMock.mock.calls[0];
    expect(url).toContain("/service/svc1/purge/article-9");
    expect(init.headers["Fastly-Key"]).toBe("tok");
    expect(init.headers["Fastly-Soft-Purge"]).toBeUndefined();
  });

  it("adds Fastly-Soft-Purge for a soft purge", async () => {
    const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
    vi.stubGlobal("fetch", fetchMock);

    await purgeKey(env, "article-9", { soft: true });

    expect(fetchMock.mock.calls[0][1].headers["Fastly-Soft-Purge"]).toBe("1");
  });

  it("retries on 503 then succeeds", async () => {
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(new Response("", { status: 503 }))
      .mockResolvedValueOnce(new Response("{}", { status: 200 }));
    vi.stubGlobal("fetch", fetchMock);

    await expect(purgeKey(env, "article-9")).resolves.toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});

Pitfalls

  • Comma-separating Surrogate-Key. Fastly splits on spaces, so article-9,cat-3 becomes one literal key. Use spaces; reserve commas for Cloudflare/Netlify Cache-Tag.
  • Confusing Surrogate-Control with Cache-Control. Surrogate-Control sets the edge TTL and is stripped before the client sees it; Cache-Control reaches the browser. Mixing them gives the browser the wrong TTL.
  • Hard-purging a hot key. A hard purge of a high-traffic key floods the origin. Prefer a soft purge plus stale-while-revalidate for hot content.
  • Blocking the write on the purge. Await the purge and you add the API round-trip to user latency. Schedule it as a background task.
  • Key string drift. Writer emits article-9, purger sends article_9: the purge no-ops. Build both from one shared helper.

Production deployment checklist

  • Surrogate-Key
  • Edge TTL is set via Surrogate-Control, distinct from client
  • Purge retries cover 429/5xx and stop on

Frequently Asked Questions

Is the Surrogate-Key header comma-separated or space-separated?

On Fastly it is space-separated. Comma-separating the keys makes Fastly treat the whole string as one literal key, so purges miss. Reserve commas for the Cache-Tag header used by Cloudflare and Netlify.

What is the difference between a hard and a soft purge?

A hard purge evicts entries immediately, so the next request is a miss and refetches from origin. A soft purge marks entries stale; the next request is still served the stale copy while a background revalidation refreshes it. Soft purge avoids an origin stampede and pairs with stale-while-revalidate.

How is Surrogate-Control different from Cache-Control?

Surrogate-Control sets the cache TTL at the Fastly edge and is stripped before the response reaches the client. Cache-Control reaches the browser and governs the client cache. Set them independently so the edge and the browser get the TTLs you intend.

How do I keep the purge from slowing down writes?

Run the purge as a background task after forwarding the mutation, rather than awaiting it inline. The user’s write returns immediately while the purge propagates, which on Fastly is typically around 150 milliseconds network-wide.