@@ -20,6 +20,13 @@ const PAYLOAD_CACHE_STORAGE_KEY = 'payload-cache'
2020/** Default TTL for cached payloads (seconds). Matches ISR expiration for package routes. */
2121const PAYLOAD_CACHE_TTL = 60
2222
23+ /**
24+ * Grace period beyond TTL where stale payloads are still served (seconds).
25+ * Prevents a race where the HTML is served from Vercel's ISR cache right before
26+ * expiry, but the payload request arrives a moment later after our cache expires.
27+ */
28+ const PAYLOAD_CACHE_STALE_TTL = PAYLOAD_CACHE_TTL * 2
29+
2330interface CachedPayload {
2431 body : string
2532 statusCode : number
@@ -74,9 +81,10 @@ export default defineNitroPlugin(nitroApp => {
7481 // Verify build ID matches (extra safety beyond cache key)
7582 if ( cached . buildId !== buildId ) return
7683
77- // Check TTL
84+ // Check TTL — serve stale payloads within the grace period to avoid
85+ // a race where HTML is cached by Vercel but our payload has expired
7886 const age = ( Date . now ( ) - cached . cachedAt ) / 1000
79- if ( age > PAYLOAD_CACHE_TTL ) return
87+ if ( age > PAYLOAD_CACHE_STALE_TTL ) return
8088
8189 if ( import . meta. dev ) {
8290 // eslint-disable-next-line no-console
@@ -103,8 +111,8 @@ export default defineNitroPlugin(nitroApp => {
103111 // render:response — Cache payloads after rendering
104112 // -------------------------------------------------------------------------
105113 nitroApp . hooks . hook ( 'render:response' , ( response , ctx ) => {
106- // Don't cache error responses
107- if ( response . statusCode && response . statusCode >= 400 ) return
114+ // Don't cache error or unknown responses
115+ if ( ! response . statusCode || response . statusCode >= 400 ) return
108116
109117 const isPayloadRequest = PAYLOAD_URL_RE . test ( ctx . event . path )
110118 const isHtmlResponse = response . headers ?. [ 'content-type' ] ?. includes ( 'text/html' )
0 commit comments