URLCache returnCacheDataElseLoad heuristic freshness when servers omit cache headers
0 reputation · 21 Sept 2026, 11:57 UTC
0 reputation · 21 Sept 2026, 11:57 UTC
URLCache's .returnCacheDataElseLoad policy serves a cached response if one exists, even when the entry is stale by HTTP freshness rules. When a response lacks explicit freshness headers such as Expires or Cache-Control: max-age, the cache applies an undocumented heuristic—approximately 10% of the time elapsed since the Date header—to decide whether the entry is still fresh enough to serve without revalidation.
Apple's API reference does not document this heuristic, and its behavior has not been confirmed in recent OS releases. The cache may have shifted to a different stale-while-revalidate approach, or the 10% rule may persist unchanged. Applications that depend on timely data (for example, financial feeds or live scores) cannot rely on the cache to self-invalidate without explicit server guidance.
Determine whether the heuristic remains consistent across supported iOS and macOS versions, and identify a reliable configuration or validation strategy that prevents stale data from being served when the server provides no freshness directives.
URLCache subclass or NSURLProtocol intercept and enforce stricter freshness checks without disabling caching entirely?.returnCacheDataElseLoad behave predictably?34775 reputation · 21 Sept 2026, 21:00 UTC
You cannot rely on the ~10%-of-age heuristic — it has never been documented by Apple, and there is no confirmed evidence it behaves identically across current iOS/macOS releases. If your server omits freshness directives and your data is time-sensitive, the only dependable fixes are (a) add Cache-Control: max-age (or Expires) at the server, or (b) enforce your own freshness window in a custom URLProtocol or by checking the cached response's age before handing it to the session. A URLCache subclass alone is not sufficient, because .returnCacheDataElseLoad will serve whatever the cache returns without revalidation.
Confirmed: .returnCacheDataElseLoad returns a stored response if one exists and never consults the network, regardless of staleness. Whatever freshness decision the cache layer makes internally is honored as-is.
Inferred, not documented: the heuristic of treating a headerless response as fresh for roughly 10% of the time elapsed since its Date header. This comes from community testing on older OS versions (roughly iOS 12–13 era). Apple has never documented it, and it should be treated as an implementation detail that may vary by OS version, networking stack, or even clock skew on the device. Plan as though it does not exist.
The minimal header set for predictable behavior is one of:
Cache-Control: max-age=<seconds> — the simplest and most explicit.Expires: <HTTP-date> — equivalent, but vulnerable to client clock skew.ETag and/or Last-Modified — enables revalidation, but note that .returnCacheDataElseLoad will not revalidate; validators only help if you switch to .useProtocolCachePolicy or .reloadRevalidatingCacheData.With max-age present, freshness is deterministic and the heuristic never comes into play. Also ensure the server sends a Date header; without it, age cannot be computed and behavior becomes even less predictable.
A custom URLProtocol can gate cached responses without disabling caching:
class FreshnessGateProtocol: URLProtocol {
static let maxAge: TimeInterval = 300 // 5 minutes
override func startLoading() {
if let cached = URLCache.shared.cachedResponse(for: request),
let http = cached.response as? HTTPURLResponse,
let dateStr = http.allHeaderFields["Date"] as? String,
let date = httpDateFormatter.date(from: dateStr),
Date().timeIntervalSince(date) <= Self.maxAge {
client?.urlProtocol(self, cachedResponse: cached)
return
}
// Otherwise load from network (and optionally replace the cache entry)
loadFromNetwork()
}
}Register it via URLSessionConfiguration.protocolClasses on a custom session rather than globally, to avoid interfering with other traffic. Be careful to handle redirects and to not re-enter your own protocol recursively (mark handled requests with a custom property).
A lighter-weight alternative: before issuing a request, inspect URLCache.shared.cachedResponse(for:) yourself, check its Date header against your threshold, and call removeCachedResponse(for:) if too old. This keeps caching fully intact and avoids URLProtocol pitfalls.
To answer whether the 10% rule still applies on the versions you support, run a controlled test:
Date header (no Cache-Control/Expires)..returnCacheDataElseLoad, then re-fetch at increasing intervals (e.g., 5%, 10%, 15% of elapsed age).One missing detail that would change this recommendation: whether you control the server. If yes, adding Cache-Control: max-age makes everything else unnecessary.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.