跳到正文

把缓存放在计算之前:从 Workers Cache 到 Node.js 实现

给动态页面加 CDN 时,一个很自然的期待是:第一次访问生成页面,后续访问直接返回已经生成的 HTML。页面内容几分钟都没变,查数据库、拼数据、渲染模板就不必每次重做。

但应用部署成 Cloudflare Worker 后,过去常见的缓存方式仍然需要先运行 Worker,再由代码查缓存。缓存虽然命中了,函数入口却照常执行。

看到 Workers Cache 的介绍,吸引我的就是这个变化:缓存可以放在 Worker 前面,命中后连生成响应的 Worker 都不用运行。这里值得弄清楚的,是缓存处于请求链路的什么位置,以及我们究竟想省掉哪一段工作。

Worker 是运行在 Cloudflare 服务端的程序。这里的「前面」描述请求处理顺序,与浏览器里的 Web Worker 无关。平台能力按 2026 年 11 月核对的官方文档说明;后面的 Node.js 示例在本地运行,用于观察缓存行为。

缓存的位置,决定省掉哪段工作

以一个商品详情页为例。服务端读取商品数据,再生成 HTML;如果接连几次请求得到的商品介绍相同,生成好的响应就有复用价值。浏览器缓存保存的是某位访客已经收到的副本,共享的服务端响应缓存则可以让后续访客也复用这份结果。

这种复用可以发生在应用内部,也可以发生在应用入口之前。两者都能减少数据库查询,跳过的执行阶段却不一样。

原有链路先运行 Worker 再查源站缓存;Workers Cache 命中后直接响应

原来的 Worker 是缓存前的处理程序

Cloudflare 最初把 Worker 放在请求抵达缓存与源站之前,用来改写 URL、检查访问权限、做 A/B 分流,或者在取得缓存响应后修改内容。这些用途需要代码参与每一次请求,缓存命中也不能直接略过它。原有缓存链路

在这种结构里,Worker 调用 fetch() 时会经过相应的源站缓存。被缓存的是子请求取得的响应;Worker 自己生成并返回的最终响应,不会因此自动获得一层前置缓存。

后来,Worker 开始直接承担服务端应用的工作。它可以查数据库、生成页面,背后不再有另一台 Web 服务器。开发者仍然可以用 caches.default.match() 和 put() 保存结果,但下一次请求必须进入 Worker,才能执行读取缓存的代码。

因此,过去的限制来自 Cloudflare 当时的产品链路。HTTP 并不阻止我们把一个可访问的 Worker 入口当作源站,在外面另外接 CDN;只是那需要独立安排前置缓存,不能靠给最终响应加一个头就改变原有执行顺序。

Workers Cache 在执行入口之前查询

启用 Workers Cache 后,Cloudflare 在运行目标入口前检查响应缓存;未命中时才调用 Worker,并按响应头判断是否保存结果。它还提供多级缓存:靠近访客的节点未命中时,可以继续查询上层缓存,减少重新执行 Worker 的机会。工作原理与分层缓存

在已有的 wrangler.jsonc 中合并配置并重新部署,就可以启用这层能力:

{
  "cache": {
    "enabled": true
  }
}

这个配置需要 Wrangler 4.69.0 或更高版本。开关允许平台查询缓存,具体响应仍然要符合缓存条件。它保存的是 HTTP 响应,不会把程序的任意运行状态自动保存下来。

对于已经生成静态 HTML、通过 CDN 分发的博客,页面本来就不需要逐次渲染,新增收益通常有限。动态生成的商品页、文章页、公开 API,以及需要聚合慢速上游数据的接口,更容易从中受益。

过期之后,用户是否需要等待

判断一份缓存能否直接返回,需要看它已经保存多久。只要业务允许短时间延迟更新,过期的副本也可以暂时使用,把重新生成的等待放到后台。

新鲜缓存直接返回,过期可复用时后台刷新,超过窗口后等待生成

max-age 指定新鲜期;stale-while-revalidate 允许缓存过期后,在限定时间内先返回旧响应,同时重新取得内容。例如,下面这组响应头让浏览器下次访问时重新确认,而 Cloudflare 边缘缓存可以直接复用一分钟:

Cache-Control: public, max-age=0
Cloudflare-CDN-Cache-Control: public, max-age=60, stale-while-revalidate=300

边缘缓存采用更具体的 Cloudflare-CDN-Cache-Control,不会被浏览器侧的 max-age=0 覆盖。新鲜期结束后的五分钟内,请求可以取得旧内容并触发后台刷新;刷新成功后,新响应重新开始计时。响应头优先级与后台刷新

这并不是定时器每隔一分钟主动更新。没有请求时,缓存不会仅因为时间到了就自动生成新页面;如果副本已被淘汰,或者请求已经超过允许使用旧内容的窗口,就需要等待生成。

Cloudflare 当前的缓存语义还要求注意 s-maxage:它包含重新验证约束,会禁用这条缓存路径上的过期内容复用。需要后台刷新时,边缘策略使用 max-age,浏览器和 CDN 的时长通过不同响应头表达,不能把其他平台的头配置原样套过来。

商品介绍、文章目录可以接受短暂延迟;下单时确认库存、执行支付或者读取必须实时更新的状态,则需要实际执行相应操作。缓存时间首先取决于内容能旧多久。

把缓存放进应用内部

完整页面不是唯一的缓存单位。有些应用每次都要验证权限,但验证完成后,查询和渲染的结果可以短时间复用。

身份验证位于缓存前,每次检查权限后按可信用户身份查询独立副本

Workers Cache 可以作用于 Service Binding 和 ctx.exports 发起的内部 HTTP 调用。一个 Worker 可以暴露多个入口:外层入口每次鉴权,内部入口返回可缓存的响应,命中时只跳过内部的昂贵工作。这比只能缓存整个公共页面入口更灵活。

按入口配置需要 Wrangler 4.107.0 或更高版本,并且要明确让网关保持执行。下面是合并进 Wrangler 配置的对应部分,Backend 是业务代码实际导出的入口名:

{
  "exports": {
    "default": { "type": "worker", "cache": { "enabled": false } },
    "Backend": { "type": "worker", "cache": { "enabled": true } }
  }
}

对于用户各不相同的数据,网关把验证得到的用户、租户等标识传入 ctx.props,平台将其纳入缓存键。身份必须由服务端确认,不能相信客户端随意传入的用户编号。每次请求仍需授权,权限撤销也不能靠等待缓存自然过期来生效。按用户缓存的完整示例

缓存键是区分响应副本的标识。除了身份,商品 ID、分页、筛选条件、语言等影响结果的输入,也必须参与区分。Workers Cache 的默认键包含入口、路径、查询字符串、ctx.props 等维度,并默认按 Worker 版本隔离;主机名不参与这套默认键。一个 Worker 绑定多个域名时,如果内容因域名而异,就要显式表达这个差异。缓存键说明

这里的入口缓存仍然针对 HTTP:只有 fetch() 调用经过它,普通 RPC 方法不会自动变成记忆化函数。当前缓存的 HTTP 方法是 GET 和 HEAD,写入请求、WebSocket、定时任务和队列消费者也有各自边界。支持范围

普通 Web App 可以从哪里改起

最容易复用的通常是公开读取。对于 Vue 或 React SPA,构建出的静态资源已经适合 CDN 分发,下一步可以检查公共 API;对于 SSR 应用,还可以检查是否有大量相同的 HTML 被重复生成。

公共内容与个人信息分别返回

商品介绍、图片和分类信息可以作为公共响应;「我是否收藏」「购物车里有几件」通过独立的个人接口取得。这样,一小块个人状态就不会让整个公共页面失去复用机会。拆分也会增加请求与页面组装工作,需要结合首屏体验决定哪些内容单独请求。

公共响应应当真的与用户无关,不能为了命中率直接删除 Cookie 或 Authorization,然后继续把个人结果放进共享缓存。确实要缓存个人数据时,采用前面的鉴权与身份隔离模型。

查询参数也要按作用处理。纯追踪参数可以在确认不影响响应后规范化;商品筛选、语言或分页则继续保留。让缓存只按路径区分,却把所有查询条件忽略掉,会返回错误结果。

写入成功后,让相关副本失效

商品修改成功后,详情页、分类列表等副本都可能过时。可以给响应设置业务标签,再由持有缓存的入口清除对应标签:

await ctx.cache.purge({ tags: ['product:123'] });

清除只影响当前入口拥有的缓存。同一个 Worker 的网关入口调用清除方法,不会顺便清掉另一个内部入口的副本。当前平台也提供 invalidate() 标记过期;它与直接删除不同,需要结合验证器和重新验证行为选择。清除与失效的作用域

应用内部缓存、CDN 缓存和浏览器缓存可能同时存在。只清除其中一层,其他层仍可能返回旧结果。例如,Next.js 自托管应用调用 revalidatePath() 或 revalidateTag(),不会自动清除额外接入的任意 CDN,需要配套处理 CDN 失效;HTML 与 RSC 响应变体也应正确区分。Next.js 的 CDN 接入边界

发布验证可以沿着同一份内容检查:首次请求生成,随后命中;更新数据并清除后,再次得到新版本。只看到响应头出现 HIT,还不能证明没有混用语言、身份或查询条件。

框架能部署,与缓存自动接入是两回事

Workers Cache 处理 HTTP 响应,不要求页面必须由某个前端框架生成。框架适配器决定应用能否运行在 Workers 上,也决定框架的缓存设置、响应头和主动失效怎样接到平台。

  • Astro 已有专用的 cacheCloudflare() provider,会启用 Workers Cache 并映射缓存头和标签。当前文档将 CDN provider 标为实验性,需要手动选择;路由再声明自己的缓存策略。Astro 路由缓存
  • Next.js 应用在当前 Cloudflare 指南中主要通过 vinext 运行,同时保留 OpenNext 路线。应按具体部署路径核对兼容性,不能把 Next.js 的所有缓存能力都归到同一层。Cloudflare 的 Next.js 指南
  • Nuxt、SvelteKit、React Router、TanStack Start、Hono 等有对应 Workers 部署指南。可以缓存符合条件的 HTTP 响应,但「能部署」不等于每个适配器都已经自动接好相同的缓存与失效接口。框架部署目录

这类功能不值得单独成为更换前端框架的理由。已有应用先把响应边界理清,往往就能使用托管平台或反向代理提供的缓存。

其他平台也能缓存动态响应

响应缓存与后台刷新都有成熟的实现。Cloudflare 这次提供的是与 Worker 入口紧密结合的一套能力,不能据此把「缓存命中后省掉函数计算」视为它独有的原理。

  • Vercel 可以在 CDN 缓存 Function 的页面与 API 响应,通过缓存头控制复用和后台刷新,也提供 ISR。对于返回相同内容的函数,缓存可以省去后续执行。Vercel CDN Cache
  • 腾讯云 EdgeOne Pages 的 Next.js 部署支持 ISR。页面过期后,访问可以先取得已有版本,再触发后台生成。它与边缘函数内部调用 Cache API 是不同层面的能力。Next.js 渲染与 ISR
  • 阿里云 ESA/CDN 可以缓存作为源站的 HTTP 应用响应,边缘函数也提供 Cache API。后者仍然需要进入函数才能读缓存;官方 DCDN 示例就用边缘代码查缓存,命中后省掉后方函数计算源站的调用,而边缘代码自身照常执行。

因此,比较产品时需要具体问:缓存命中后省掉的是源站请求、业务计算,还是整个目标函数入口;用户与租户怎么隔离;更新后由谁清除。只有这些维度一致,才适合继续比较时延、运维和费用。

用 Node.js 看见命中、刷新与清除

一台云服务器也能实现同样的基本机制。下面的示例只使用 Node.js 内置模块,缓存 GET /data 返回的一份公共 JSON,用一秒异步等待模拟数据源延迟。每次真正生成时,version 增加,方便区分是否重复执行。

这里缓存位于业务生成函数前,HTTP Server 仍会处理每个请求。它演示的是省掉 generateData() 的工作;要连后方应用入口也跳过,可以再用 Nginx proxy_cache 放到应用前面。Nginx 已提供缓存填充锁和后台更新,不需要从 HTTP 协议重新实现。Nginx 代理缓存

先运行一次

下载服务端源码,保存为 server.mjs。使用 Node.js 运行,不需要安装依赖:

node server.mjs

服务默认只监听本机 127.0.0.1:3000,可以用 node server.mjs 3001 改端口,按 Ctrl+C 停止。另开终端请求接口;macOS、Linux 使用 curl,Windows PowerShell 使用 curl.exe,下面统一写成 curl。

curl -i http://127.0.0.1:3000/data

第一次会等待约一秒,得到 X-Cache: MISS;立即再请求,得到 HIT,正文版本保持不变。示例的新鲜期为五秒,随后允许旧内容再使用三十秒:

距离该副本生成完成的时间 响应行为
小于 5 秒 HIT,直接返回
5 秒至不足 35 秒 STALE,返回旧版本并后台刷新
达到 35 秒,或没有副本 MISS,等待生成

先等六秒再请求,会立即拿到旧版本;再等两秒请求,就会看到后台生成的新版本。若想观察彻底过期,需要在新副本生成后,连续超过三十五秒不再请求这个接口。

主动清除使用另一个路由:

curl -X DELETE http://127.0.0.1:3000/cache
curl -i http://127.0.0.1:3000/data

清除后下一个请求重新生成。示例返回给客户端的 Cache-Control 特意设为 no-store,防止浏览器副本掩盖服务器自己的内存缓存;这不妨碍应用显式复用内存数据。X-Cache: STALE 是示例自己的标记,Cloudflare 后台刷新通常观察 Cf-Cache-Status: UPDATING,两者不应混作同一个协议值。

完整源码、说明和 HTTP 验证脚本可以在Node.js 源码与验证弹窗中查看。

是否等待,取决于有没有可用副本

下面节选真实请求处理函数,省略主动清除与未知路由分支。entry 保存当前响应和生成完成时间,age 决定当前请求采用哪个分支:

async function handleRequest(req, res) {
  // ...省略主动清除与未知路由分支。
  let entry = cached;
  const age = entry ? Date.now() - entry.storedAt : Infinity;
  let cacheStatus;

  if (!entry || age >= FRESH_MS + STALE_MS) {
    cacheStatus = 'MISS';
    entry = await refreshCache(); // 没有可用副本,等待生成。
  } else if (age >= FRESH_MS) {
    cacheStatus = 'STALE';
    // 不等待刷新,立即返回旧内容;刷新失败时保留旧副本并记录错误。
    void refreshCache().catch((error) => console.error('后台刷新失败:', error));
  } else {
    cacheStatus = 'HIT';
  }

  sendJson(res, 200, entry.data, {
    'X-Cache': cacheStatus,
    'X-Cache-Age': ((Date.now() - entry.storedAt) / 1_000).toFixed(3),
  });
}

后台刷新的区别在 await。没有副本时,请求等待 refreshCache();还能使用旧副本时,启动刷新后就继续返回已有数据。void 只表明这里不等待 Promise,错误仍然要捕获。

并发刷新共用一个 Promise

如果五个请求同时遇到空缓存,不能让它们都去生成一份相同数据。示例用 refreshing 保存正在加载的 Promise,让后来的请求复用它:

function refreshCache() {
  if (refreshing) return refreshing;

  const task = generateData()
    .then((data) => {
      const entry = { data, storedAt: Date.now() };
      // 主动清除会解除任务关联,旧任务即使完成也不能把缓存重新写回来。
      if (refreshing === task) cached = entry;
      return entry;
    })
    .finally(() => {
      if (refreshing === task) refreshing = null;
    });

  refreshing = task;
  return task;
}

同一个 Promise 同时承担了加载去重与任务身份。主动清除时,把 cached 和 refreshing 都设为 null;旧任务完成后,身份检查不再通过,就不能把已清除的副本写回来,也不能清掉后续新任务的引用。

解除关联并没有中止已经发出的数据源调用。在清除之前就等待旧任务的请求,仍可能收到那次调用的结果;这个小示例保证的是旧任务不能恢复缓存,不承诺撤销所有在途响应。

本地验证使用 Node.js 26.12.0,覆盖了冷缓存并发去重、HIT、返回旧值后后台刷新、主动清除、旧任务晚到和超过完整窗口后的重新生成。验证脚本通过真实 HTTP 请求观察结果,不靠只检查变量证明行为。这里的一秒等待模拟 I/O,不是一秒 CPU 工作,也不能用来推算云端费用或生产吞吐。

缓存只保存在当前进程,重启清空;多进程部署需要另外安排共享状态与协调。常驻 Node.js 进程可以在响应结束后继续完成刷新,Serverless 环境则要遵守平台的后台任务生命周期,不能直接把未等待的 Promise 当成可靠执行保证。

使用收益与费用分别判断

缓存命中可以减少生成响应的计算、数据库读取和上游请求,但这些收益不一定对应同一张账单。自建服务器通常仍支付常驻实例费用,重复工作减少后主要体现为容量与时延的变化。

Cloudflare 的 Workers Cache 命中不消耗被跳过 Worker 的 CPU 时间,请求仍按对应套餐的标准规则计量;开启缓存后,原本免费的静态资源请求、Service Binding 或 ctx.exports 调用,也会按这条缓存路径的请求规则计费。外层网关如果仍在执行,它的工作也没有消失。Workers Cache 计费

因此,已经由静态分发完成的页面,没有必要只为了启用新功能增加一层 Worker。更值得优先尝试的,是访问频繁、生成较慢、结果可以复用的公开读取;观察缓存状态的同时,也观察实际数据源调用是否减少。

总结

Workers Cache 补上了 Worker 直接承担源站职责时需要的前置响应缓存。它让平台在执行目标入口前复用响应,也让鉴权入口与昂贵的数据生成入口拥有不同的缓存策略。原理可以在普通 CDN、Nginx 和自己的 HTTP Server 中实现,具体产品的区别在于缓存能插在哪些位置,以及隔离、刷新、失效和计费怎样协作。

缓存有效的前提是结果真的可以复用。把公共内容与个人状态分开,让缓存键覆盖结果差异,再把数据写入与失效动作接起来,通常比先追求更长的 TTL 更有用。Node.js 示例中的命中、后台刷新和旧任务写回检查,都是围绕这个前提减少重复工作;真正部署时,还需要把同样的约束落实到跨进程状态和平台生命周期上。

Node.js 响应缓存:完整源码与 HTTP 验证

打开原文件 README.md

源码工作区以只读方式展示 Node.js HTTP 服务及验证脚本。它们需要在本机 Node.js 中运行;浏览器中的源码预览不会启动监听服务。

内存响应缓存与 HTTP 验证正在加载代码工作区...

这份记录来自配套脚本对本地服务发起的真实 HTTP 请求;使用随机测试端口,验证结束后关闭服务。记录保留版本、缓存状态和响应耗时,耗时只用于区分等待生成与复用结果,不作为性能基准。

本地 HTTP 验证记录
{
  "runtime": "v26.12.0",
  "recordedAt": "2026-10-31T22:03:22.772Z",
  "freshMs": 5000,
  "staleMs": 30000,
  "simulatedSourceDelayMs": 1000,
  "scenarios": [
    "冷缓存并发去重",
    "新鲜缓存命中",
    "过期后台刷新",
    "主动清除",
    "清除后旧任务写回隔离",
    "新旧任务并发替换",
    "完整窗口后硬过期",
    "未知路径"
  ],
  "observations": [
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1030,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0.001,
      "control": "no-store",
      "elapsedMs": 1016,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0.001,
      "control": "no-store",
      "elapsedMs": 1016,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0.002,
      "control": "no-store",
      "elapsedMs": 1016,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0.002,
      "control": "no-store",
      "elapsedMs": 1016,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "HIT",
      "age": 0.008,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "STALE",
      "age": 5.159,
      "control": "no-store",
      "elapsedMs": 4,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "STALE",
      "age": 5.159,
      "control": "no-store",
      "elapsedMs": 4,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "STALE",
      "age": 5.16,
      "control": "no-store",
      "elapsedMs": 4,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "STALE",
      "age": 5.16,
      "control": "no-store",
      "elapsedMs": 4,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "STALE",
      "age": 5.16,
      "control": "no-store",
      "elapsedMs": 4,
      "body": {
        "version": 1,
        "generatedAt": "2026-10-31T22:02:30.999Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "HIT",
      "age": 0.016,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "version": 2,
        "generatedAt": "2026-10-31T22:02:37.167Z"
      }
    },
    {
      "path": "/cache",
      "method": "DELETE",
      "status": 200,
      "cache": null,
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "cleared": true
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1007,
      "body": {
        "version": 3,
        "generatedAt": "2026-10-31T22:02:38.192Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "STALE",
      "age": 5.167,
      "control": "no-store",
      "elapsedMs": 2,
      "body": {
        "version": 3,
        "generatedAt": "2026-10-31T22:02:38.192Z"
      }
    },
    {
      "path": "/cache",
      "method": "DELETE",
      "status": 200,
      "cache": null,
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "cleared": true
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1008,
      "body": {
        "version": 5,
        "generatedAt": "2026-10-31T22:02:45.393Z"
      }
    },
    {
      "path": "/cache",
      "method": "DELETE",
      "status": 200,
      "cache": null,
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "cleared": true
      }
    },
    {
      "path": "/cache",
      "method": "DELETE",
      "status": 200,
      "cache": null,
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "cleared": true
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1011,
      "body": {
        "version": 6,
        "generatedAt": "2026-10-31T22:02:46.405Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1014,
      "body": {
        "version": 7,
        "generatedAt": "2026-10-31T22:02:46.592Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 173,
      "body": {
        "version": 7,
        "generatedAt": "2026-10-31T22:02:46.592Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "HIT",
      "age": 0.001,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "version": 7,
        "generatedAt": "2026-10-31T22:02:46.592Z"
      }
    },
    {
      "path": "/data",
      "method": "GET",
      "status": 200,
      "cache": "MISS",
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1010,
      "body": {
        "version": 8,
        "generatedAt": "2026-10-31T22:03:22.760Z"
      }
    },
    {
      "path": "/missing",
      "method": "GET",
      "status": 404,
      "cache": null,
      "age": 0,
      "control": "no-store",
      "elapsedMs": 1,
      "body": {
        "routes": [
          "GET /data",
          "DELETE /cache"
        ]
      }
    }
  ]
}