`Astro.cache.set()` in layouts and components is silently dropped once rendering has awaited · Issue #18453 · withastro/astro · GitHub
Skip to content

Astro.cache.set() in layouts and components is silently dropped once rendering has awaited #18453

Closed

Description

@stefanprobst

Astro Info

Astro                    v7.4.0
Node                     v24.21.0
System                   Linux (x64)
Package Manager          npm
Output                   server
Adapter                  @astrojs/node (v11.1.8)
Integrations             none

If this issue only occurs in one browser, which browser is a problem?

No response

Describe the Bug

With memoryCache(), tags set via Astro.cache.set() in a layout or component are silently ignored once rendering has awaited outside the page frontmatter. Invalidating such a tag does not re-render the page:

page-tag                   ok: re-rendered
page-tag-after-await       ok: re-rendered
layout-tag                 ok: re-rendered
layout-tag-after-await     BUG: still served from cache
component-tag              BUG: still served from cache
component-tag-after-await  BUG: still served from cache

The cache handler reads the cache options as soon as the streamed response is created (applyCacheHeaders() in dist/core/cache/handler.js), so later directives still show up in Astro.cache.tags but never reach the cache. Since memoryCache() buffers the whole body before storing anyway, it could read them once rendering has finished. Details and a workaround are in the repro README.

What's the expected result?

Invalidating any tag the page set re-renders it, as the caching guide says layouts and page code "can each contribute cache directives independently" - or at least a warning when cache.set() is called too late to take effect.

Link to Minimal Reproducible Example

https://github.com/stefanprobst/astro-cache-tags-repro

Participation

  • I am willing to submit a pull request for this issue.

Activity

  1. astro-factory commented on Oct 19, 2026

    @astro-factory
    Contributor

    Triage complete

    • Prepare the workspace: ready
    • Install dependencies: complete
    • Build the project: complete
    • Reproduce the issue: confirmed
    • Diagnose the cause: high confidence
    • Verify the diagnosis: bug
    • Attempt a fix: verified
    • Publish the result: set triage: fix pending

    • Reproduced: Yes
    • Exploration: Yes — View branch
    • Unit Test: Yes — packages/astro/test/units/cache/app-cache.test.ts
    • Priority: P2: has workaround. This goes against the caching guide, which says layouts can add their own cache directives. The middleware workaround in the repro README fixes it (I confirmed it does).

    handleCache() in packages/astro/src/core/cache/handler.ts calls applyCacheHeaders() as soon as next() resolves. For a streamed page, that happens before layouts (after their first await) and child components have rendered. memoryCache() then takes maxAge/tags from those early CDN-Cache-Control/Cache-Tag headers. Any later Astro.cache.set() calls still show up in Astro.cache.tags but never reach the cache, and nothing is logged.

    I found a potential fix for this issue. It only changes the runtime-provider (onRequest) path. If the request is a GET with a body and maxAge > 0 is already set, the handler reads the full body before applying cache headers. That keeps the Astro cookies attached to the response. memoryCache() already waits for the full body before storing cacheable responses, so cacheable pages don't lose streaming. Uncacheable pages, non-GET requests and CDN providers stream as before. This needs human review. Two limitations remain: a maxAge set only late (e.g. only in a component after an await) is still not picked up, and CDN providers still read headers before the body streams.

    Try this fix

    You can test this fix right now without waiting for a release:

    # astro
    npm i https://pkg.pr.new/astro@46dcb2d

    If this fixes your issue, please leave a comment letting us know (for example
    "confirmed, this fixes it"). A pull request will then be opened to get it merged.

    Full Triage Report

    Issue summary

    With cache: { provider: memoryCache() }, output: 'server' and @astrojs/node (standalone), tags set through Astro.cache.set() in a layout or component are dropped once rendering has awaited outside the page frontmatter. Invalidating those tags doesn't re-render the page. Expected behavior, per the caching guide ("Middleware, layouts, content loaders, and page code can each contribute cache directives independently"): invalidating any tag the page set re-renders it, or at least a warning is logged.

    Reported environment: Astro 7.4.0, @astrojs/node 11.1.8, Node 24.21.0, Linux, npm.
    Tested against: current main (astro 7.4.1, @astrojs/node 11.1.9 from the workspace), Node 22+.

    Reproduction

    Used https://github.com/stefanprobst/astro-cache-tags-repro with the workspace versions of astro and @astrojs/node:

    • the page sets maxAge: 3600 + page-tag, then awaits, then sets page-tag-after-await
    • the layout sets layout-tag, then awaits, then sets layout-tag-after-await
    • the child component sets component-tag, then awaits, then sets component-tag-after-await
    • POST /api/invalidate?tag=… calls context.cache.invalidate({ tags: [tag] })

    Steps:

    1. astro build
    2. node dist/server/entry.mjs
    3. node repro.mjs (for each tag: prime the cache with MISS then HIT, invalidate the tag, check whether the page re-renders)

    Result on main (reproduces every time):

    page-tag                   MISS -> HIT -> invalidate -> MISS  ok: re-rendered
    page-tag-after-await       MISS -> HIT -> invalidate -> MISS  ok: re-rendered
    layout-tag                 MISS -> HIT -> invalidate -> MISS  ok: re-rendered
    layout-tag-after-await     MISS -> HIT -> invalidate -> HIT  BUG: still served from cache
    component-tag              MISS -> HIT -> invalidate -> HIT  BUG: still served from cache
    component-tag-after-await  MISS -> HIT -> invalidate -> HIT  BUG: still served from cache
    

    Nothing is logged. With the reporter's buffering-middleware workaround in place, all 6 tags re-render as expected.

    Diagnosis

    I logged the tags inside AstroCache[APPLY_HEADERS]. On every cache miss it only sees ['page-tag', 'page-tag-after-await', 'layout-tag'].

    • core/cache/handler.ts handleCache(): for providers with onRequest, the wrapped next runs const res = await next(); applyCacheHeaders(cache, res, request). The CDN path does the same thing at the end of the function.
    • runtime/server/render/page.ts / runtime/server/render/astro/render.ts: next() resolves once the page frontmatter and head have rendered and a streaming body exists. The rest of the component tree renders lazily as the body is read.
    • core/cache/runtime/cache.ts [APPLY_HEADERS]: takes a snapshot of the options as CDN-Cache-Control / Cache-Tag. Later set() calls still update the internal state but never reach the headers.
    • core/cache/memory-provider.ts onRequest: on a miss, and on SWR revalidation, it reads maxAge/swr/tags from those headers before serializeResponse() buffers the body.

    Middleware runs inside handleCache (core/routing/handler.ts, runPipeline). That's why buffering in user middleware works around it.

    Confidence: high.

    Verification

    Verdict: bug (medium confidence).

    • The docs say layouts and page code can contribute cache directives independently. Neither the docs nor the JSDoc mention any timing limit.
    • There's no code comment, guard or warning about late set() calls. Route caching was added in feat: route caching #15579, and its commit message and the stabilisation changeset don't mention this.
    • The cache test fixtures only call cache.set from API routes, never from layouts or components.
    • Caveat: Astro already has a known streaming limitation for response headers and cookies in components, and a maintainer could see this as the same thing. For CDN providers, late directives can't be honoured without buffering the page.

    Fix

    packages/astro/src/core/cache/handler.ts:

    +import { attachCookiesToResponse, getCookiesFromResponse } from '../cookies/response.js';
     ...
     			async () => {
    -				const res = await next();
    +				let res = await next();
    +				// `next()` resolves once the streamed body starts, but layouts and
    +				// components can still call `cache.set()` while the rest renders.
    +				// When the response is going to be cached, the provider buffers the
    +				// body anyway, so render it fully before reading the cache options.
    +				// https://github.com/withastro/astro/issues/18453
    +				if (state.request.method === 'GET' && res.body && (cache!.options.maxAge ?? 0) > 0) {
    +					const buffered = new Response(await res.arrayBuffer(), res);
    +					const cookies = getCookiesFromResponse(res);
    +					if (cookies) attachCookiesToResponse(buffered, cookies);
    +					res = buffered;
    +				}
     				applyCacheHeaders(cache!, res, state.request);
     				return res;
     			},
    • Repro after the fix: all 6 tags show MISS -> HIT -> invalidate -> MISS ok: re-rendered.
    • New unit test stores tags set by components after rendering has awaited: the page sets maxAge, and a child component awaits and then sets tag late. Invalidating late must produce a MISS. The test fails without the fix (actual: 'HIT') and passes with it.
    • Regressions: test/units/cache/* 171/171 and test/units/{app,middleware}/** 357/357 pass. Biome and tsc are clean.
    • Changeset: .changeset/ten-boxes-wish.md (astro: patch).

    Alternatives considered:

    • Always buffer: breaks streaming for uncacheable pages, and infinite-stream GET endpoints would never finish.
    • Have the provider re-read the options after reading the body: needs a new channel between the cache object and the provider, and still doesn't handle a maxAge set late.
    • Only log a warning: doesn't give runtime providers the behavior the docs describe.

    Remaining limitations:

    • A maxAge set only late is still not honoured.
    • CDN providers could get a warning when cache.set() comes too late, or a docs note.

    This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.

  2. added
    - P2: has workaroundAn edge case that only affects very specific usage, but has a trivial workaround (priority)
    pkg: astroRelated to the core `astro` package (scope)
    triage: fix pendingReporter needs to verify the triage bot fix works
    and removed on Oct 19, 2026
  3. ematipico commented on Oct 24, 2026

    @ematipico
    Member

    @stefanprobst please test the preview and tell us if it fixes the issue

  4. Princesseuh commented on Oct 26, 2026

    @Princesseuh
    Member

    Closing in favor of #18499 which has more details

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

- P2: has workaroundAn edge case that only affects very specific usage, but has a trivial workaround (priority)pkg: astroRelated to the core `astro` package (scope)triage: fix pendingReporter needs to verify the triage bot fix works

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions