Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 12 / 18 35 MIN

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.

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.

CallAttribute in the tagPrinted in
No fifth argumentnonehead, blocking
true as the fifth argumentnonefooter, blocking
array( 'in_footer' => true )nonefooter, blocking
array( 'strategy' => 'defer' )deferhead
wp_script_add_data( $handle, 'strategy', 'defer' )deferhead
Deferred handle whose only dependent is in the footerdeferfooter, moved
array( 'in_footer' => false, 'delay' => 3000 )none, plus a noticehead, 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.

FileRawgzipbrotli
Manrope variable woff253,60053,58653,604
Fira Code variable woff2106,112106,125106,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.

PageExternal JSInline JSJS totalTransfer, 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 lineContent page, measuredCheckout page, targetWhy 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.

Lab 40 minutes

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.