Improve LCP by Deprioritizing Script Modules from the Interactivity API

Illustration of a bison fetching a script module with low priority in order to run faster. The bison is in a data center looking at a stack of blocks which have low priority on the side.
Generated via Google’s Imagen 3 in ImageFX with the prompt: “A bison fetching a script module with low priority in order to run faster.”

Adding a fetchpriority of low to script modules and moving them from the head to the footer can improve LCP by >9%! I’ve written plugins you can use to implement this now while waiting for them to land in WordPress core.

The past week I’ve been doing a deep dive into the performance impact of how WordPress loads script modules for the Interactivity API. When the Interactivity API was first introduced, it used classic non-module scripts which were printed at the end of the body (i.e. in the footer, at wp_footer) so that they would not block the critical rendering path. With support for script loading strategies in core, I added defer to these scripts and moved them to the head (i.e. at wp_head) in block themes, reasoning:

Leaving them in the head is advantageous because it means the browser will discover these scripts earlier and start loading them with other page resources, but the presence of defer means that they will no longer block page rendering.

Ultimately, when the Interactivity API was fully launched in WordPress 6.5, it had switched to using script modules. One great thing about script modules is that they don’t block rendering: they have the defer behavior by default. This means that they are safe to place in the head, and they continue to be printed there in block themes. (In classic themes, the block scripts are printed in the footer since blocks aren’t parsed before wp_head runs.) However, as I’ve been researching the past week, there can still be negative implications to the LCP metric when these script modules are discovered early, even though they don’t block rendering.

I set up a vanilla test site with Local WP and the Twenty Twenty-Five default theme active. I configured the theme template and post content so that all block configurations which depend on the Interactivity API are present:

  • The theme template has a Navigation block with the mobile overlay menu enabled (which is the default).
  • A Search block is added to the header with the “Button only” configuration which depends on the Interactivity API.
  • The post content starts with an Image block whose IMG is the LCP element. It is configured to “Enlarge on click”.1 This uses a photo of a Bison of course. 🦬
  • After a few paragraphs of Lorem Ipsum, I added a File block for a PDF with “Show inline embed” enabled.
  • The “More Posts” section of the template has a Query Loop which has its “Reload full page” advanced setting turned off.

In the end, this results in the following markup being printed in the head (from the full page source with some prettying):

<script type="importmap" id="wp-importmap">
{
	"imports":{
		"@wordpress/interactivity": "/wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=55aebb6e0a16726baffb",
		"@wordpress/interactivity-router": "/wp-includes/js/dist/script-modules/interactivity-router/index.min.js?ver=dc4a227f142d2e68ef83",
		"@wordpress/a11y": "/wp-includes/js/dist/script-modules/a11y/index.min.js?ver=b7d06936b8bc23cff2ad"
	}
}
</script>
<script type="module" src="/wp-includes/js/dist/script-modules/block-library/file/view.min.js?ver=fdc2f6842e015af83140" id="@wordpress/block-library/file/view-js-module"></script>
<script type="module" src="/wp-includes/js/dist/script-modules/block-library/image/view.min.js?ver=e38a2f910342023b9d19" id="@wordpress/block-library/image/view-js-module"></script>
<script type="module" src="/wp-includes/js/dist/script-modules/block-library/navigation/view.min.js?ver=61572d447d60c0aa5240" id="@wordpress/block-library/navigation/view-js-module"></script>
<script type="module" src="/wp-includes/js/dist/script-modules/block-library/query/view.min.js?ver=f55e93a1ad4806e91785" id="@wordpress/block-library/query/view-js-module"></script>
<script type="module" src="/wp-includes/js/dist/script-modules/block-library/search/view.min.js?ver=208bf143e4074549fa89" id="@wordpress/block-library/search/view-js-module"></script>
<link rel="modulepreload" href="/wp-includes/js/dist/script-modules/interactivity/index.min.js?ver=55aebb6e0a16726baffb" id="@wordpress/interactivity-js-modulepreload">Code language: HTML, XML (xml)

When loading the page in Chrome, here’s the network panel in DevTools:

Notice how the script modules are being loaded with a high priority and before the all-important Bison image resource is loaded for the LCP element. This is bad. Here’s a view of the waterfall in the Performance panel, where you can see the script modules indeed start loading before the Bison image:

In my tests, both Chrome and Safari set a default fetch priority of high for module scripts and modulepreload links. In Firefox, the default fetch priority for a modulepreload link is highest, while the script modules are loaded with normal fetch priority. In all these cases, the priorities are incorrect. They should all have a fetch priority of low because they are not in the critical rendering path. This is because the very first requirement/goal defined for the Interactivity API was for server-side rendering:

It must support server-side rendering. Server-rendered HTML and client-hydrated HTML must be exactly the same. This is important for SEO and the user experience.

Since blocks using the Interactivity API are intended to leverage server-side rendering, the script modules for these blocks2 by definition should not be prioritized over loading other resources which are in the critical rendering path, especially any image resource for the LCP element.

I wanted to find out what the LCP performance impact would be if these script modules had their priorities changed to low from the default of high. The link and script tags both support the fetchpriority attribute, same as the img tag does. While WordPress now facilitates adding fetchpriority to img tags, it doesn’t do the same for registered scripts or script modules. This is what #61734 is about, and it is why I’m analyzing the performance impact. In the same way that WordPress facilitates adding async and defer to scripts, and does so by default for some scripts, there should perhaps be a way to declare the fetchpriority for a script or script module.

Performance of Low Priority Script Modules

In order to benchmark the performance impact, I developed the Script Fetch Priority Low plugin which automatically adds fetchpriority=low to the module scripts used by the Interactivity API, as well as any modulepreload links which are printed for static import dependencies. If a page is loaded with a specific query parameter, then the plugin short-circuits; this allows for doing before/after benchmarks.

With this plugin active, this is the change to the network panel in Chrome DevTools:

Notice how the script modules are now being loaded with a low priority and after the Bison image. This can also be seen in the Performance panel waterfall:

This looks much better. But what is the performance impact in terms of LCP? To analyze that I used the benchmark-web-vitals tool to obtain the median web vitals metrics for 100 requests with and without the reduction in fetch priority:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="broadband" --diff \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer&disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP142.6141.7-0.9-0.6%
LCP409.4382.4-27.0-6.6%
TTFB34.735.3+0.7+1.9%
LCP-TTFB374.2347.0-27.2-7.3%

This is a big improvement! (Each subsequent benchmark test shows the median metrics of 100 iterations, unless otherwise noted.)

The above results were testing while emulating a broadband network connection. Here are the results testing a “Fast 3G” connection:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="Fast 3G" --diff \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer&disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP1275.31275.4+0.1+0.0%
LCP3377.13157.1-220.0-6.5%
TTFB35.034.6-0.4-1.0%
LCP-TTFB3341.13123.0-218.1-6.5%

So there is roughly the same improvement regardless of the network conditions.

I was also curious what the results would be when there was just a single block on the page using the Interactivity API, instead of attempting to add every single interactive block. This is the normal case for all block themes since the Navigation block is almost always present with the mobile overlay menu enabled. So I tested on a template with the Navigation block and a featured image as the LCP element, this time again emulating a broadband network connection:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="broadband" --diff \
  --url "http://localhost:10008/bison-no-file-block/?disable_print_script_modules_in_footer&disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison-no-file-block/?disable_print_script_modules_in_footer"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP140.7142.6+1.9+1.4%
LCP399.0368.9-30.1-7.5%
TTFB33.433.0-0.4-1.2%
LCP-TTFB365.1335.8-29.3-8.0%

So even when there is just a single script module along with the modulepreload link, the performance improvement to LCP is consistent.

As referred to above, the classic method WordPress has employed to deprioritize scripts is to load them in the footer by supplying in_footer as true when registering a script. What if instead of adding a fetchpriority of low to script modules, they were instead just printed in the footer for block themes in the same way they are already printed in the footer for classic themes? I created the Script Modules in Footer plugin to implement this and to facilitate benchmarking. Here are the results:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="broadband" --diff \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer&disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/?disable_script_fetchpriority_low"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP141.8143.7+1.9+1.3%
LCP410.8383.1-27.8-6.8%
TTFB34.434.4-0.1-0.1%
LCP-TTFB376.7348.4-28.4-7.5%

This shows almost the same improvement as adding fetchpriority of low. However, even when they are located at the end of the body, they are still loaded with a high fetch priority. When script modules are printed in the footer, adding fetchpriority as well yields an additional ~1% improvement to LCP on my test page:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="broadband" --diff \
  --url "http://localhost:10008/bison/?disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP142.0144.1+2.1+1.5%
LCP377.5374.7-2.8-0.7%
TTFB34.735.1+0.4+1.2%
LCP-TTFB342.9338.9-4.0-1.2%

If script modules are all printed with fetchpriority=low, here’s the difference when moving them from wp_head to wp_footer:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="broadband" --diff \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer" \
  --url "http://localhost:10008/bison/"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP142.4143.2+0.8+0.6%
LCP383.9372.7-11.2-2.9%
TTFB35.035.2+0.2+0.6%
LCP-TTFB348.1337.8-10.3-3.0%

So again, there’s an improvement but not as large as before/after adding fetchpriority=low or printing in the head versus the footer.

Bonus: Deprioritizing Classic Scripts

While classic scripts registered in WordPress already have the ability to be printed in the footer, they can’t be registered with a specific fetchpriority value. It doesn’t make a lot of sense to set a fetch priority for blocking classic scripts since they should always be loaded with the highest priority since they block rendering. However, what about a script that is using the defer or async loading strategies? Chrome automatically assigns such scripts as having a low priority, regardless of whether they are printed at the head or the footer. However, this is not the case for other browsers:

  • An async script in the head gets a medium/normal priority in Safari/Firefox.
  • A defer script in the head gets a high priority in Safari and a normal priority in Firefox.
  • An async script in the footer gets a medium/normal priority in Safari/Firefox.
  • A defer script in the footer gets a high priority in Safari and a normal priority in Firefox.

So for the sake of non-Chromium browsers, it absolutely makes sense to be able to register classic scripts with a fetch priority. For example, the comment-reply script is registered as async and is printed in the footer. Explicitly marking this script as having a low fetch priority ensures that loading it will not compete with more critical resources in Firefox and Safari. My Script Fetch Priority Low plugin also implements this.

As an extra bonus, check out my Site Kit GTag Script Deprioritization and Jetpack Stats Script Deprioritization plugins which optimize the loading of analytics trackers by adding fetchpriority=low to the script tags, removing the dns-prefetch, and ensuring the external script tags are printed in the footer.

Conclusion

There are essentially for different configurations for printing script modules on the page:

  1. In head with default fetch priority. (This is the current behavior in WordPress core.)
  2. In head with fetchpriority=low.
  3. At end of body with default fetch priority.
  4. At end of body with fetchpriority=low.

Here are the median metrics for benchmarking 1,000 iterations for each of the four configurations:

Command
npm run research -- benchmark-web-vitals --number=1000 --output="md" --network-conditions="broadband" \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer&disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer" \
  --url "http://localhost:10008/bison/?disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/"Code language: Bash (bash)
Metricheadhead + lowbodybody + low
FCP137.9143.0140.7138.8
LCP405.9383.7376.8370.0
TTFB33.734.533.633.7
LCP-TTFB372.2349.3343.0336.3

When script modules are printed in the footer and they have fetchpriority=low, the result is a >9% improvement to LCP on my test page:

Command
npm run research -- benchmark-web-vitals --number=100 --output="md" --network-conditions="broadband" --diff \
  --url "http://localhost:10008/bison/?disable_print_script_modules_in_footer&disable_script_fetchpriority_low" \
  --url "http://localhost:10008/bison/"Code language: Bash (bash)
MetricBeforeAfterDiff (ms)Diff (%)
FCP137.0137.2+0.2+0.1%
LCP406.0368.8-37.2-9.2%
TTFB33.733.6-0.1-0.1%
LCP-TTFB371.7336.0-35.7-9.6%

Of course your mileage will vary. This will primarily benefit block themes. A vanilla WordPress install with the stock theme is very lightweight and a typical site will have a lot more going on which will lessen the impact of these optimizations. But still, with these findings I’ve been working on a pull request for #61734 to implement fetchpriority support for scripts and script modules in WordPress core; it defaults the fetch priority to low for script modules related to the Interactivity API as well as the comment-reply classic script. This feature is a natural progression to follow script loading strategies (async & defer); in fact, we could consider defaulting scripts to add fetchpriority=low if they use the a delayed loading strategy.

I’ve filed #63486 to implement support for printing script modules in the footer. This work can follow the fetchpriority support as it will be more involved due to the need to account for module dependencies.

While waiting for these performance enhancements to land in core, you can install the Script Fetch Priority Low and Script Modules in Footer plugins. Let me know if you measure any LCP improvements!


Where I’ve shared this:

Shameless plug: I found out last month that my 6½-year position at Google was eliminated. I was hired to work on WordPress full time, and I’ve been contributing to WordPress as a core committer for over 10 years. Most recently I’ve worked heavily on the Core Performance team. I’m currently #opentowork, hoping to find a full time position as a sponsored contributor at another company that also cares about the health of the open web. Alternatively, I’m exploring the feasibility of being sponsored as an independent contributor. If you find my open source work valuable, maybe you can help sustain my contributions?

  1. I used an Image block as opposed to setting the featured image so that I could use the “Enlarge on click” setting which involves the interactivity, but see also my post about how the Featured Image block can also be extended with a lightbox. ↩︎
  2. The one exception here is the File block when a PDF is selected and the “Show inline embed” setting is enabled. In this case, JavaScript runs when the block initializes to populate the hasPdfPreview state, and this then changes the element’s hidden state from true to false if the browser supports rendering PDFs. This is an exceptional case, however, and it would be rare for a File block to appear in the initial viewport and be the LCP element. The script module for the File block should only have a high fetch priority if (1) a PDF is selected, (2) the “Show inline embed” setting is enabled, and (3) the block appears in the initial viewport (on desktop or mobile). This is something that Optimization Detective could facilitate. ↩︎

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *