Cache-Control Headers vs Resource Hints: Resolving Priority Conflicts

A preloaded asset shows Low priority in the Network panel despite a <link rel="preload"> declaration — this page explains exactly why that happens and how to fix it permanently.

Root Cause: Two Orthogonal Systems Colliding

Cache-Control and HTML resource hints (preload, prefetch, preconnect) operate at different layers of the browser network stack, and neither is aware of the other’s intent. Understanding this separation is the key to diagnosing priority inversion.

Resource hints live in the HTML parser layer. When the preload scanner encounters <link rel="preload" as="style">, it pushes the URL into the browser’s resource priority queue at the highest available slot for that resource type — before the main parser reaches the element that actually needs it. The hint only controls when the fetch is initiated and at what queue priority.

Cache-Control directives live in the HTTP layer. They govern whether the browser can serve a stored response directly (max-age, immutable) or must validate it first (must-revalidate, no-cache, stale-while-revalidate). Cache evaluation happens after the hint has dispatched the fetch — at the point where the network stack checks the HTTP cache.

The collision occurs during the validation step. When the browser dispatches a preloaded request and then discovers the cache entry needs validation (conditional GET with If-None-Match / If-Modified-Since), the pending round-trip competes with other in-flight fetches. The scheduler, seeing an uncertain response, may downgrade the request’s internal priority from Highest to Low — particularly under the stale-while-revalidate pattern, where a background revalidation is explicitly deferred so it does not block the foreground response.

The second collision vector is cache partitioning. Chromium introduced per-site cache partitioning (shipped in M86) to prevent cross-site timing attacks. A preloaded resource and its consuming element must share the same cache partition key (top-frame origin, frame origin, resource URL). A mismatch — common with CDN-served assets loaded via crossorigin attributes — creates a separate opaque entry that cannot be reused, forcing a redundant network fetch at a lower priority than the original preload.

The third vector is hint queue saturation. The network waterfall timing model shows that browsers cap concurrent high-priority requests per origin (typically six under HTTP/1.1, effectively unlimited under HTTP/2 but still bounded by server MAX_CONCURRENT_STREAMS). When stale-while-revalidate background fetches fill available slots, genuinely critical preloads queue behind them at a lower-than-expected priority — which shows up in DevTools as inflated Queueing and Stalled time rather than as a slow server, so it is worth reading the queueing breakdown before blaming the origin.

Minimal Reproduction

The smallest setup that triggers the mismatch: a hashed CSS asset preloaded without immutable, causing the browser to validate on every load.

<!-- index.html: triggers priority downgrade on repeat visits -->
<head>
  <!-- Cache-Control served by the server: max-age=3600 (no immutable) -->
  <!-- Browser must validate after 1 hour — or even on first repeat-visit
       if the browser's heuristic freshness lifetime is shorter than max-age -->
  <link rel="preload" href="/assets/main-abc123.css" as="style" />
  <link rel="stylesheet" href="/assets/main-abc123.css" />
</head>
# Response headers — missing immutable forces conditional GET on repeat visits
Cache-Control: public, max-age=3600
ETag: "abc123"

On the first visit the browser fetches normally. On the second visit within the max-age window, the browser may serve from cache — but if the user’s cache has been evicted or the server clock drifts, a conditional GET fires instead. That conditional GET starts life as High priority but if the validation round-trip takes more than ~50 ms while other High-priority fetches are in flight, Chromium’s scheduler reclassifies it to Low to avoid stalling the critical path.

Sequence diagram of the second visit to the reproduction page, showing the preload dispatched at Highest and reclassified to Low while the conditional GET is in flight Three lifelines: the preload scanner and scheduler, the HTTP cache, and the origin or CDN. The preload is dispatched at Highest. The cache finds the entry past its max-age with an ETag, so it must revalidate. A conditional GET goes to the origin. Fifty milliseconds later the scheduler reclassifies the request from Highest to Low. The 304 Not Modified returns after 120 to 350 milliseconds and the bytes are handed to the parser at Low. Two summary cards contrast that outcome with the immutable response, which serves in 0 milliseconds and keeps the Highest slot. Second visit, max-age=3600 without immutable: where the Highest slot is lost The hint still fires first — it is the validation round-trip on ETag "abc123" that demotes the request. Preload scanner + scheduler HTTP cache Origin / CDN 1. preload dispatched at Highest entry age > max-age, ETag present must revalidate before use 2. conditional GET If-None-Match 3. +50 ms: scheduler reclassifies Highest to Low while it waits 4. 304 Not Modified, 120–350 ms 5. bytes handed over, still at Low max-age only: 120–350 ms TTFB, ends at Low immutable: 0 ms TTFB, Highest retained

Fixed version — add immutable to signal the content will never change for this URL:

<!-- index.html: stable priority on all visits -->
<link rel="preload" href="/assets/main-[contenthash].css" as="style" fetchpriority="high" />
<link rel="stylesheet" href="/assets/main-[contenthash].css" />
# Response headers — immutable tells the browser: skip validation unconditionally
Cache-Control: public, max-age=31536000, immutable
ETag: "contenthash-value"

Deterministic Fix Protocol

  • [ ] Step 1 — Audit cache headers on all preloaded assets. Open Chrome DevTools → Network → filter by Initiator: preload. For each row, click the resource and inspect the Response Headers pane. Confirm Cache-Control contains either immutable (for versioned assets) or no-store (for assets that must never be cached). Flag any asset with only max-age and no immutable.

  • [ ] Step 2 — Add immutable to all content-hashed assets. In your build pipeline, every asset whose filename includes a content hash (e.g. main-abc123.css, chunk-7f3d.js) should be served with Cache-Control: public, max-age=31536000, immutable. This tells the browser the URL is permanently stable and no conditional GET is ever needed.

  • [ ] Step 3 — Add explicit fetchpriority="high" to critical preloads. The fetchpriority attribute is evaluated independently of cache freshness. Even when a validation round-trip is unavoidable, fetchpriority="high" keeps the request pinned in the High slot and prevents the scheduler from downgrading it during the wait:

    <link rel="preload" href="/assets/critical-[hash].css"
          as="style" fetchpriority="high" />
  • [ ] Step 4 — Fix crossorigin alignment. If a preloaded resource is fetched with CORS (fonts, WebGL textures, <script type="module">), the crossorigin attribute on the <link rel="preload"> must exactly match the attribute on the consuming element. A mismatch creates two cache entries and doubles the fetch — and because the first entry is never claimed, it also prints a “preloaded but not used” console warning a few seconds after load:

    <!-- Both must have crossorigin="anonymous" or neither should -->
    <link rel="preload" href="/fonts/Inter.woff2"
          as="font" type="font/woff2" crossorigin="anonymous" />
    <!-- The @font-face rule must also fetch with CORS — ensured by crossorigin on preload -->
  • [ ] Step 5 — Demote non-critical stale-while-revalidate revalidations. For assets served with stale-while-revalidate, the background revalidation fetch fires at default priority. Add fetchpriority="low" to the <link rel="prefetch"> hints for non-critical assets so they cannot occupy High-priority queue slots:

    <!-- Non-critical route chunk: low priority so revalidation cannot starve critical preloads -->
    <link rel="prefetch" href="/assets/route-about-[hash].js"
          as="script" fetchpriority="low" />
  • [ ] Step 6 — Verify CORS response headers on CDN-served assets. If assets are served from a CDN, ensure the CDN forwards or sets Access-Control-Allow-Origin and includes Vary: Origin, Accept-Encoding so edge caches partition correctly by origin and do not serve the wrong CORS response to cross-origin consumers.

  • [ ] Step 7 — Confirm no duplicate fetches. After deploying the above changes, run a WebPageTest Repeat View test. In the waterfall, verify no URL appears twice. Duplicate rows for the same asset are the definitive signal of a cache-partition mismatch or crossorigin misalignment.

The seven steps reduce to one decision per asset class — hash the URL or accept validation, then pick the hint that matches how the asset is fetched:

Decision tree mapping each preloaded asset class to the Cache-Control directive and resource hint pairing it needs, with each leaf labelled by the protocol step that applies it The tree starts from a preloaded asset and asks whether its URL carries a content hash. Content-hashed assets get max-age=31536000 with immutable and then split on whether they are fetched with CORS: if yes, the crossorigin attribute must match on hint and consumer, which is Step 4; if no, preload with fetchpriority high, which is Steps 2 and 3. Assets whose URL can change get max-age plus ETag and split on whether they are needed for first paint: if yes, fetchpriority high pins the slot through the 304 round-trip, which is Step 3; if no, prefetch at low priority so revalidation stays off the High queue, which is Step 5. Which header and hint pairing does this asset need? Run it once per asset class turned up by the Step 1 audit; every leaf names the step that applies it. Preloaded asset content-hashed URL? yes no max-age=31536000, immutable no validation, ever max-age + ETag conditional GET likely Fetched with CORS? font, module, texture Needed for first paint? above the fold yes no yes no match crossorigin on hint + consumer Step 4 fetchpriority=high Highest every visit Steps 2 + 3 fetchpriority=high pins slot through 304 Step 3 prefetch at low keeps SWR off High Step 5 Every branch still needs Step 6 at the edge: Vary: Origin, Accept-Encoding on the CDN response.

Priority Flow Diagram

Browser request lifecycle showing how Cache-Control and resource hints interact at different stack layers A three-lane flowchart. In the HTML parser lane the parser discovers a link rel preload and the fetchpriority attribute sets the queue slot. In the priority queue lane the scheduler assigns Highest, High or Low. In the HTTP cache lane the lookup evaluates max-age, immutable and stale-while-revalidate, then a freshness decision either returns a cache hit with a stable priority or fires a conditional GET that may be downgraded to Low. HTML PARSER LAYER PRIORITY QUEUE HTTP CACHE LAYER Parser discovers <link rel="preload"> fetchpriority attr sets queue slot Scheduler assigns Highest / High / Low HTTP cache lookup max-age / immutable / SWR Fresh? immutable hit YES Cache Hit priority stable NO Conditional GET fired may downgrade to Low KEY INSIGHT fetchpriority sets the slot; immutable keeps it.

Before/After Metrics

The table below shows expected DevTools and field metric changes after applying the fix protocol above. Values are representative across a Next.js app with three critical CSS files and two JS entry chunks.

Metric Before fix After fix How to verify
Preload asset priority (DevTools) Low or Medium on repeat visits Highest on every visit Network panel → Priority column
Repeat-visit TTFB (critical CSS) 120–350 ms (conditional GET) 0 ms (cache hit) Timing tab → Waiting (TTFB)
LCP (repeat visit) 1.8–2.6 s 0.9–1.4 s Lighthouse → LCP
Duplicate fetch rows in waterfall 1–3 duplicate rows 0 WebPageTest → Waterfall
Preload cache-hit ratio (RUM) 70–82% 97–99% PerformanceObserver transferSize
Concurrent High-priority requests 6–9 (SWR revalidation leaking into High) 3–5 Network panel → Priority filter

RUM Snippet — Detect Validation-Forced Fetches

// Fires after page load; logs any preloaded resource that was not served from cache.
// transferSize > 0 on a preloaded asset means the browser went to the network,
// indicating a cache miss or conditional GET — both signal a Cache-Control misalignment.
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType !== 'link') continue;
    const isPreloaded = entry.name.match(/\.(css|js|woff2)(\?|$)/);
    if (!isPreloaded) continue;

    const cacheHit = entry.transferSize === 0 && entry.encodedBodySize > 0;
    const ttfb = entry.responseStart - entry.startTime;

    if (!cacheHit) {
      // transferSize > 0 on a repeat visit → likely a conditional GET
      // caused by missing immutable or crossorigin partition mismatch
      console.warn('[priority-audit] Network fetch on preloaded asset:', {
        url: entry.name,
        ttfb: ttfb.toFixed(0) + 'ms',
        transferSize: entry.transferSize,
        decodedSize: entry.decodedBodySize,
      });
    }
  }
});
observer.observe({ type: 'resource', buffered: true });

FAQ

Can resource hints override Cache-Control directives?

No. Resource hints accelerate fetch initiation but do not bypass cache validation. The browser evaluates freshness (max-age, immutable) before completing the fetch. A preloaded asset still goes through a conditional GET if its cache entry lacks a freshness directive or has expired. The hint only moves the request earlier in the dispatch queue — it cannot make a stale response fresh.

Why does a preloaded asset still show Low priority in DevTools?

If Cache-Control is missing the immutable directive, the browser must assume the asset needs validation. During that validation round-trip the scheduler may downgrade the request from Highest to Low, especially when the hint queue is saturated with other High-priority fetches. Adding fetchpriority="high" to the <link rel="preload"> element keeps the slot pinned regardless of what the cache validation decides.

Does stale-while-revalidate background fetch compete with critical resources?

Yes, under HTTP/1.1 or when a connection is at its concurrency limit. The background revalidation opens a new request at default (Medium or Low) priority. If six High-priority fetches are already in flight on an HTTP/1.1 connection, the background fetch queues behind them. Under HTTP/2, the background fetch is multiplexed but still consumes a stream slot — and if the server caps MAX_CONCURRENT_STREAMS at a low value, it can delay critical stream dispatch. Assign fetchpriority="low" to any <link rel="prefetch"> hints associated with stale-while-revalidate assets to ensure revalidation never steals High-priority queue slots.