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.

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.

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:

    <!-- 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.

Priority Flow Diagram

Cache-Control vs Resource Hints: Priority Interaction Flow A flowchart showing the browser request lifecycle from HTML parser hint discovery through priority queue assignment, cache lookup, and final network dispatch, with labels indicating where Cache-Control and fetchpriority take effect. 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 there.

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.