Front-end performance
Fonts, CSS and JavaScript Budgets as Numbers
A budget only counts if something enforces it. How WordPress 7.0 delivers CSS and JavaScript, and what belongs in a budget table for two page types.
Ask a team what their JavaScript budget is and you get a number somebody read in an article. Ask where it is enforced and the conversation changes. A budget nothing checks is a preference, and preferences lose to a deadline.
Three budgets are cheap to check here because WordPress already knows the answer: how much CSS the document carries, how much script the page loads and when it runs, and how many bytes of font the page asks for. All three come out of one request with no profiler.
The numbers come from a WordPress 7.0.7 install on PHP 8.5.6 running Twenty Twenty-Five, measured with the built-in server. I did not install WooCommerce, so the checkout column later is a budget rather than a measurement.
A budget is a number with an owner
Three properties make a budget enforceable. It has a unit you can measure in one request. It names the page it applies to, because a content page and a checkout page do not have the same job. And it has a mechanical check, which can be as crude as a deploy script line that fails when the gzipped JavaScript on a URL goes over the number.
Most sites are in the other state. Nobody knows the page weight, so nobody notices it doubling one justified plugin at a time.
Measuring the page is not the same as measuring the experience, though. A page can be under every byte budget and still fail INP because one small script runs a long synchronous loop on load.
WordPress 7.0 inlines most of a block theme's CSS into the document
On the baseline content page there were zero stylesheet link elements. Every
byte of CSS, 53,250 of them, arrived inside style tags in the head,
in a document of 68,699 bytes raw and 11,787 gzipped.
That is deliberate, and two mechanisms produce it. A block theme gets
should_load_separate_core_block_assets and
should_load_block_assets_on_demand turned on automatically by
_add_default_theme_supports(), so only the styles for blocks the page actually
renders are enqueued. Then wp_maybe_inline_styles(), hooked to
wp_head and wp_footer at priority 1, inlines those stylesheets
when the total stays under the styles_inline_size_limit filter. The default
limit was raised from 20,000 to 40,000 bytes in WordPress 6.9.
Two consequences follow for how you count. A tool that sums link and
script tags reports a CSS weight of zero on a block theme, so measure the
document size and the inline style bytes as well. And inlining is a trade
it also has a cost: it removes a round trip on a cold load and makes the HTML
uncacheable separately, so two pages sharing a stylesheet each carry a copy. With per-block
styles the trade usually goes the right way, which is why core makes it.
in_footer, defer and async decide when your code runs
Byte count is half of a script budget. The other half is when the browser may execute the
code, which WordPress exposes through the fifth argument of wp_enqueue_script().
I registered one small file seven ways and recorded where each tag landed.
| Call | Attribute in the tag | Printed in |
|---|---|---|
| No fifth argument | none | head, blocking |
true as the fifth argument | none | footer, blocking |
array( 'in_footer' => true ) | none | footer, blocking |
array( 'strategy' => 'defer' ) | defer | head |
wp_script_add_data( $handle, 'strategy', 'defer' ) | defer | head |
| Deferred handle whose only dependent is in the footer | defer | footer, moved |
array( 'in_footer' => false, 'delay' => 3000 ) | none, plus a notice | head, blocking |
Three rows carry the lesson. The default is a blocking script in the head, so the parser
stops to fetch and run it, which makes a plugin that passes no fifth argument complicit in
the most expensive placement available. defer keeps the script in the head but
runs it after parsing, and WordPress moves a deferred script to the footer when every script
depending on it is already there. And the last row was rejected: the only keys the argument
array accepts in 7.0 are strategy, in_footer,
fetchpriority and module_dependencies. Anything else produces a
_doing_it_wrong() notice naming the offending key and the supported ones.
None of this changes the bytes. It changes whether the bundle blocks the parser or runs after it.
Set a strategy on a handle you did not register
You usually cannot edit the plugin that enqueued the bundle, so
wp_script_add_data() is the lever. It sets a data key on an already registered
handle, and the plugin's own registration keeps working.
add_action( 'wp_enqueue_scripts', function () {
// Defer one handle and move another to the footer without touching the plugin.
if ( wp_script_is( 'm4-page-widgets', 'registered' ) ) {
wp_script_add_data( 'm4-page-widgets', 'strategy', 'defer' );
}
if ( wp_script_is( 'm4-legacy-slider', 'registered' ) ) {
wp_script_add_data( 'm4-legacy-slider', 'group', 1 );
}
}, 20 );
Two details make this work. The priority matters, because the handle has to exist before
you add data to it, so anything after the plugin's own
wp_enqueue_scripts callback is fine and the default priority of 10 is often too
early. And wp_script_is( $handle, 'registered' ) is the guard, because
wp_script_add_data() returns false silently for an unknown handle.
The strategy key accepts defer and async and
nothing else, and it is validated: an invalid value produces a notice instead of a broken
tag. A deferred script runs in document order after parsing, so dependencies behave as
expected. An async script runs when it arrives, in whatever order that turns out to be,
which suits analytics that share nothing and breaks anything a second script waits on.
Before deferring everything, remember that a script cannot be delayed past one that depends on it. WordPress handles the straightforward cases by moving a deferred handle to the footer when its dependents are there, but a plugin that builds its own ordering with inline scripts after the tag can run that inline code first.
Fonts are already compressed, so the lever is which glyphs you ship
Webfont files are the one asset class where transport compression does nothing. I gzipped and brotli-compressed the two files the lab page loads, and the sizes did not move.
| File | Raw | gzip | brotli |
|---|---|---|---|
| Manrope variable woff2 | 53,600 | 53,586 | 53,604 |
| Fira Code variable woff2 | 106,112 | 106,125 | 106,117 |
A woff2 file is already a brotli stream inside a container, so a CDN cannot compress it and the only lever left is how many glyphs are in it. Two questions are worth asking of every face: does this page render text in it, and does it need the full character set.
Both answers are usually cheap to act on. A variable font covers a weight range in one file, so twenty static weights become one. Subsetting to the languages you serve can cut a file dramatically, and it is a build step rather than a runtime one. A monospace face that styles code blocks is a candidate for loading only on pages that contain code.
Two things matter about how WordPress delivers fonts. Faces declared in
theme.json render to @font-face rules with
font-display: fallback unless you set fontDisplay on the face, and
that value decides whether text stays invisible while the font loads, swaps after it
arrives, or is never swapped in. And the theme's font directory is not the page's font
weight: Twenty Twenty-Five ships 44 woff2 files totalling 5,886,324 bytes, while the page I
measured requested two. The rest exist for style variations nobody on that page asked for,
costing nothing at runtime and a lot in a confused audit.
Finding the plugin that shipped the 300 KB bundle
I built a plugin that enqueues a 300 KiB bundle with no fifth argument to
wp_enqueue_script(), which is what a lot of real plugins do. Here is what it
cost.
| Page | External JS | Inline JS | JS total | Transfer, gzipped |
|---|---|---|---|---|
| Content page, no third-party plugins | 2 files, 43,159 raw / 16,324 gzipped | 3,453 | 46,612 | 28,111 |
| Same page plus the bundle and one deferred script | 4 files, 350,448 raw / 90,060 gzipped | 3,453 | 353,901 | 101,918 |
The bundle is 307,191 bytes raw and 73,628 gzipped, it landed in the head
with no defer, and it multiplied the page's JavaScript by seven. The plugin
author sees a passing build and a green dashboard, because nothing about enqueuing a script
is a warning.
Finding it takes one command, and the answer is a handle and a file path. With WP-CLI, fire the front-end enqueue action and read the queue:
wp eval '
do_action( "wp_enqueue_scripts" );
foreach ( wp_scripts()->queue as $handle ) {
$s = wp_scripts()->registered[ $handle ] ?? null;
if ( ! $s || ! $s->src ) { continue; }
$path = str_replace( home_url(), ABSPATH, strtok( $s->src, "?" ) );
printf( "%9s %-16s %-9s %s\n",
file_exists( $path ) ? number_format( filesize( $path ) ) : "remote",
$handle,
wp_scripts()->get_data( $handle, "strategy" ) ?: "blocking",
str_replace( ABSPATH, "", $path )
);
}'
On the lab install that printed the handle, the strategy and the owning directory, which is enough to name the plugin in a ticket. Without WP-CLI, or when you want the real document's view rather than a simulated enqueue, read the rendered HTML instead:
#!/usr/bin/env bash
# Every JS and CSS asset on one page, in bytes, with the plugin or theme that owns it.
set -uo pipefail
URL="${1:?usage: assets.sh URL DOCROOT}"
ROOT="${2:?usage: assets.sh URL DOCROOT}"
BASE="${URL%/}"
curl -s "$URL" \
| grep -oE '(src|href)="[^"]+\.(js|css)(\?[^"]*)?"' \
| sed -E 's/^(src|href)="//; s/"$//; s/\?.*$//' \
| sort -u \
| while read -r asset; do
file="$ROOT/${asset#"$BASE"/}"
case "$file" in
*wp-content/plugins/*) owner="plugin:$(echo "${file#*wp-content/plugins/}" | cut -d/ -f1)" ;;
*wp-content/themes/*) owner="theme:$(echo "${file#*wp-content/themes/}" | cut -d/ -f1)" ;;
*wp-includes/*) owner=core ;;
*) owner=other ;;
esac
if [ -f "$file" ]; then
printf '%10d %-32s %s\n' "$(wc -c < "$file" | tr -d ' ')" "$owner" "$asset"
else
printf '%10s %-32s %s\n' remote "$owner" "$asset"
fi
done
One limitation is worth stating, because it has misled me. A script a plugin enqueues conditionally, or one a tag manager injects at runtime, is not in the HTML you fetched. The command answers "what did this page load", not "what does this plugin ship".
A budget table for a content page and a checkout page
The two pages have different jobs, so they get different rows. The content page is judged by field data against the Core Web Vitals bands, so its budget has to land inside the good band with margin for a bad device. The checkout page is where a slow interaction costs money directly, so its interactivity budget is tighter even though it is allowed to carry more script, because payment and address tooling is not optional.
| Budget line | Content page, measured | Checkout page, target | Why they differ |
|---|---|---|---|
| HTML document, gzipped | 11,787 bytes | 25,000 bytes | Checkout templates and form markup are larger, and the document is not shared. |
| CSS, raw | 53,250 bytes, all inline | 100,000 bytes | Blocks plus store styles; still inline under the 40 KB per-file limit. |
| CSS, gzipped | under 8,000 bytes in the document | 15,000 bytes | CSS compresses well, so the gzipped number is the one to enforce. |
| JavaScript, raw | 46,612 bytes | 300,000 bytes | Payment, address and validation libraries are necessary on checkout. |
| JavaScript, gzipped | 16,324 bytes external | 100,000 bytes | The enforceable number, because it is what the phone downloads. |
Blocking scripts in head |
0 | 0 | No placement argument justifies a blocking script on either page. |
| Font files and bytes | 2 files, 159,712 bytes | 2 files, 159,712 bytes | Same faces, and the count is what to check rather than the format. |
| Faces declared but unused | 0 of 2 | 0 of 2 | A declared face that no element renders is pure waste. |
| Third-party requests | 0 | 4 | Analytics, a tag manager, an address lookup and one payment frame. |
| Main-thread tasks over 50 ms | 0 | 2 | Allowed only if both finish before the form is interactive. |
| LCP p75 | 2.0 s target | 2.5 s target | The content page is scored; checkout traffic is mostly warm. |
| INP p75 | 150 ms target | 120 ms target | A delayed tap in a checkout costs an order, not a ranking position. |
| CLS p75 | 0.05 target | 0.02 target | A button that moves under a finger produces a mis-tap on a payment form. |
Read the third column as decisions, not readings. Every number in it is a line you choose and then wire to a check, and the measured column tells you the starting distance.
To show what an interaction budget means in code, I measured a loop of 400,000 floating-point operations, the shape of a plugin that processes a list synchronously on load. In Node 24.11.1 on this machine it took 66 milliseconds at the median, already past the 50 millisecond definition of a long task and a third of the INP budget on a fast desktop CPU. A phone does not improve that number.
Produce a budget table for one real site, with a measured column and a target column, and one owner named per row.
Measure the document size raw and gzipped, the inline CSS bytes and the inline script bytes, every external asset with its owner, and the font files the page requested. Then run the asset attribution command and record the two largest plugin-owned files.
For each of the two largest, decide between three actions and write down which you
chose and why: defer it, remove it, or leave it blocking with a reason. If you chose
defer, add the wp_script_add_data() call and prove from the rendered HTML
that the attribute appeared.
Finish with the two comparisons that matter. How many bytes of JavaScript does the page carry compared with its budget, and how many of the declared font faces does the page actually render text in?
Show the solution and the reasoning
The table is more useful than the totals, because a total cannot be assigned to anyone. A row reading "JavaScript, gzipped, 90 KB of 100 KB, owner: the plugin list" starts an argument you can finish.
On the two largest files, the decision usually comes down to whether the script is
needed before the first paint. Nothing on a content page is, so defer is
almost always correct there and the only question is whether the plugin's own inline
script expects to run after the file. On checkout the answer can be different: a
validation library that has to be ready before the first keystroke is a legitimate
blocking case, though putting it in the footer is usually a better answer than leaving it
in the head.
Removal is worth trying before deferral, because deferring a bundle nothing needs makes an unmeasured page slightly faster and a measured one no smaller. Disable the plugin on staging and see whether a visible feature breaks. If the answer is a widget nobody has used since the redesign, the budget grows by the whole file rather than by the delay.
Two limits apply. Byte budgets cannot see a script that is small and slow, so a page under budget can still fail INP, which is what field data is for. And the font check only tells you what the page requested: a face that renders one glyph is still a full download, so read the network panel as well as the markup.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.