Skip to content

Stale windows

A stale window is a number of seconds after an entry’s lifetime during which GraphPilot still answers from the cache, while a fresh copy is fetched behind it. Inside the window the caller gets the stored answer immediately, with no origin round trip, and the response reports gp-cache: STALE rather than HIT. The refresh runs after the answer has left, and the next caller gets the new copy. Inside an edge location, the stale copy is served by the cache server that holds the entry, so a request that another server handles can occasionally be a MISS (see stale responses within a location).

That makes it the cheap half of a hit rate. The lifetime decides how fresh the served copy is guaranteed to be; the stale window decides how long you keep answering instantly after that guarantee runs out. Widening it costs nothing in latency and buys back every request that used to wait for your origin at the moment an entry expired.

From the same source that decided the lifetime (see precedence):

  • Your origin’s header, as stale-while-revalidate:

    Cache-Control: max-age=60, stale-while-revalidate=86400
  • Your schema, as the swr argument of @cacheControl (GraphQL only):

    schema.graphql
    type Query {
    # Guaranteed fresh for a minute. For the day after that, still answered
    # from the cache while a refresh runs behind the response.
    invoices: [Invoice!]! @cacheControl(maxAge: 60, swr: 86400)
    }

A response with neither goes straight from fresh to a MISS.

How the schema resolves a window across a query

Section titled “How the schema resolves a window across a query”

A field that states no window does not switch one off. The response’s window is the minimum over the fields that declared one, and a field that declares none contributes nothing. This is deliberately not the rule maxAge follows, where an unannotated field counts as zero and takes the whole query with it.

An unannotated field is a field nobody has said is safe to hold, so maxAge treats silence as a refusal. Staleness is not that kind of statement: it moves no audience, the discriminators an entry is keyed on are untouched, and a purge ends staleness the moment something writes. If silence meant zero here, one unannotated wrapper type anywhere in a selection would switch the window off for the whole query, with no symptom but a hit rate that never moves.

A stated swr: 0 is still a statement, and it wins the minimum like any other number. That is how you switch staleness off for a response on purpose.

  • must-revalidate or proxy-revalidate on the origin header. Both forbid serving a response once it is stale, and GraphPilot has no conditional revalidation. So max-age=60, stale-while-revalidate=600, must-revalidate is stored for 60 seconds and never served STALE. Your logs record CACHE_STORED_WITHOUT_STALE_WINDOW with the window that was dropped.
  • A hard purge. A purge removes the entry outright rather than aging it. Only a soft purge leaves it to be served stale.

stale-if-error is not a stale window in this sense: GraphPilot parses it and does not act on it, so an entry is not served stale because your origin failed. See from the origin header.

s-maxage=0 keeps a response from ever being served fresh from the edge, and it combines with stale-while-revalidate in a way that is specified rather than a quirk. RFC 5861 lets a cache serve a response “after it becomes stale, up to the indicated number of seconds”, with no exemption for why it became stale. So s-maxage=0, stale-while-revalidate=60 reads as “stale to a shared cache immediately, and you may serve it stale for a minute”, and GraphPilot does exactly that: STALE for the window.

If what you mean is that the edge should keep nothing, Cache-Control: no-store says so with no interactions at all.

When a popular entry expires without a window, the next requests for it wait on one origin request: request collapsing sends one and holds the rest. With a window, a request that reaches the entry inside the window is served it immediately, and the refresh runs behind it. A request that does not reach it goes to your origin, as described in stale responses within a location.

The background response goes through the whole store decision like any other. If it is not storable, for example because your origin now answers with Set-Cookie, the stale entry is not replaced, and the refusal code appears in your logs rather than on a response.

An edge location (a Fastly POP) is not one cache but several cache servers. Each cached entry has one server in the location that holds it. While the entry is fresh, the location’s other servers can answer from it too, as a HIT. Once its lifetime has run out, the stale copy is served by the server that holds the entry.

In practice, most requests inside the window are STALE. Occasionally a request that another server handles finds no entry: it is a MISS, goes to your origin, and stores a fresh copy that the following requests are served from. This is expected behavior, not an error. It never serves wrong or older data; it costs one origin request per such miss.

An example, for an entry stored with maxAge: 60, swr: 3600, with all four requests at the same location:

TimeServergp-cachegp-cache-ageInsights LookupWhat happened
0 sAMISSNo entryYour origin answered; the entry was stored.
0.2 sBHIT0Entry foundFresh, so server B could answer from it.
93 sASTALE93Entry foundPast its lifetime, inside the window. Refreshed in the background.
93 s, 16 ms laterBMISSNo entryServer B did not find the stale entry. Your origin answered; a fresh copy was stored.

To recognize it, compare the requests in Insights: the request detail panel’s Cache tab shows the status and the Lookup (Entry found or No entry), and its Overview tab shows the edge location and the Server within it. A MISS with No entry at the same location and within a few seconds of a STALE, on a different Server, is this case. The detail panel shows these fields on every plan.

With shielding (paid plans, not Glider (Free)), such a miss is sent to the shield location instead of your origin. If the shield still holds the entry, fresh or inside its stale window, it answers and your origin is not contacted. The edge location still reports what it saw itself:

  • The shield’s copy was fresh: gp-cache: MISS, Shield result HIT. The edge location stores the copy for the lifetime it has left.
  • The shield’s copy was stale: gp-cache: PASS with gp-cache-reason: CACHE_SKIPPED_SHIELD_RESPONSE_NOT_STORABLE, Shield result STALE. The edge location does not store a stale copy; the shield refreshes its own in the background.

Shielding does not change how a location’s servers share entries, and the shield is itself a location with several servers: a request that reaches a shield server without the entry after its lifetime is a miss there and goes on to your origin. Shielding reduces how often your origin is asked; it does not guarantee a STALE at the edge.

  • Switch on shielding, so most of these misses are answered by the shield instead of your origin.
  • Use a longer lifetime (maxAge or s-maxage). While an entry is fresh, every server in the location answers from it, so fewer requests fall into the window at all. With invalidation on write, a long lifetime does not mean old data.

A window also extends how long an entry keyed to one caller may be served after that caller’s access changed: the bound is the lifetime plus the window. See revocation.

A private or named-scope response keeps its window in GraphPilot’s cache, but the window is not announced to the client. See downstream header.