Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 17 / 18 35 MIN

Shipping a theme

Performance and Asset Budgets a Theme Can Hold

Measure what a block theme actually loads on a post, an archive and a page with no theme blocks, then hold the theme to a budget for CSS, JavaScript and fonts.

A block theme is easy to make slow without noticing, because most of what it ships is not in the theme folder. WordPress prints the block CSS itself, inlines what it decides is small enough, and loads the Interactivity API runtime on any page that uses a block built on it. A theme can look lean, ship two small files, and still add forty kilobytes of stylesheet to a page that contains one paragraph.

An asset budget is the fix. It is a number you write down before you start cutting, so that "the theme feels heavy" becomes "the theme is twelve kilobytes over on an archive". This lesson measures a real block theme on WordPress 7.0.7 first, one of mine with a global stylesheet and three per-block stylesheets, and then converts the measurements into a budget you can hold a theme to.

A content page is mostly CSS, and most of it is core's

I built a page containing one paragraph, loaded it with a cold cache and counted every byte that crossed the network: the document, every linked stylesheet, every script, and the module preload. This is the page that uses none of my theme's own blocks:

RequestBytesWhat it is
Document50,921HTML plus 39,862 bytes of inlined CSS
Theme stylesheet83,971style.css, which is over budget on purpose
Script3,053navigation block view module
Module preload40,106the Interactivity API runtime
Total178,051

The inlined CSS is worth reading line by line, because it tells you what the theme is actually responsible for:

20,794  wp-block-navigation-inline-css
12,192  global-styles-inline-css
 3,647  wp-block-library-inline-css
   739  wp-block-paragraph-inline-css
   539  wp-block-template-skip-link-inline-css
   488  wp-block-page-list-inline-css
   346  wp-block-post-title-inline-css
   340  wp-emoji-styles-inline-css
   317  wp-block-site-title-inline-css
   197  wp-block-group-inline-css
   135  wp-img-auto-sizes-contain-inline-css
   128  wp-block-post-content-inline-css
------------------------------------------------------------------
39,862  total

Two things follow from those numbers. The navigation block alone is 20.8 kilobytes of CSS, more than half the inlined total, and it is on every page because the header is. Global styles, which is where theme.json ends up, is 12.2 kilobytes. Everything else is small because block assets load on demand: a block that is not rendered does not contribute its stylesheet. That is the whole reason a block theme can be fast at all, and it is worth knowing how it is enabled.

Block themes set on-demand loading for you

Two functions decide how core block assets reach the front end, and wp_should_load_separate_core_block_assets() is the one you will find referenced in older material. In 7.0.7 it returns false on the front end by default, then true for any block theme, because block theme support adds the filters itself. The check that matters today is the second one, wp_should_load_block_assets_on_demand(), added in 6.8 and documented as a separate concern:

function wp_should_load_block_assets_on_demand() {
	if ( is_admin() || is_feed() || wp_is_rest_endpoint() ) {
		return false;
	}

	// For backward compatibility, the default is based on the return value of
	// wp_should_load_separate_core_block_assets().
	$load_assets_on_demand = wp_should_load_separate_core_block_assets();

	return apply_filters( 'should_load_block_assets_on_demand', $load_assets_on_demand );
}

Separate assets means each core block has its own stylesheet registered instead of one combined wp-block-library file. On demand means a block's stylesheet is only enqueued when the block renders. They are related by default and separately filterable, which is why a theme that wants the old behaviour of shipping everything can filter either one and get a much larger page. If your theme has a should_load_separate_core_block_assets filter returning false in it, that is one line doing more damage than anything else in the folder.

wp_enqueue_block_style ties a stylesheet to a rendered block

Your own block stylesheets can participate in the same mechanism. wp_enqueue_block_style() takes a block name and the same arguments as wp_register_style(), and when on-demand loading is on it attaches to the render_block filter instead of to wp_enqueue_scripts:

function m6verify_block_styles() {
	$styles = array(
		'core/quote'   => 'quote.css',
		'core/details' => 'details.css',
		'core/table'   => 'table.css',
	);

	foreach ( $styles as $block_name => $file ) {
		wp_enqueue_block_style(
			$block_name,
			array(
				'handle' => 'm6verify-' . str_replace( '/', '-', $block_name ),
				'src'    => get_theme_file_uri( 'assets/css/' . $file ),
				'path'   => get_theme_file_path( 'assets/css/' . $file ),
				'ver'    => wp_get_theme()->get( 'Version' ),
			)
		);
	}
}
add_action( 'init', 'm6verify_block_styles' );

Register the callback on init, not on wp_enqueue_scripts. If wp_enqueue_scripts has already fired when you call this function, it attaches the callback to wp_footer instead, and the on-demand behaviour is gone. Twenty Twenty-Four registers its button stylesheet exactly this way, on init, which is the pattern to copy.

The path key is the part people leave out and then wonder about. It tells WordPress where the file is on disk, which enables two things: relative URLs inside the stylesheet are rewritten to be relative to the site root, and the file becomes a candidate for inlining by wp_maybe_inline_styles(). Leave it out and the stylesheet always costs a round trip.

So why per-block stylesheets instead of one large style.css? Because the block stylesheet is only sent when the block is used. My three files are 10,046, 10,328 and 8,250 bytes; a post with a quote loads the quote one and nothing else, and a page with no quote, no details and no table loads none of them. A single global stylesheet would carry all three on every page. The measurements show the split:

page: paragraph only        document  50,921   inlined css 39,862
post: no theme blocks      document  51,248   inlined css 39,862
post: quote block          document  62,328   inlined css 50,776
post: details block        document  61,986   inlined css 50,444
category archive           document  53,671   inlined css 41,469

Eleven kilobytes for the quote block is the per-block stylesheet plus core's own quote styles. The archive costs two kilobytes over the content page, which is the post template and post date styles. Nothing in that table scales with the number of posts on the archive, because the markup repeats and the CSS does not.

The inline budget is shared and small

wp_maybe_inline_styles() inlines stylesheets that declare a path, taking them in ascending order of file size until the cumulative total reaches styles_inline_size_limit, which defaults to 40,000 bytes in 7.0 and was raised from 20,000 in 6.9. Core's own block styles on a real page consume most of that budget, so in my test the theme's own stylesheet was inlined at roughly 13 kilobytes and became a separate render-blocking request at roughly 14 kilobytes. The exact crossover moves with which blocks are on the page. Do not reason about it from first principles: render the page and look.

wp_enqueue_style and the style key in block.json are not the same tool

There are three ways a stylesheet gets attached to the front end, and they differ in who they belong to and when they load. wp_enqueue_style() with no block association is a global stylesheet: it loads on every request where the hook runs, which is right for the theme's own layout rules and wrong for anything that styles one block.

The style key in block.json declares a front-end stylesheet for a block you are registering. It is processed by register_block_style_handle(), which turns the value into a generated handle like my-plugin-my-block-style. Use it when the stylesheet ships inside the block's own folder, because asset path resolution, RTL handling and the version all come from the block metadata.

wp_enqueue_block_style() is for a stylesheet that lives in your theme and targets someone else's block. A theme restyling core/quote cannot add a style key to core's block.json, and enqueueing it globally means every page pays for it. This function is the only one of the three for a per-block stylesheet living outside the block's own directory.

A font a theme loads by default is a real cost

Fonts are declared in theme.json and turned into @font-face rules by WP_Font_Face. Here is what one family with three faces produced from a theme file:

@font-face{font-family:"Lab Sans";font-style:normal;font-weight:400;font-display:swap;src:url('http://example.test/wp-content/themes/m6verify/assets/fonts/lab-sans.ttf') format('truetype');}
@font-face{font-family:"Lab Sans";font-style:normal;font-weight:700;font-display:swap;src:url('.../lab-sans.ttf') format('truetype');}
@font-face{font-family:"Lab Sans";font-style:italic;font-weight:400;font-display:swap;src:url('.../lab-sans-italic.ttf') format('truetype');}

The declarations are cheap and go into the page. The files are the cost: those two were 109,700 and 336,652 bytes, which is 446 kilobytes for one family, and there are three declarations because the same file is registered for weights 400 and 700. A browser fetches a face only when text uses it, so a page that never applies the family downloads nothing, but a page that does applies it to the whole document and downloads every weight the CSS references.

Four things reduce that. Subset the font to the scripts you ship. Prefer a variable font to separate files per weight, because one file covers the range. Declare only the weights and styles the theme actually uses. And check font-display: the value in the example is swap, which shows fallback text immediately, while the default WP_Font_Face applies when the property is missing or invalid is fallback. A theme that sets block makes text invisible until the font arrives, which is a performance decision and an accessibility one at the same time.

Measure, then set the budget

A budget only means something if the measurement is repeatable. I use one command per page, with the compression turned off so that the numbers are stable and comparable between runs:

BASE=http://localhost:8966       # your local site
doc=$(curl -s -H 'Accept-Encoding: identity' "$BASE/a-plain-page/")
printf 'document %s bytes\n' "$(printf '%s' "$doc" | wc -c)"
printf '%s' "$doc" \
  | grep -oE "(src|href)=['\"][^'\"]+\.(js|css)[^'\"]*" \
  | cut -d"'" -f2 | cut -d'"' -f2 \
  | while read -r url; do
      case "$url" in /*) ;; *) url=${url#"$BASE"} ;; esac
      curl -s -o /dev/null -w "%{size_download}  $url\n" "$BASE$url"
    done

On my content page that prints a 50,920 byte document followed by three lines: the theme stylesheet at 83,971 bytes, the Interactivity runtime at 40,106 and the navigation module at 3,053.

Run it for a post, an archive and a page, write the three totals down, and only then decide what the theme is allowed to weigh. For a content page with no images, a budget a well-built block theme can hold is around 60 kilobytes of document including inlined CSS, one external stylesheet under 20 kilobytes, and 45 kilobytes of JavaScript once the Interactivity API runtime is on the page. The runtime is the floor you cannot argue with: any page with a navigation block loads it, because the navigation block's view module imports it.

The budget is a test, not a goal. When a page exceeds it, the question is which bytes you added and whether the block that needed them is on that page. The answer is usually one of three things: a global stylesheet that should have been per block, a font weight nobody uses, or a filter that turned on-demand loading off.

Lab 35 minutes

Measure a block theme you have built on three pages: a single post, a category archive, and a page that uses none of the blocks your theme styles. Use the command above, or your browser's network panel with the cache disabled and compression shown. Write down the document size, the number of external stylesheets, their combined bytes, and the number of scripts.

Then make three changes and re-measure after each one. Move every per-block rule out of style.css and into a stylesheet registered with wp_enqueue_block_style(). Add a filter to your theme that returns false from should_load_separate_core_block_assets, and say what it does to the content page. Remove the path key from one per-block stylesheet registration and find where that stylesheet ends up in the page.

Finish by writing the budget you will hold the theme to, as three numbers, and note which page of the three is the one that breaks it.

Show the solution and the reasoning

The expected shape of the measurements, with a theme whose global stylesheet is under the inline budget:

page: paragraph only     document ~51 KB   css files 0-1   scripts 1 + 1 preload
post: quote block        document ~62 KB   css files 0-1   scripts 1 + 1 preload
category archive         document ~54 KB   css files 0-1   scripts 1 + 1 preload

Moving rules into per-block stylesheets reduces the document on pages that do not use those blocks and increases it on pages that do, by roughly the size of the stylesheet that becomes used. That is the trade and it is worth making whenever the block is not on every page. If a block is in your header or footer template part, its stylesheet is on every page by definition, and splitting it out buys nothing.

Returning false from should_load_separate_core_block_assets is the change that should alarm you. It makes WordPress register the single combined wp-block-library stylesheet instead of per-block files, so the content page grows by the size of every core block style in the bundle rather than the handful of blocks actually rendered. On my test page that filter would have turned a 50-kilobyte document into something substantially larger, and no amount of careful CSS in the theme would have recovered it. The should_load_block_assets_on_demand filter is separate and controls on-demand loading; filtering it to false has a similar effect on scripts and styles that are registered for blocks.

Removing the path key has two visible consequences and one invisible one. The stylesheet stops being a candidate for inlining, so it appears as a <link> element in the head instead of a <style id="...-inline-css">. Relative URLs inside it stop being rewritten, so an url(../images/x.png) that resolved from the theme's CSS directory now resolves from the stylesheet's own location and may 404. And WordPress cannot warn you about either, because the only signal it has is the missing key. Adding path is therefore not a micro-optimisation to consider later; it is part of registering the style correctly.

The budget I hold this theme to is 60 kilobytes for a content page document including inlined CSS, 20 kilobytes for a single external stylesheet, and 45 kilobytes of JavaScript. The archive is the page that breaks it first in practice, because the query title, post template, post title and post date blocks each bring a stylesheet that the content page does not have. When that happens the fix is not to shrink the archive; it is to check whether the theme styles any of those four blocks globally, since that is the part the theme controls.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.