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 ofdefer
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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 142.6 | 141.7 | -0.9 | -0.6% |
LCP | 409.4 | 382.4 | -27.0 | -6.6% |
TTFB | 34.7 | 35.3 | +0.7 | +1.9% |
LCP-TTFB | 374.2 | 347.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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 1275.3 | 1275.4 | +0.1 | +0.0% |
LCP | 3377.1 | 3157.1 | -220.0 | -6.5% |
TTFB | 35.0 | 34.6 | -0.4 | -1.0% |
LCP-TTFB | 3341.1 | 3123.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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 140.7 | 142.6 | +1.9 | +1.4% |
LCP | 399.0 | 368.9 | -30.1 | -7.5% |
TTFB | 33.4 | 33.0 | -0.4 | -1.2% |
LCP-TTFB | 365.1 | 335.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.
Printing Script Modules in the Footer
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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 141.8 | 143.7 | +1.9 | +1.3% |
LCP | 410.8 | 383.1 | -27.8 | -6.8% |
TTFB | 34.4 | 34.4 | -0.1 | -0.1% |
LCP-TTFB | 376.7 | 348.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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 142.0 | 144.1 | +2.1 | +1.5% |
LCP | 377.5 | 374.7 | -2.8 | -0.7% |
TTFB | 34.7 | 35.1 | +0.4 | +1.2% |
LCP-TTFB | 342.9 | 338.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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 142.4 | 143.2 | +0.8 | +0.6% |
LCP | 383.9 | 372.7 | -11.2 | -2.9% |
TTFB | 35.0 | 35.2 | +0.2 | +0.6% |
LCP-TTFB | 348.1 | 337.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 thehead
gets a medium/normal priority in Safari/Firefox. - A
defer
script in thehead
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:
- In
head
with default fetch priority. (This is the current behavior in WordPress core.) - In
head
withfetchpriority=low
. - At end of
body
with default fetch priority. - At end of
body
withfetchpriority=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)
Metric | head | head + low | body | body + low |
---|---|---|---|---|
FCP | 137.9 | 143.0 | 140.7 | 138.8 |
LCP | 405.9 | 383.7 | 376.8 | 370.0 |
TTFB | 33.7 | 34.5 | 33.6 | 33.7 |
LCP-TTFB | 372.2 | 349.3 | 343.0 | 336.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)
Metric | Before | After | Diff (ms) | Diff (%) |
---|---|---|---|---|
FCP | 137.0 | 137.2 | +0.2 | +0.1% |
LCP | 406.0 | 368.8 | -37.2 | -9.2% |
TTFB | 33.7 | 33.6 | -0.1 | -0.1% |
LCP-TTFB | 371.7 | 336.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?
- 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. ↩︎
- 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’shidden
state fromtrue
tofalse
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. ↩︎
Leave a Reply