Script Loader: Prefetch the admin's unconcatenated assets from the login screen - #13084
Script Loader: Prefetch the admin's unconcatenated assets from the login screen#13084westonruter wants to merge 5 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>

Explores one way to soften the cost of retiring script and style concatenation, per Core-57548: warm the admin's assets from the login screen, so the first admin page load after signing in does not pay for them.
This has changed substantially since the first revision, in response to the review on this PR. It used
rel="preload"and it now usesrel="prefetch"; the headline measurements below were taken against the preload revision and have not yet been re-run. Details under "State of the measurements".What this does
When concatenation is off, prints
<link rel="prefetch" as="…">tags on the login screen for the handles thatload-scripts.phpandload-styles.phpwould otherwise bundle, so the browser puts them in the HTTP cache while the login form is on screen rather than after the redirect.The handle list is 6 scripts and 24 styles. Six of those are already printed by the login screen itself and are skipped via the
donecheck, so a default install emits 24 tags.Nothing is printed when:
load-scripts.phpandload-styles.phpalready collapse these handles into a handful of requests;login_headtoo, and none leads to the admin;redirect_topoints outside the admin.Two functions in
src/wp-includes/script-loader.php:wp_prefetch_admin_assets()— builds and prints the list, hooked tologin_headat priority 10, just afterprint_admin_styles. Exposes alogin_prefetch_admin_assetsfilter receiving the resource list and the resolved redirect target._wp_resolve_dependency_urls()— private helper resolving 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.Why the login screen
Benchmarking the Dashboard on a throttled Fast 4G connection, 10 runs per condition, medians:
Once the cache is warm the difference vanishes into the run-to-run spread. The cold-cache gap is dominated by request serialization rather than bytes: measured over HTTP/1.1, where the six-connection-per-origin cap turns the 28 extra requests into roughly twenty round trips at 85 ms each. That figure should shrink substantially over HTTP/2, so treat it as an upper bound on what concatenation is worth.
That leaves the cold first admin load as the case worth addressing, and the login screen is a natural place: the user is sitting on it typing credentials, the connection is idle, and the next navigation is almost always into the admin.
State of the measurements
Everything in this section was measured on the preload revision (b3809c8) and has not been re-run since the switch to prefetch. Fresh browser context per run, real login submit, 10 runs per arm, Fast 4G, medians. The control arm is the same login-to-Dashboard flow with the tags suppressed — necessary because the login screen warms ~21 shared assets on its own, so the hard-reload numbers above are not the right baseline for this flow.
Distributions did not overlap: 1244–1280 ms vs 688–728 ms. Cost to the login screen itself was FCP 570 → 568 ms (no regression) but
load908 → 1597 ms.What needs re-running before any of this should be quoted for the current code:
transferSize0). It is the single behaviour that differs most between the two link types and the entire benefit rests on it.loadregression.+689 msis a preload artifact — preloads are part of the document's own fetches and blockload. A prefetch is dispatched at idle priority and should not. That regression may already be gone; it is unmeasured either way.Correction to the previous description
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 (Runtime.consoleAPICalled) but not browser-generated warnings (Log.entryAdded), 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.Design decisions
prefetch, notpreload. These are resources for the next navigation, which is what prefetch describes. Preload fetches at the current document's priority, makes cross-navigation reuse depend entirely on static-file cache headers that core does not control, and warns about resources the document never uses.No
fetchpriority. A prefetch is already dispatched at the lowest priority, sofetchpriority="low"has nothing left to lower. The attribute is defined for external resource links and browsers wire it topreload,modulepreload, scripts, images and iframes rather than toprefetch.asis kept — it gives the request the same destination the admin will later ask for, which is what lets the response be reused.login_head, notlogin_footer.prefetchis body-ok, so the footer would be valid, but the cost in the head is small and already downstream of the critical path. The 24 tags add 2,891 bytes raw and 253 bytes gzipped — they compress hard, being near-identical. The whole login document is 3.7 KB gzipped, inside one initial congestion window. Hook priority puts them after the login screen's own render-blocking CSS (stylesheets on lines 7–14, first prefetch on line 15), so the preload scanner has found every render-blocking resource before reaching a prefetch byte. Footer placement would move 253 bytes out of a position that is already behind the critical path, at the cost of a later prefetch start — and start time determines whether the fetch completes before the user submits.The handle 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 — they are bundled together precisely because they were concatenated. Checked across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: all 6 scripts and 24 of the original 25 styles appear on every one.site-healthwas the sole exception and has been dropped.Gate is a prediction, not a reading.
$concatenate_scriptscannot be used on the login screen:script_concat_settings()usually runs beforelogin_initfires, since registering any script oninitis enough to trigger it, and at that pointis_admin()is false, so the global settles onfalsewhatever the constant says. A side effect is that the login screen itself never concatenates even with the constant on. This gates onCONCATENATE_SCRIPTS && ! SCRIPT_DEBUGinstead, which is whatscript_concat_settings()would compute for the admin request. 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.Review findings
Addressed: switched to
prefetch(2); restricted to the login form action and interim login (3, partly —Save-Datais not honored); dropped the screen-specific handle; narrowed the filter's documented contract to the attributes actually printed (11).Not addressed: framing this as a complement rather than a replacement (1) — see the closing note; dropping the script handles (4); deriving the handle list or adding a drift test (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
CONCATENATE_SCRIPTStofalseandSCRIPT_DEBUGtofalse.wp-login.php. View source: 24<link rel="prefetch">tags, after the login screen's own stylesheets.CONCATENATE_SCRIPTStotrueand reload — none. SetSCRIPT_DEBUGtotruewith it stilltrue— they return, since the admin will not concatenate.wp-login.php?action=lostpassword,?action=register, and?interim-login=1— none.wp-login.php?redirect_to=%2Fhello-world%2F— none. With?redirect_to=%2Fwp-admin%2Fpost-new.php— 24.Step 6 is the one still unverified for prefetch.
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
Used for: Running the benchmarks and statistics, 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.