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:
| Request | Bytes | What it is |
|---|---|---|
| Document | 50,921 | HTML plus 39,862 bytes of inlined CSS |
| Theme stylesheet | 83,971 | style.css, which is over budget on purpose |
| Script | 3,053 | navigation block view module |
| Module preload | 40,106 | the Interactivity API runtime |
| Total | 178,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.
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.