A Core Web Vitals Guide to Resource Prioritization

Do not let the browser guess. Force it to load what matters when it matters!

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2025-11-04

TL;DR: Improving resource priority's

Your browser's default priorities are just a "best guess" and they often get it wrong. The browser's preload scanner is fast, but it's blind. It cannot see critical resources hidden inside CSS files or know which resources are more important hen others. So here is how to fix it!

  • 1. Fix Discovery First: Use <link rel="preload"> in your <head> for any LCP image or critical font. This makes them visible to the browser immediately. 
  • 2. De-prioritize Everything Else: Add loading="lazy" to all below-the-fold <img> and <iframe> elements. Add fetchpriority="low" to any above-the-fold but unimportant images (like country flags for translations).
  • 3. Manage Your Scripts: Use defer for scripts that need the DOM and must run in order. Use async for independent scripts (like analytics). If a critical async script (like UI hydration) loads too slow, boost it with fetchpriority="high".

Core Web Vitals Guide to Resource Prioritization

To build a truly fast webpage, you must understand how to schedule resources. A browser, especially on a mobile device, works with a finite network bandwidth and CPU.
When you attempt to fetch everything at once, you create resource contention. The browser is forced to divide its limited bandwidth and processing time among all  resources. This parallel approach is highly effective if all resources are equally important. But they never are .... that is what you need to explain to the browser!
When a low-priority resource (like an analytics script) steals bandwidth from a critical, render-blocking one (like your CSS) you are wasting time delaying the Core Web Vitals and costing your business money

In this article you will learn how and when to prioritize critical network resources and de-prioritize non critical network resources

Table of Contents!

Background: Understanding Browser Heuristics and Default Priorities

The "Computed" Priority: How Browsers Think

Before you can manually prioritize or de-prioritize a resource, you must understand the complex, heuristic-based system browsers use to assign priority automatically. When a browser parses a web page, its primary objective is to render content progressively and achieve key performance milestones, such as First Contentful Paint (FCP) and Largest Contentful Paint (LCP), as quickly as possible. To do this, it builds a dependency graph of all required resources and assigns each a "computed priority."

priority waterfall web dev example

This priority is not static. It's a dynamic value that depends on the resource's type, its location in the document (e.g., in the <head> or at the end of the <body>), and its potential to block rendering.

Browsers like Chrome (using the Blink rendering engine) typically use a five-level priority scale: Highest, High, Medium, Low, and Lowest. These are the levels visible in the "Priority" column of the Chrome DevTools Network panel.

The Default Priority Queue

The out-of-the-box prioritization logic of a browser is generally effective. A simplified model of these default priorities looks like this:

  • Highest: Reserved for the most critical, render-blocking resources. This includes the main HTML document, render-blocking CSS files in the <head>, and font files (once discovered).
  • High: Includes resources that are important but not as critical. This is the default for parser-blocking JavaScript in the <head> and asynchronous fetch() API calls.
  • Medium: Often a "late" priority. It's assigned to parser-blocking scripts found later in the <body> and, importantly, to images that the browser detects are in the viewport (i.e., "above-the-fold") after layout is calculated.
  • Low: The default for explicitly non-blocking or deferred resources. This includes scripts with async or defer, and images that are below the fold (before layout is known).
  • Lowest: For background or speculative resources. This includes stylesheets with a non-matching media attribute (e.g., media="print") and hints like <link rel="prefetch">.

The "Discovery" Problem and the Preload Scanner

A resource cannot be fetched until the browser discovers it. To accelerate this discovery, browsers employ a secondary, speculative parser called the "preload scanner" or "lookahead parser."

While the main HTML parser is blocked (e.g., waiting for a CSS file to download), the preload scanner races ahead, scanning the raw HTML markup to find resources it can start downloading opportunistically.

This mechanism is fast, but it has one critical limitation: the preload scanner only scans the HTML. It cannot see resources referenced inside other files.

This "blind spot" is the primary reason why critical resources are often "late-discovered." The preload scanner can find an <img> tag in the HTML, but it cannot find:

  • A background-image URL defined inside a CSS file.
  • A font file (.woff2) referenced by an @font-face rule, which is also inside a CSS file.
  • Lazy images (because it does not know if they are in the viewport without rendering)

In these cases, the browser must wait for the main parser to download, parse, and apply the CSS before it even knows these other critical resources exist. This fundamental discovery delay is the root cause of many LCP and text-rendering performance issues, and it forms the basis for many of the prioritization techniques that follow.

Default Browser Resource Priorities (Blink Engine)

To effectively manipulate priorities, you must first know the default state. The following provides a map of the default "computed" priorities for most resources in the Blink engine (e.g., Chrome, Edge).

  • Document (HTML): Highest
  • CSS (Early in <head>): Highest (Render-blocking)
  • CSS (Late in <body>): Medium
  • CSS (media mismatch): Lowest
  • Script (Early, parser-blocking): High
  • Script (Late, parser-blocking): Medium
  • Script (async or defer): Low
  • Font (Once discovered): Highest
  • Image (In viewport, post-layout): High (Boosted from Low)
  • Image (Out of viewport): Low (Default)
  • Fetch API: High
  • Preload (rel="preload"): Varies (Matches its 'as' type)
  • Prefetch (rel="prefetch"): Lowest
  • Beacon (navigator.sendBeacon): Lowest

Controlling CSS: The Render-Blocking Standard

CSS is the most critical render-blocking resource. Its prioritization is aggressive by default, so most techniques focus on de-prioritization or managing non-critical CSS.

Prioritization: The <link rel="stylesheet"> Default

By default, any CSS file requested via a <link rel="stylesheet"> tag in the <head> is render-blocking. The browser assigns it Highest priority. This is the correct and desired behavior for styles essential for the initial, "above-the-fold" content. The browser will pause page rendering until this CSS is fetched and parsed to construct the CSS Object Model (CSSOM).

De-prioritization: Non-Matching 'media' Attributes

A powerful technique for de-prioritizing CSS is to use the media attribute. When a browser encounters a <link> tag, it checks the media attribute to see if it matches the current device state.

If the media attribute does not match (e.g., media="print" on a 'screen' device), the browser considers the resource non-critical for the initial render and assigns it the Lowest priority. You can use this technique to load non-critical stylesheets asynchronously by loading them with a non-matching media type (like media="only x") and then using JavaScript to switch the attribute to all or screen after the page has loaded.

Why to avoid @import

While <link> tags in the <head> can be discovered by the preload scanner and downloaded in parallel, CSS @import rules are a performance anti-pattern. The @import rule is serial and blocking.

If main.css contains the line @import "other.css";, the browser's request chain is sequential:

  1. Discover and fetch main.css (Highest priority).
  2. Wait for main.css to download.
  3. Parse main.css and *only then* discover the @import rule.
  4. Begin a new request to fetch other.css.

This creates a serial dependency chain that defeats the preload scanner and parallel downloads. It effectively de-prioritizes the imported stylesheet by delaying its discovery and is the single worst way to load CSS. You must avoid it.

Explicit Control: 'fetchpriority' on <link> Tags

The fetchpriority attribute can be applied directly to <link> tags. However, it's crucial to understand that fetchpriority is a hint that provides a relative nudge, not an absolute command.

The browser's internal heuristics can override this hint if it seems contradictory. For example, if you apply fetchpriority="low" to a render-blocking <link rel="stylesheet"> in the <head>, the browser will not drop its priority to "Low." It recognizes that this file is critical for rendering. Instead, it will only drop the priority slightly, from Highest to High. This demonstrates that the browser understands the resource's criticality and will not allow a developer to easily break the page's rendering.

Mastering JavaScript: Parser-Blocking vs. Asynchronous

JavaScript's priority is the most complex, as it involves three distinct axes of control:

  • Fetch Priority: When the network request should happen.
  • Parser Blocking: Whether the HTML parser must stop and wait.
  • Execution Timing: When the script's code is actually run.

The Default: Parser-Blocking <script>

A standard <script src="..."> in the <head> without attributes is parser-blocking. It receives a High priority fetch. The browser must stop parsing the HTML, fetch the script, parse the script, and execute the script before it can continue building the DOM. This is highly detrimental to performance.

De-prioritization (Execution Timing): 'defer'

The defer attribute is a powerful de-prioritization tool. It tells the browser:

  • Fetch Priority: Download this script with Low priority.
  • Parser Blocking: Do not block the HTML parser. Fetch in parallel.
  • Execution Timing: Wait to execute this script until *after* the HTML parsing is complete.
  • Execution Order: Critically, defer scripts are guaranteed to execute in the order they appear in the document. This makes defer ideal for scripts that have dependencies on each other or need to access the full DOM.

De-prioritization (Fetch & Execution): 'async'

The async attribute is for truly independent scripts. It tells the browser:

  • Fetch Priority: Download this script with Low priority.
  • Parser Blocking: Do not block the HTML parser. Fetch in parallel.
  • Execution Timing: Execute this script as soon as it finishes downloading, regardless of the HTML parser's state.
  • Execution Order: async scripts have no guaranteed order. This makes async suitable only for "fire and forget" scripts like analytics, ads, or other third-party scripts with no dependencies.

The 'type="module"' Exception

A crucial detail in modern development is that <script type="module"> behaves differently. By default, modules are deferred. A <script type="module" src="..."> behaves exactly like a <script defer src="...">. The async attribute can still be added to a module to get the async behavior.

Prioritizing and De-prioritizing with 'fetchpriority'

The default Low priority for async and defer scripts is usually desired. However, a common performance problem arises: what if a critical async script (like for UI hydration) is stuck in the network queue behind larger, non-critical images that are also "Low" priority?

The semantically-correct solution is fetchpriority="high". Applying this to an async or defer script elegantly boosts its fetch priority from Low to High. This allows it to win the bandwidth race against other low-priority resources without making it parser-blocking.

Conversely, if there are parser-blocking scripts late in the <body> (which get a default Medium priority), fetchpriority="low" can be applied to them. This lowers their priority, allowing other, more important resources (like LCP images) to be fetched first, improving LCP.

Optimizing Images: LCP and Beyond

Image prioritization is almost entirely focused on ensuring the Largest Contentful Paint (LCP) element loads as fast as possible, while de-prioritizing everything else.

The Default Heuristic: <img> Tags

By default, all images start with a baseline priority of Low. However, to optimize for LCP, browsers have a complex set of heuristics that modify this.

The old model was to wait for layout, see what was in the viewport, and *then* boost those images from Low to High. This was flawed because it was too slow.

The new heuristic (since Chrome 117) is more optimistic. As the preload scanner discovers the first few <img> tags in the markup (e.g., the first 5), it *speculatively* boosts their priority from Low to Medium. It does this *before* layout, assuming these early images are likely important (like a logo or hero).

Then, after layout is complete, the browser runs its second check. Any image found to be in the viewport (including those already at Medium) is boosted to High priority.

This new heuristic is better, but it still involves a delay and a guess. The LCP image only starts at Medium, not High. During this phase, it can still be starved of bandwidth by other Medium or High priority resources (like parser-blocking scripts).

Prioritization (The LCP Trick): 'fetchpriority="high"'

This is the primary and most important use case for the Fetch Priority API. By adding fetchpriority="high" directly to the LCP <img> tag, you provide a critical hint to the browser.

This hint eliminates the new heuristic delay. It tells the browser, "Do not guess and start this at Medium. I am telling you right now that this image is High priority." This small change allows the image to be fetched much earlier, often resulting in a significant improvement in the LCP metric.

The 'background-image' Problem

As established, CSS background-image properties are invisible to the preload scanner and are a poor choice for an LCP element. They are "late-discovered" after CSS files are downloaded and parsed.

If you must use a background-image for your LCP element, you must manually fix both the discovery problem and the priority problem. This is a two-part solution:

<link rel="preload" as="image" href="lcp-image.jpg" fetchpriority="high">

This single line of code in the HTML <head> accomplishes two goals:

  1. rel="preload": Fixes the discovery delay by making the image visible to the preload scanner.
  2. fetchpriority="high": Fixes the priority delay by ensuring that once this preloaded resource is discovered, it wins the bandwidth race.

De-prioritization (Native): 'loading="lazy"'

The loading="lazy" attribute on an <img> tag is the standard, native method for de-prioritizing images that are "below-the-fold." This attribute instructs the browser to defer loading the image entirely until the user scrolls near it. This is a de-prioritization of *time* (when to fetch) rather than just network priority.

It is important to note that loading="lazy" and fetchpriority="high" are contradictory signals. A developer should not use both on the same image. The browser will ignore the fetchpriority hint on an image that is marked for lazy loading.

De-prioritization (Explicit): 'fetchpriority="low"'

If loading="lazy" is for below-the-fold images, when would you use fetchpriority="low" on an image?

The primary use case is for *above-the-fold, non-critical* images. The classic example is an image carousel. The first image in the carousel (the visible LCP) should receive fetchpriority="high". The second and third images, which are also technically "above-the-fold" but are not visible, should be explicitly marked with fetchpriority="low". This prevents them from competing for bandwidth with the actual LCP image.

Loading Fonts: Solving the "Late-Discovered" Resource

Fonts are a unique resource. They are invisible to the preload scanner, but they are critical for rendering text, so they are assigned the highest priority once they are finally discovered.

Default Behavior: The Discovery Delay

Fonts are defined in CSS via @font-face rules. The browser cannot discover the need for a font file until it has (1) downloaded the CSS, (2) parsed the CSS, (3) built the CSSOM, (4) matched it against the DOM, and (5) determined that text on the page requires that specific font.

Only after this "late discovery" does the browser trigger the font download. Because the font is now blocking text from being rendered, the browser assigns it Highest priority. This is often too late, causing a "Flash of Invisible Text" (FOIT) or "Flash of Unstyled Text" (FOUT).

The Preload Solution: Fixing "Discovery"

The only reliable way to fix this discovery delay is to tell the preload scanner about the font in the HTML <head>. This is done with <link rel="preload" as="font">.

<link rel="preload" as="font" type="font/woff2" href="my-font.woff2" crossorigin>

This hint tells the preload scanner to start fetching the font file immediately, in parallel with the CSS file. By the time the CSS is parsed and asks for the font, it is already in the browser's cache or is halfway through downloading.

'font-display': A Rendering Control, Not a Priority Control

This is a common point of confusion. The font-display CSS property (e.g., font-display: swap; or font-display: block;) does not change the network priority of the font request.

font-display controls the *rendering behavior* during the font's download period:

  • block: Hides the text (FOIT) and waits for the Highest priority font fetch to complete (for up to 3 seconds).
  • swap: Shows the fallback text immediately (FOUT) and then "swaps" in the custom font whenever its Highest priority fetch completes.

This property controls the user experience of the load, while rel="preload" controls the timing of the load.

Video, Iframes, and Other Media

This section covers heavy, embedded media, which are often non-critical and should be de-prioritized by default.

De-prioritizing Video: The 'preload' Attribute

For the HTML5 <video> element, the primary control is the preload attribute.

  • preload="auto": This is the default in some browsers. It hints that the browser can download the entire video file, even if the user never presses play. This is a massive waste of bandwidth.
  • preload="metadata": This is the recommended default. The browser fetches only the video's metadata (e.g., dimensions, duration, first frame). This is a small, Low priority request.
  • preload="none": This is the ultimate de-prioritization. The browser fetches nothing—not even metadata—until the user explicitly clicks "play."

De-prioritizing Iframes: 'loading="lazy"'

Third-party embeds, such as YouTube videos, social media feeds, or advertisements, are a primary source of page bloat and slow load times. These are almost always delivered via an <iframe>.

The loading="lazy" attribute on an <iframe> tag is the native, standard method to de-prioritize them. This defers loading the entire iframe (and all of its internal subresources) until the user scrolls near it. This saves enormous amounts of data and frees up network bandwidth for more critical, first-party resources.

Network-Level Hints: 'preload', 'prefetch', and 'preconnect'

These <link>-based hints provide manual control over the discovery and connection phases of a request. They are often confused but have very different purposes.

rel="preload": Forcing a High-Priority Fetch

  • Purpose: To fix discovery delays for critical resources on the *current page* that the preload scanner cannot find (e.g., fonts, CSS background-image).
  • Priority: High. A preload request gets the default priority of the resource specified in its as attribute (e.g., as="style" is Highest). It tells the browser to fetch this resource now. The as attribute is mandatory.

rel="prefetch": A "Lowest" Priority Hint for the Future

  • Purpose: To fetch a resource that will likely be needed on a *future navigation* (e.g., the next page in a multi-step user flow).
  • Priority: Lowest. The browser will download this resource only when the network is idle and after all resources for the current page are finished loading.

rel="preconnect": Paying the Connection Cost Early

  • Purpose: To initiate the connection to a critical third-party domain (e.g., Google Fonts, an API server, a CDN) before a resource is actually requested from it.
  • Priority: This doesn't fetch a file. It initiates a high-priority connection. This single hint performs the DNS lookup, TCP handshake, and TLS negotiation. This process can take 100-500ms, and preconnect allows it to happen in parallel, saving significant time later.

rel="dns-prefetch": The Low-Priority Fallback

  • Purpose: To only perform the DNS lookup for a domain.
  • Priority: This is a low-priority hint. It is less powerful than preconnect (which does DNS + TCP + TLS). You should use it as a fallback for older browsers that do not support preconnect.

<link rel="preconnect" href="httpsC://api.example.com">

<link rel="dns-prefetch" href="https://api.example.com">

The Fetch API: Prioritizing Data

This covers asynchronous data requests (e.g., XHR, fetch) initiated from JavaScript.

Default Priority: 'fetch()' is "High"

By default, a standard fetch() API call is considered an important resource and is assigned High priority. This is generally reasonable, as this data is often required to render UI.

De-prioritization: { priority: 'low' }

A modern "trick" for fetch calls is the priority property in the options object. If an application is fetching non-critical data (e.g., suggested articles, background analytics), it should explicitly de-prioritize this request:

fetch('/api/non-critical-data', { priority: 'low' });

This ensures the data fetch does not compete with more critical resources, like an LCP image or a critical async script.

The Beaconing Dilemma: 'sendBeacon' vs. 'fetch(keepalive)'

A common problem is how to reliably send analytics data when a user is closing a page.

  • The Old Way: navigator.sendBeacon(). This API was designed specifically for this purpose. It reliably sends a POST request as the page unloads, and it does so with Lowest priority to avoid delaying the next navigation. Its main limitations are that it is "fire and forget" (no response available) and POST-only.
  • The New Way: fetch(url, { keepalive: true }). The keepalive flag tells the browser to keep this request alive even if the page unloads. This creates a significant priority inversion. Unlike sendBeacon(), a fetch() with keepalive: true *retains its default High priority.* This makes fetch(keepalive) a high-priority, reliable, and flexible beaconing method, superior to sendBeacon in almost every way, unless you specifically require a Lowest priority request.

Under the Hood: How Priorities Map to Network Protocols

This "expert-level" section details how the browser's internal priority (e.g., "High") is actually communicated to the server. The internal name is just a hint; it must be translated to the network protocol in use.

HTTP/2: Weights, Dependencies, and Divergence

HTTP/2 introduced multiplexed streams, allowing multiple resources to be downloaded over a single TCP connection. To manage this, it introduced a prioritization system based on weights and dependencies. However, there is no standardized mapping from the browser's "High" priority to these values. As a result, browser implementations are chaotic:

  • Chrome creates long dependency chains.
  • Safari uses only weights to differentiate priority.
  • Firefox builds a complex dependency tree.

This means a fetchpriority hint will be translated differently and have a different on-the-wire effect depending on the user's browser, even on the same HTTP/2 protocol.

HTTP/3: The Extensible Prioritization Scheme (RFC 9218)

HTTP/3 (which runs on QUIC) abandons HTTP/2's complex model for a simpler one. It uses a new Priority HTTP header. This new scheme has two main parameters:

  • u (Urgency): A value from 0 (Highest) to 7 (Lowest). This is the primary priority level.
  • i (Incremental): A boolean (?1) indicating if the resource can be loaded incrementally (like a progressive JPEG).

The "Lost in Translation" Problem: Mapping to HTTP/3

This new standard should have unified browser behavior, but it has not. Browsers still take their 5-level internal priority scale and map it to the new 8-level (0-7) urgency value differently.

  • Chrome (Blink): Maps its 5 internal levels to the first 5 urgency values: u=0, 1, 2, 3, 4.
  • Safari (WebKit): Spreads its 5 levels out across the full range: u=0, 1, 3, 5, 7.
  • Firefox (Gecko): Uses only 4 levels and maps them to u=1, 2, 3, 4 (it skips u=0 entirely).

This divergence proves that resource prioritization remains an implementation-defined heuristic, not a precise, cross-browser command. A developer's hint is translated differently by each browser, which may in turn be interpreted differently by the server.

The Final Strategy: A Holistic Prioritization Model

Resource prioritization is not a single action but a holistic process. By synthesizing all of the above techniques, a complete strategic model emerges.

Step 1: Audit and Identify Your Critical Path

Use tools like WebPageTest or Lighthouse to identify your render-blocking chain and LCP element. Understand your baseline default priorities by inspecting the Network tab.

Step 2: Fix Discovery Delays

Is your LCP a background-image or is your custom font loading late? Use <link rel="preload"> to make these "late-discovered" resources visible to the browser immediately. Eliminate all CSS @import rules and refactor them to <link> tags.

Step 3: Optimize Heuristics (Work with the Browser)

Don't fight the browser. If your LCP is a background-image, the best solution may be to refactor the HTML to use an <img> tag. This makes it natively discoverable by the preload scanner, requiring no "tricks." Move non-critical, parser-blocking JavaScript out of the <head> and apply async or defer.

Step 4: Apply Manual 'fetchpriority' Hints

Now that the browser can see all your resources, apply relative hints. Prioritize: Add fetchpriority="high" to your LCP <img> tag and to any critical async script (e.g., hydration). De-prioritize: Add fetchpriority="low" to above-the-fold non-critical images (e.g., carousel items) and non-critical background fetch() calls.

Step 5: De-prioritize the Non-Critical

Aggressively de-prioritize everything that is not needed for the initial render. Apply loading="lazy" to all below-the-fold <img> and <iframe> elements. Use video preload="metadata" or preload="none" for all non-autoplay videos.

Step 6: Optimize Connections and Future Navigations

Finally, look beyond the current page load. Use rel="preconnect" for your 1-2 most critical third-party domains (e.g., font provider, API). Use rel="prefetch" for the one key resource needed on the next logical step of the user journey. Replace navigator.sendBeacon with fetch({ keepalive: true }) for high-priority, reliable analytics or session-end data.

By managing discovery, execution, and fetching through this combination of browser heuristics, HTML semantics, and explicit network-level hints, you can gain full control over your application's loading performance.

Need your site lightning fast?

Join 500+ sites that now load faster and excel in Core Web Vitals.

Let's make it happen >>

  • Fast on 1 or 2 sprints.
  • 17+ years experience & over 500 fast sites
  • Get fast and stay fast!
A Core Web Vitals Guide to Resource PrioritizationCore Web Vitals A Core Web Vitals Guide to Resource Prioritization