Script Loader: Prefetch assets for the next admin screen - #13084
westonruter wants to merge 19 commits into
Conversation
When script and style concatenation is disabled, the first admin screen after logging in downloads each core script and stylesheet separately. Measured on a throttled Fast 4G connection with a cold cache, that costs roughly 600 ms of First Contentful Paint against the concatenated equivalent: 28 extra requests that HTTP/1.1 has to serialize behind its six-connection cap. Print `link rel=preload` tags on the login screen for the handles that `load-scripts.php` and `load-styles.php` would otherwise bundle, so the browser puts them in the HTTP cache while the login form is on screen rather than after the redirect. The tags carry `fetchpriority=low` so they queue behind the login screen's own render-blocking assets, and handles the login screen has already printed are skipped. Add `_wp_resolve_dependency_urls()` to resolve a registered handle to the URL it would load from, mirroring how `WP_Scripts::do_item()` and `WP_Styles::do_item()` build it — the version argument, the `script_loader_src` and `style_loader_src` filters, and the RTL replace-or-append rules — without printing anything or disturbing the queue. Gate on `CONCATENATE_SCRIPTS && ! SCRIPT_DEBUG` rather than on the `$concatenate_scripts` global. `script_concat_settings()` usually runs on a login request before `login_init` fires, since registering any script on `init` is enough to trigger it, and at that point it evaluates `is_admin()` as false and settles the global on false whatever the constant says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude Code analysis of the change wp-admin script/style concatenation: phase 1 findings (HTTP/1.1, /wp-admin/ Dashboard)Measured on 2026-08-16 against the local Docker env (nginx:alpine, HTTP/1.1, Summary
ResultsFast 4G is Chrome DevTools' built-in preset applied via CDP
Delta of "concat off" versus "concat on" (positive = removing concat is slower):
Request shape per condition (from the network log):
Raw per-run values (ms)
Method
Caveats
Interpretation for the removal decision
Environment state left in place (revert notes)These local changes are still applied so phase 2 can continue; none are committed:
|
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
| add_action( 'login_head', 'wp_resource_hints', 8 ); | ||
| add_action( 'login_head', 'wp_print_head_scripts', 9 ); | ||
| add_action( 'login_head', 'print_admin_styles', 9 ); | ||
| add_action( 'login_head', 'wp_preload_admin_assets', 10 ); |
There was a problem hiding this comment.
It will be useful to print the preload links in login_footer. This would save some bytes before the body can load, especially if the list is long.
There was a problem hiding this comment.
I asked Claude and it suggests not moving it:
Real numbers, and they cut against moving it — though your mechanism is genuinely there.
The response is chunked, so your premise holds: Transfer-Encoding: chunked, Content-Encoding: gzip. Bytes in the head do sit in front of everything after them.
But the cost is 253 bytes gzipped.
| raw | gzip | |
|---|---|---|
| Login HTML without prefetch links | 10,492 | 3,476 |
| Login HTML with prefetch links | 13,383 | 3,729 |
| Cost of the 24 tags | +2,891 | +253 |
2.8 KB raw compresses to 253 bytes because the tags are near-identical — same scheme, host, path prefix, same ver string, same as value. The whole login document is 3.7 KB gzipped, comfortably inside a single initial congestion window, so there's no segment boundary for those 253 bytes to push anything past.
And they're already behind the render-blocking CSS. The hook priority does the work — print_admin_styles at 9, this at 10:
first stylesheet : line 7
last stylesheet : line 14
first prefetch : line 15
</head> : line 41
All 8 stylesheets are emitted before the first prefetch tag, so the preload scanner has discovered every render-blocking resource before it reaches a single prefetch byte. Moving them to the footer would relocate 253 bytes from a position that's already downstream of the critical path.
The measurement I have is a worst case. Login FCP was 568 ms with the links vs 570 ms without — and that was with 25 rel=preload tags, which are bigger (they carried fetchpriority='low') and fetch at document priority rather than idle. The version we now ship is strictly less aggressive on both axes than the thing that measured as free.
Where you'd be right and I'd change position: if the list grew substantially, if a site's head were already large enough that boundaries mattered, or if someone filtered in many more resources via login_prefetch_admin_assets. None of those is the default case, but none is exotic either.
So I'd keep it in login_head. If you want the bytes out regardless, footer is valid markup and I'll move it — it just trades 253 bytes of head for a later prefetch start, and start time is what determines whether the prefetch finishes before the user submits.
|
Combined review of Claude and Codex Preload the admin's unconcatenated assets from the login screenReviewed against VerdictThe mechanism works as described and the measured effect on the first Dashboard load after login is large. It is not a general answer to removing concatenation: it only helps a cold cache that happens to pass through What was verified
FindingsDesign
Correctness in
|
|
🤖 Claude analysis of benefit of preloading Dashboard load after logging in (10 runs per arm, fresh cache each run)
Distributions don't overlap at all — 1244–1280 ms vs 688–728 ms. That's 542 of the original 602 ms concat-on/off FCP gap, or 90%. Subtracting the login POST + redirect (~292 ms, present in both arms) to compare against the earlier direct-navigation numbers:
Preloading doesn't just close the gap — it beats concatenation, because the bytes move during idle time on the login screen instead of during the dashboard load. Cost to the login screen
Two caveats on reading thisWhy I ran a control arm despite you saying not to re-test. The earlier cold-cache number (1342 ms) was a hard reload of the dashboard, not a login→dashboard navigation. Going through the login screen warms 21 shared assets by itself, so that flow lands at 965 ms even with zero preloads. Without the control, preloading would have looked like it recovered 628 ms when the honest figure is 542 ms. Dwell time. Preloads finish at ~1570 ms; the runs used a fixed 4-second dwell on the login screen, identical in both arms. A user whose password manager submits in under ~1.6 s gets proportionally less. Everything is still HTTP/1.1, so the whole effect should shrink over HTTP/2. |
The assets these links point at are for the navigation that follows the login, not for the login screen itself, and `rel="prefetch"` is what describes that. Using `rel="preload"` had three consequences worth avoiding: it fetches at the current document's priority rather than idle priority, it makes cross-navigation reuse depend entirely on the static files' HTTP cache headers, which core does not control, and it makes browsers warn about every preloaded resource the document never goes on to use. Rename `wp_preload_admin_assets()` to `wp_prefetch_admin_assets()` and the `login_preload_admin_assets` filter to `login_prefetch_admin_assets` to match. Keep the `as` attribute, which is what lets a prefetched response be reused for a request with the same destination, and keep `fetchpriority="low"`. Also narrow the filter's documented contract. It claimed to accept the same resource attributes as the `wp_preload_resources` filter, but only `href`, `as` and `fetchpriority` are ever printed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A prefetch is already dispatched at the browser's lowest priority, so `fetchpriority="low"` has nothing left to lower. The attribute is defined for use with external resource links, where it sets the priority for fetching and processing the linked resource, and browsers wire it up for `preload`, `modulepreload`, scripts, images and iframes rather than for `prefetch`. Printing it here implied a control that was not being exercised. The `as` attribute stays. It gives the request the same destination the admin screen will later ask for, which is what allows the prefetched response to be reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'login_head' fires for every login-family screen, not just the login form, and a successful login does not necessarily land on an admin screen. Prefetching in those cases spends the visitor's bandwidth on files they will never request. Skip the prefetching entirely on the password reset, registration, logout confirmation and check-your-email flows, on an interim login, which re-authenticates inside a modal on a page that already has these assets, and when `redirect_to` points outside the admin. An off-host `redirect_to` still prefetches, because `wp_safe_redirect()` falls back to the admin in that case and `wp_validate_redirect()` is used here to mirror that. The set of handles itself does not need to vary with the destination. Every handle listed loads on all admin screens rather than only on the Dashboard, since `wp-admin` is an alias handle enqueued everywhere that pulls in `dashboard`, `edit`, `themes`, `nav-menus` and the rest. Verified across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: all 6 scripts and 24 of the 25 styles appear on every one. Drop the exception. `site-health` is concatenated on the Dashboard and nowhere else, so it is the one handle that was tied to a particular screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A plugin adjusting the prefetched set almost always wants to know where the login is about to land, and without it being handed over the only way to find out is to read `redirect_to` back out of `$_REQUEST` and repeat the validation this function has already done. Pass the resolved destination as a second argument to `login_prefetch_admin_assets`. It is the value wp_safe_redirect() will receive: `redirect_to` when the request supplied one, the admin otherwise, already through wp_validate_redirect() so an off-host value has fallen back to the admin. Resolve it unconditionally rather than only when the request carries the argument, so the filter gets a usable value in the common case where it does not. The docblock notes that it may be relative, since a request-supplied path is passed through unchanged and only the fallback is a full URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The login screen is not the only place the next screen can be guessed at. From the Dashboard and the post list tables the editor is the usual next stop, and it is by far the heaviest screen in the admin: landing there from the Dashboard pulls in 47 files the Dashboard did not already have. Prefetch the editor's stylesheets from those screens, and from the login screen as well when `redirect_to` points at `post-new.php` or at `post.php` with `action=edit`. Because handles the current screen has already printed are skipped, each context only fetches what it is actually adding: 18 stylesheets from the Dashboard or a post list, and those plus the admin-wide set from the login screen. Stylesheets only. The editor's scripts come to roughly 1.26 MB compressed against 98 KB for its stylesheets, which is far too much to spend speculatively on a screen the user may never open. The stylesheets are render-blocking and land in the same size class as the login screen's existing prefetch. Name the roots rather than the whole set. `_wp_expand_dependency_handles()` pulls in whatever those roots depend on, so the list follows the dependencies declared in `wp_default_styles()` instead of restating them: eight roots cover all eighteen handles, and `wp-edit-post` alone accounts for most of the editor chrome. Skip the whole thing for a user who cannot create the post type, and for a post type still using the classic editor, which would load none of these. Rename the filter from `login_prefetch_admin_assets` to `prefetch_admin_assets`, since it is no longer login-specific, and describe its second argument as the screen being prefetched for rather than as a redirect target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Expanding a set of handles to include their dependencies reads nothing beyond the registry's `registered` array and each item's `deps`, both of which are declared on `WP_Dependencies` itself. Naming the two subclasses in the signature therefore claimed more than the function needs, and turned away any other registry that would work just as well. Since the parameter now names a single class rather than a union, it also gains a native type hint. `_wp_resolve_dependency_urls()` keeps its `WP_Scripts|WP_Styles` union: it reaches for `_css_href()`, `text_direction`, `base_url`, `content_url`, and `default_version`, none of which the base class declares, and the union is what gives its `instanceof WP_Styles` branch something to narrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The roots handed to the handle expander are non-empty by contract, but the handles reached through them are only ever known to be strings: the `deps` property is documented as `string[]`, so an empty one would be queued, used as an array key, and handed back to the caller as an empty handle to resolve. Excluding it where a non-root handle enters the queue is the only place the check is needed, and it lets the signature say what the function actually returns: a list of non-empty handles, expanded from a non-empty list of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prefetched resources were keyed by URL, which collapsed handles resolving to the same file but left the filter looking at a map whose keys repeated the `href` beside them. Worse, it put the collapsing before the filter rather than after, so a callback appending a URL core had already listed would have printed a second link for it. `wp_preload_resources()` had already settled all of this: the filter sees a plain list of attribute arrays, duplicates are folded afterwards into a set keyed by `href` with the first entry winning, and printing walks that set. Doing the same here means a callback can append without first checking what is already there, and anyone who has read one filter has read the other. Printing stays a fixed `href`/`as` pair rather than the generic walk over an attribute allowlist, since those two are the whole contract and `fetchpriority` was deliberately dropped from these links earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Those are the two destinations core itself prefetches, but nothing in the code constrains the attribute, and a callback with a reason to prefetch an image, a font or a document should not read the documentation as ruling it out. Describing the values the way `wp_preload_resources()` already does keeps the two filters saying the same thing about the same attribute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deriving the registry from the destination meant a ternary re-answering, once per group, a question the loop had just asked. Pairing the two in the array being walked lets the destination stay what it is for, which is the value of the `as` attribute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two passages were written while the login screen was the only place this ran, and kept naming it after the Dashboard and the post list tables started printing these links too: the note on skipping handles already printed, and the explanation of why these are prefetches rather than preloads. Both describe how the function behaves wherever it runs, so both now say so. The remaining mentions are left alone, being the ones that are about the login screen: its own bullet in the list of contexts, the admin-wide handles not varying with where a login lands, the flows that print nothing, and everything inside the branch that only runs on `login_head`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A helper named after the caller's concern rather than its own work is a helper that exists only to shorten a call site, and this one had exactly one. Folding it in costs eight lines in a function that reads no worse for them, and spares the global namespace a permanent addition. The two remaining helpers stay: resolving a handle to the URLs it loads from, and expanding handles to include their dependencies, are both described without reference to prefetching and would serve any caller that wanted them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing the parameter as WP_Dependencies passes static analysis, which is the trap: with only WP_Scripts and WP_Styles in view, ruling out the one leaves the other, so every member the URL is built from resolves. Gutenberg's WP_Fonts is a third subclass and declares none of them, and a caller reaching this with one would land on an undefined property several lines into the function rather than a type error at its door. Since the analyser cannot make that argument, the docblock does. The handle is also documented as non-empty, matching the URLs already promised of the return. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| $script_handles = array(); | ||
| $style_handles = array(); |
There was a problem hiding this comment.
The lists of hard-coded handles below will need to be maintained. We should possibly add comments to where the assets are enqueued to note they should also note the list(s) here should be updated if anything changes.
| * Mirrors how {@see WP_Scripts::do_item()} and {@see WP_Styles::do_item()} build the | ||
| * URL they print, including the version query argument and the {@see 'script_loader_src'} | ||
| * and {@see 'style_loader_src'} filters, without printing anything or disturbing the queue. |
There was a problem hiding this comment.
If it mirrors, then this seems like duplication. Better to see if we can reuse what WP_Scripts and WP_Styles are already doing, rather than introduce a new _wp_resolve_dependency_urls() function.
| * @phpstan-param non-empty-list<non-empty-string> $handles | ||
| * @phpstan-return list<non-empty-string> | ||
| */ | ||
| function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $handles ): array { |
There was a problem hiding this comment.
This function is only used in one place, wp_prefetch_admin_assets(), so probably better to inline it so as to avoid adding a global function needlessly (even though it could make it easier to unit test).
manzoorwanijk
left a comment
There was a problem hiding this comment.
This looks good to me as a starting point. Should we commit it?
|
This remains high on my radar to get back to. I want to address the comments I raised and add some tests, but then, yes, we should commit! |
The docblock argued for prefetch over preload partly on the grounds that preload would leave reuse across the navigation dependent on the static files' HTTP cache headers. Prefetch is no different. With the login screen loaded in a fresh browser context under Fast 4G, a login a few seconds later found all 24 prefetched files in the cache without a request, while a login four minutes later revalidated 18 of them with a 304. Those 18 were exactly the files whose heuristic freshness, a tenth of the time since `Last-Modified`, had run out; the six recent enough to stay fresh were still served from the cache. No five-minute exemption for unused prefetches applied. The case for prefetch still stands on priority and on preload's unused-resource warnings, so the paragraph now rests on those, and a new one says what reuse actually depends on: whatever freshness the server's headers, or their absence, give the file. A stale response still saves the download, just not the round trip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin-wide list was taken from what load-scripts.php and load-styles.php bundle, but being concatenated was only ever a stand-in for what matters, which is holding back the first paint. With concatenation off, `hoverIntent`, `wp-dom-ready` and `wp-hooks` are printed in the footer, and Chrome marks only the stylesheets and the three head scripts as render-blocking on the Dashboard. Measured login-to-Dashboard on Fast 4G, ten runs each, dropping the three made no difference to LCP (894 ms against 900 ms median) or anything else. The footer's scripts were benchmarked as a set too, since they do block DOMContentLoaded. When their prefetch finishes before the login is submitted the gain is large: DOMContentLoaded 3.6 s to 1.05 s on Fast 4G, LCP unchanged. But the set is about 1.2 MB compressed, mostly the command palette's dependencies, and on Fast 3G it had not finished 3 s or even 8 s after the login screen loaded. What was still in flight kept downloading across the navigation and competed with the Dashboard's render-blocking stylesheets, costing 574 ms and 1,038 ms of LCP respectively. Plain prefetch links cannot tell those cases apart, so the footer stays out, and the comment says why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Comment by Claude Opus 5.5 Benchmarks: prefetch reuse, what to prefetch, and slow connectionsThese were run on 2026-09-25/26 against this branch as of 87e9460 (before 9818520 narrowed the login set, which was chosen on the strength of the results below). SetupUnless stated otherwise:
1. Are prefetched responses reused across the navigation?This had been verified for the first revision's
Yes, but only for as long as ordinary HTTP freshness allows. With no explicit caching headers, Chrome gives each file a heuristic lifetime of a tenth of the time since its A freshly built checkout is close to the worst case for this. On a production site core's files are typically unchanged for weeks, and many hosts send a long 2. What actually blocks LCP on the Dashboard?One cold-cache Dashboard load under Fast 4G:
3. What loads on every admin screen but isn't prefetched?12 admin screens were fetched, and the core scripts and stylesheets present on every one were kept:
Of the 50,
4. Which set should the login screen prefetch?Three sets were compared by adding or removing entries through the
Fast 4G, login submitted 3 s after the login screen loaded (10 runs per arm):
Fast 3G, login submitted 3 s after the login screen loaded (10 runs each of B and C):
Fast 3G, login submitted 8 s after the login screen loaded (10 runs each):
On Fast 3G, C improves DCL by 1.6 s with the 3 s delay and 6.9 s with the 8 s delay. But it makes LCP worse by 574 ms and 1,038 ms respectively, and the LCP ranges don't overlap B's in either case. More typing time made LCP worse, not better:
The competition for bandwidth is inferred from these counts and timings; it wasn't traced at the network level. By the rate observed, the full C set needs roughly 11–12 s to finish on Fast 3G. One C run with the 3 s delay was an outlier: 63 of 94 prefetches had finished at submit, and DCL was 7832 ms. It was the first run of that series and immediately followed a browser page that failed to open. It is kept in the data above. The login screen itself was unaffected by the prefetch set's size. Its Conclusions
Caveats
|
|
🤖 Comment by Claude Opus 5.5 Does prefetch reuse depend on far-future caching headers?No. A prefetched file is served from the cache without a request whenever it is still fresh. Freshness doesn't need an explicit How long a response stays fresh without caching headersIf a response has no
At 241 s, the six files that were still cache hits were the ones old enough for their estimated freshness to cover the wait. The 18 that revalidated had been rebuilt minutes before the test. With an explicit one-year The last row used an opt-in far-future When prefetch reuse worksA prefetched file is reused without revalidation when all of these hold:
When any of these don't hold, the prefetch still saves the download: the Dashboard sends a conditional request and gets a 304 instead of the file. It never makes things worse than not prefetching. Two limits to this:
What HTTP Archive and the Web Almanac sayNo published data breaks static-asset caching down by CMS, so there's no WordPress-specific figure. Across all sites:
For this PR, what matters is conditions 1 and 2 above, not how common far-future caching is, so this data isn't needed to judge it. Why it matters for Core-57548The broader ticket does depend on this. $expires_offset = 31536000; // 1 year.
header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + $expires_offset ) . ' GMT' );
header( "Cache-Control: public, max-age=$expires_offset" );On a server with no caching rules for static files, retiring concatenation replaces bundles cached for a year with individual files that only get estimated freshness. Repeat visits would stay cached for most of a release cycle, but revalidate more after each update. How many WordPress sites are in that position is something HTTP Archive can answer. HTTP Archive only crawls public pages, never Draft HTTP Archive query: caching headers on WordPress core JS and CSS-- JS and CSS that WordPress sites serve from their own core directories,
-- bucketed by explicit freshness lifetime. One crawl, mobile, root pages.
DECLARE crawl DATE DEFAULT '2026-08-01';
WITH wp_pages AS (
SELECT DISTINCT page
FROM `httparchive.crawl.pages`
WHERE date = crawl
AND client = 'mobile'
AND is_root_page
AND EXISTS (SELECT 1 FROM UNNEST(technologies) AS t WHERE t.technology = 'WordPress')
),
core_assets AS (
SELECT
r.page,
r.type,
LOWER((SELECT STRING_AGG(h.value, ', ') FROM UNNEST(r.response_headers) AS h WHERE LOWER(h.name) = 'cache-control')) AS cache_control,
EXISTS (SELECT 1 FROM UNNEST(r.response_headers) AS h WHERE LOWER(h.name) = 'expires') AS has_expires,
EXISTS (SELECT 1 FROM UNNEST(r.response_headers) AS h WHERE LOWER(h.name) = 'last-modified') AS has_last_modified
FROM `httparchive.crawl.requests` AS r
JOIN wp_pages USING (page)
WHERE r.date = crawl
AND r.client = 'mobile'
AND r.is_root_page
AND r.type IN ('script', 'css')
AND NET.HOST(r.url) = NET.HOST(r.page) -- served by the site itself, not a CDN rewrite
AND REGEXP_CONTAINS(r.url, r'/wp-(?:includes|admin)/')
),
classified AS (
SELECT
*,
SAFE_CAST(REGEXP_EXTRACT(cache_control, r'(?:^|[,\s])max-age\s*=\s*"?(\d+)') AS INT64) AS max_age
FROM core_assets
)
SELECT
type,
CASE
WHEN REGEXP_CONTAINS(IFNULL(cache_control, ''), r'no-store|no-cache') OR max_age = 0 THEN '1: always revalidate'
WHEN max_age IS NULL AND NOT has_expires AND has_last_modified THEN '2: heuristic only (Last-Modified)'
WHEN max_age IS NULL AND NOT has_expires THEN '3: no freshness, no Last-Modified'
WHEN max_age IS NULL THEN '4: Expires only'
WHEN max_age < 86400 THEN '5: under 1 day'
WHEN max_age < 2592000 THEN '6: 1 to 29 days'
WHEN max_age < 31536000 THEN '7: 30 days to under 1 year'
ELSE '8: 1 year or more'
END AS freshness,
COUNT(DISTINCT page) AS sites,
COUNT(*) AS responses,
ROUND(100 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY type), 1) AS pct_of_responses
FROM classified
GROUP BY type, freshness
ORDER BY type, freshness;The schema follows the |
|
🤖 Comment by Claude Opus 5.5 Benchmarks: today's default vs this PR, login → DashboardThese replace the first revision's figures in the PR description. They compare WordPress's current default (concatenation on) with this PR's configuration (concatenation off, prefetching on), plus a control arm with concatenation off and no prefetching. The runs were on 2026-09-26 against this branch at 9818520, with the local nginx caching option from a separate commit enabled. Setup
Fast 4G
Slow 4G
In current Chrome, "Slow 4G" is the preset formerly named "Fast 3G". The prefetch arm's LCP of 1872 ms matches the earlier Fast 3G runs of the same set (1860 ms), which is consistent with that. Findings
CorrectionEarlier revisions of the description said the login screen never concatenates, even with the constant on. On this setup it does, as the bundle URL above shows. Whether it does depends on whether Caveats
|

Explores one way to soften the cost of retiring script and style concatenation, per Core-57548: when the next screen can be predicted with confidence, prefetch what that screen is certain to need, so it is already in the HTTP cache by the time the user gets there.
This has changed substantially since the first revision, in response to the review on this PR. It used
rel="preload"and was login-only; it now usesrel="prefetch", covers the editor as well, and from the login screen prefetches only what blocks the admin's first paint. All the measurements below were taken against the current implementation; the full method and raw numbers are in the benchmark comments on this PR.What this does
Two contexts qualify today.
The login screen → the admin. With concatenation off, the first admin screen downloads each core script and stylesheet separately, which is what makes an uncached admin load slower than a concatenated one. The ones that block its first paint — the stylesheets, and the scripts printed in the head — are prefetched while the login form is on screen, putting them in the cache during the time the user spends typing credentials. 21 tags on a default install.
The Dashboard and the post list tables → the editor. The editor is the usual next stop from both, and it is by far the heaviest screen in the admin. 19 tags.
The two compose: a login whose
redirect_topoints atpost-new.php, or atpost.php?action=edit, prefetches both sets — 40 tags.Handles the current screen has already printed are skipped, so each context only fetches what it actually adds.
Nothing is printed when concatenation is enabled; when the screen is not the login form (password reset, registration, logout, check-your-email); on an interim login; when
redirect_topoints outside the admin; on admin screens other than the Dashboard and post lists; for a user who cannot create the post type; or for a post type still using the classic editor.Why: login → Dashboard, today's default vs this PR
Fresh browser context per run (empty cache), real login submitted 3 s after the login screen loaded,
SCRIPT_DEBUGoff, gzip and one-year static caching on, 10 runs per arm interleaved, medians with the full range in brackets. Three arms:loadloadAgainst today's default, this PR's configuration gives the Dashboard an LCP 294 ms (−26%) faster on Fast 4G and 1,622 ms (−46%) faster on Slow 4G, with the three arms' ranges well separated, while DOMContentLoaded and
loadstay within 1.5% (+54 ms and +16 ms). Retiring concatenation without prefetching would cost 278–312 ms of LCP and 0.5–1.9 s of DOMContentLoaded.Prefetching overtakes concatenation on LCP largely because concatenation defeats caching across screens. The login screen concatenates too when the constant is on, but into a bundle of its own (
dashicons,buttons,forms,l10n,wp-base-styles,wp-tooltip,login), so the Dashboard's bundles share nothing with it and download from scratch. With concatenation off, the Dashboard reuses the six files the login screen already loaded plus the 21 prefetched ones. Some of the lead belongs to prefetching as such, though: a concatenated setup could in principle prefetch its own bundle URLs, which differ per screen; that has not been measured.The login screen itself pays for concatenation being off, not for the prefetching: its
loadgoes from 698 to 912 ms on Fast 4G and from 2418 to 3181 ms on Slow 4G, the same as the no-prefetch arm (920 and 3195 ms). Its FCP barely moves (+52 ms on Fast 4G, −80 ms on Slow 4G).In current Chrome, "Slow 4G" is the preset formerly named "Fast 3G"; the prefetch arm reproduced the earlier Fast 3G runs to within 12 ms.
Render-blocking only, from the login screen
The login set used to be whatever
load-scripts.phpandload-styles.phpbundle, but being concatenated was only a stand-in for what matters. With concatenation off, three of the six bundled scripts (hoverIntent,wp-dom-ready,wp-hooks) print in the footer, and Chrome marks only the stylesheets and the three head scripts (jquery-core,jquery-migrate,utils) as render-blocking on the Dashboard. Dropping the three footer scripts made no measurable difference to anything.Prefetching the footer scripts as well was benchmarked, since they do block DOMContentLoaded. When the prefetch finishes before the login is submitted the gain is large — DOMContentLoaded 3.6 s → 1.05 s on Fast 4G, LCP unchanged — but the set is about 1.2 MB gzipped, mostly the command palette's dependencies, and on Fast 3G it had not finished 3 s or even 8 s after the login screen loaded. What was still in flight carried across the navigation and competed with the Dashboard's render-blocking stylesheets, costing 574 ms and 1,038 ms of LCP respectively. Plain prefetch links cannot tell those cases apart, so the footer is left out.
Stylesheets only, for the editor
The editor's incremental cost over the Dashboard is 47 files. Split by type:
Only the stylesheets are prefetched.
wp-editorJS alone is 503 KB gzipped andwp-block-libraryanother 356 KB; over a megabyte of speculative download for a screen the user may never open is not a reasonable default, especially on a metered connection. The stylesheets are render-blocking and land in the same size class as the login screen's own prefetch. Prefetching the editor's scripts on an explicit intent signal — hover or focus on an Add New link — would be a reasonable follow-up, where the prediction is strong enough to justify the bytes.These figures predate
wp-editorgaining a dependency on thewp-media-utilsstylesheet, which the root expansion picked up without a code change.Implementation
In
src/wp-includes/script-loader.php:wp_prefetch_admin_assets()— decides whether a next screen can be predicted, builds the list and prints it. Hooked tologin_headandadmin_headat the default priority, after the current screen's own assets have printed so the already-printed check works._wp_expand_dependency_handles()— expands root handles to include everything they depend on. Typed againstWP_Dependencies, since it reads onlyregisteredanddeps._wp_resolve_dependency_urls()— resolves a registered handle to the URL it would load from, mirroringWP_Scripts::do_item()andWP_Styles::do_item(): the version argument, thescript_loader_srcandstyle_loader_srcfilters, and the RTL replace-or-append rules. Prints nothing, does not touch the queue. Deliberately typedWP_Scripts|WP_Stylesrather thanWP_Dependencies: it needs members only those two declare, and Gutenberg'sWP_Fontsis a third subclass that declares none of them.The editor set is expressed as 8 roots, so it follows the dependencies declared in
wp_default_styles()rather than restating them —wp-edit-postalone accounts for most of the editor chrome. The admin-wide set is still a flat list.The filter is
prefetch_admin_assets, since it is no longer login-specific. It is modeled onwp_preload_resources: it receives a plain list of attribute arrays, duplicates byhrefare collapsed after the filter runs with the first entry winning, andastakes any destination, not justscriptandstyle. Its second argument is the URL of the screen being prefetched for.Reuse across the navigation
Verified for prefetch in Chrome, fresh isolated browser context per run, Fast 4G, reading Resource Timing and the network log on the Dashboard after a real login submit:
Last-ModifiedandETagonlyLast-ModifiedandETagonlyCache-Control: max-age=31536000Reuse is governed by ordinary HTTP freshness. Without explicit caching headers the browser estimates freshness as a tenth of the time since
Last-Modified. The 18 that revalidated at 241 s were exactly the files rebuilt minutes earlier, whose estimated freshness had run out; the six still served from cache were old enough to stay fresh for hours or days. No five-minute exemption for unused prefetches applied. A 304 still saves the download, but costs a round trip.A freshly built development checkout is close to the worst case for this. On a production site core's files are typically unchanged since the last update, which gives a heuristic lifetime of days, and many hosts send an explicit long
max-agefor CSS and JS besides. Far-future caching headers are not required for this to work — see the caching comment on this PR for the conditions and the HTTP Archive data.What still needs measuring
Corrections to earlier descriptions
The first revision claimed no "preloaded but not used" console warnings appeared. That was wrong — the check used a tool that surfaces JS
console.*calls but not browser-generated warnings, so it could not have observed them. Thanks to @manzoorwanijk for catching it. The switch to prefetch moots the warnings, but the claim should not have been made.A later revision also argued that preload, unlike prefetch, would make reuse depend on static-file cache headers core does not control. That was wrong too: prefetch depends on them in exactly the same way, as the reuse test above shows. The docblock has been corrected to match.
Earlier revisions also said the login screen never concatenates even with the constant on. That was too strong: it depends on whether
script_concat_settings()runs beforelogin_init, as explained under "The gate is a prediction" below, and on the setup used for the benchmarks above it does concatenate.Design decisions
prefetch, notpreload. These are resources for the next navigation, which is what prefetch describes. Preload fetches at the current document's priority and warns about resources the document never uses.No
fetchpriority. A prefetch is already dispatched at the lowest priority.asis kept — it gives the request the same destination the next screen will ask for, which is what lets the response be reused.In the head, not the footer.
prefetchis body-ok so the footer would be valid, but the cost is small and already downstream of the critical path: when measured, the login screen's 24 tags then added 253 bytes gzipped and the Dashboard's 18 added 222 bytes. Hook priority puts them after the current screen's own render-blocking CSS, so the preload scanner has found everything render-blocking before reaching a prefetch byte. Footer placement would move ~200 bytes out of a position already behind the critical path, at the cost of a later prefetch start.The admin-wide list does not vary by destination. Nearly all of it is universal admin CSS rather than Dashboard CSS:
wp-adminis an alias handle enqueued on every admin screen that pulls indashboard,edit,themes,nav-menus,widgets,revisionsand the rest. Checked across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: the head scripts and 24 of the original 25 styles appear on every one.site-healthwas the sole exception and was dropped.The gate is a prediction, not a reading.
$concatenate_scriptscannot be relied on from the login screen: ifscript_concat_settings()runs beforelogin_initfires — registering a script oninitis enough to trigger it — it evaluatesis_admin()as false and settles the global onfalsewhatever the constant says, and the login screen then does not concatenate either. This gates onCONCATENATE_SCRIPTS && ! SCRIPT_DEBUGinstead. That prediction can be wrong if a plugin pre-sets the global or defines the constant only whenis_admin(). The underlying quirk looks worth its own ticket.Known gaps
use_block_editor_for_post_type()only exists in the admin, so the login-screen path cannot make that check. A classic-editor site withredirect_to=post-new.phpwill still prefetch editor CSS. Confirmed on a site running the Classic Editor plugin, where the Dashboard correctly prints nothing but that login URL still prints the editor stylesheets.redirect_toalone.colors) is universal and render-blocking but deliberately not prefetched from the login screen, since the scheme is a per-user setting and the user is unknown at that point. It is the only render-blocking core stylesheet on every admin screen that is not covered. Same class of problem as the locale mismatch below.fetch()with anAbortControllerrather than link tags — which has not been tried.Review findings
Addressed: switched to
prefetch(2); restricted to the login form action and interim login (3, partly —Save-Datais still not honored); dropping the script handles (4, partly — the three footer scripts are gone, the three head scripts stay because they block rendering); dropped the screen-specific handle; derived the editor list from roots rather than hardcoding it (5, partly — the admin-wide list is still flat); narrowed the filter's documented contract to the attributes actually printed (11); Fast 3G / Slow 4G.Not addressed: framing this as a complement rather than a replacement (1) — agreed, and it should not be the argument for retiring
load-styles.php; a drift test comparing the lists against what the admin actually loads (5); locale mismatch between the login screen and the admin (6);args/#fragmentin the script branch of the resolver (7); a docblock note that the src filters run in a logged-out, non-admin request (8); idempotency (12). No automated tests yet.Testing instructions
With
CONCATENATE_SCRIPTSfalse andSCRIPT_DEBUGfalse, and the block editor enabled for posts and pages, view source and count<link rel="prefetch">tags:wp-login.phpwp-login.php?redirect_to=%2Fwp-admin%2Fpost-new.phpwp-login.php?redirect_to=%2Fwp-admin%2Fpost.php%3Fpost%3D1%26action%3Dtrashwp-login.php?redirect_to=%2Fhello-world%2Fwp-login.php?action=lostpassword,?interim-login=1post-new.php, Plugins, Settings, MediaCONCATENATE_SCRIPTStrueWith the Classic Editor plugin active, the Dashboard and list tables print 0.
To check reuse, load the login screen, log in within a few seconds, and confirm in the Network panel that the prefetched URLs are served from the cache on the Dashboard without a request. Waiting a few minutes before logging in on a freshly built checkout will show 304s instead, per "Reuse across the navigation".
Trac ticket: https://core.trac.wordpress.org/ticket/57548
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5, Claude Opus 5.5
Used for: Running the benchmarks and asset analysis, drafting the implementation, and drafting this description. The approach, the design decisions and the final code were reviewed and edited by me.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.