Skip to content

Script Loader: Prefetch assets for the next admin screen - #13084

Draft
westonruter wants to merge 16 commits into
WordPress:trunkfrom
westonruter:add/admin-script-style-preloading
Draft

Script Loader: Prefetch assets for the next admin screen#13084
westonruter wants to merge 16 commits into
WordPress:trunkfrom
westonruter:add/admin-script-style-preloading

Conversation

@westonruter

@westonruter westonruter commented Aug 16, 2026

Copy link
Copy Markdown
Member

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 uses rel="prefetch" and covers the editor as well. The headline measurements below were taken against the first revision and have not yet been re-run — details under "State of the measurements".

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. Prefetching them while the login form is on screen puts them in the cache during the time the user spends typing credentials. 24 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. 18 tags.

The two compose: a login whose redirect_to points at post-new.php, or at post.php?action=edit, prefetches both sets — 42 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_to points 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.

Stylesheets only, for the editor

The editor's incremental cost over the Dashboard is 47 files. Split by type:

Subset Handles Gzipped Raw
CSS 17 97.6 KB 634.8 KB
JS 30 1,261.8 KB 4,184.6 KB
Everything 47 1,359.4 KB 4,819.5 KB

Only the stylesheets are prefetched. wp-editor JS alone is 503 KB gzipped and wp-block-library another 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.

Implementation

Three functions 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 to login_head and admin_head at priority 10, 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.
  • _wp_resolve_dependency_urls() — resolves a registered handle to the URL it would load from, mirroring WP_Scripts::do_item() and WP_Styles::do_item(): the version argument, the script_loader_src and style_loader_src filters, and the RTL replace-or-append rules. Prints nothing, does not touch the queue.

The editor set is expressed as 8 roots that expand to 18 handles, so it follows the dependencies declared in wp_default_styles() rather than restating them — wp-edit-post alone accounts for most of the editor chrome. The admin-wide set is still a flat list.

The login_prefetch_admin_assets filter is renamed prefetch_admin_assets, since it is no longer login-specific. Its second argument is the URL of the screen being prefetched for.

Why

Benchmarking the Dashboard on a throttled Fast 4G connection, 10 runs per condition, medians:

Metric Concat on Concat off Δ
Cache disabled — FCP 740 ms 1342 ms +602 ms (+81%)
Cache disabled — LCP 744 ms 1342 ms +598 ms (+80%)
Cache disabled — Load 3574 ms 4322 ms +748 ms (+21%)
Cache enabled — FCP 326 ms 328 ms +2 ms
Cache enabled — LCP 326 ms 328 ms +2 ms
Cache enabled — Load 347 ms 352 ms +5 ms

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. Expect it to shrink substantially over HTTP/2, so treat it as an upper bound on what concatenation is worth.

State of the measurements

The recovery figures below were measured on the first revision (b3809c8), which used preload and covered only the login screen. They have not been re-run. 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.

Metric No links (control) With links Δ
FCP 1256 ms 714 ms −542 ms (−43%)
LCP 1256 ms 714 ms −542 ms (−43%)
Load event 4096 ms 3466 ms −630 ms
Served from cache 21 42 +21

Distributions did not overlap: 1244–1280 ms vs 688–728 ms. Cost to the login screen itself was FCP 570 → 568 ms, but load 908 → 1597 ms.

What still needs measuring:

  1. Whether prefetched responses are reused across the navigation at all. Verified for preload; unverified for prefetch, and the entire benefit rests on it.
  2. The login-screen load regression. +689 ms is a preload artifact — preloads block load, a prefetch at idle priority should not. Possibly already gone.
  3. The editor case end to end. Dashboard → Add New Post has not been benchmarked at all; only the asset sets and payload sizes are measured.
  4. Fast 3G, per the review, where contention would show up first.

Correction to the earlier 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 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.

Design decisions

prefetch, not preload. 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 on static-file cache headers core does not control, and warns about resources the document never uses.

No fetchpriority. A prefetch is already dispatched at the lowest priority. as is 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. prefetch is body-ok so the footer would be valid, but the cost is small and already downstream of the critical path: 24 tags add 253 bytes gzipped on the login screen, 18 tags add 222 bytes on the Dashboard. 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-admin is an alias handle enqueued on every admin screen that pulls in dashboard, edit, themes, nav-menus, widgets, revisions and the rest — 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-health was the sole exception and was dropped.

The gate is a prediction, not a reading. $concatenate_scripts cannot be used on the login screen: script_concat_settings() usually runs before login_init fires, since registering any script on init is enough to trigger it, and at that point is_admin() is false, so the global settles on false whatever the constant says. A side effect is that the login screen itself never concatenates even with the constant on. This gates on CONCATENATE_SCRIPTS && ! SCRIPT_DEBUG instead. That prediction can be wrong if a plugin pre-sets the global or defines the constant only when is_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 with redirect_to=post-new.php will still prefetch editor CSS.
  • The login screen cannot check capabilities either, since no user is authenticated yet; it relies on redirect_to alone.
  • The admin colour scheme (colors) is universal but deliberately not prefetched from the login screen, since the scheme is a per-user setting and the user is unknown at that point. Same class of problem as the locale mismatch below.

Review findings

Addressed: switched to prefetch (2); restricted to the login form action and interim login (3, partly — Save-Data is still not honored); 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).

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; dropping the script handles (4); a drift test comparing the lists against the concat output (5); locale mismatch between the login screen and the admin (6); args/#fragment in 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_SCRIPTS false and SCRIPT_DEBUG false, view source and count <link rel="prefetch"> tags:

Screen Expected
wp-login.php 24
wp-login.php?redirect_to=%2Fwp-admin%2Fpost-new.php 42
wp-login.php?redirect_to=%2Fwp-admin%2Fpost.php%3Fpost%3D1%26action%3Dtrash 24
wp-login.php?redirect_to=%2Fhello-world%2F 0
wp-login.php?action=lostpassword, ?interim-login=1 0
Dashboard, Posts list, Pages list 18
post-new.php, Plugins, Settings, Media 0
Anything, with CONCATENATE_SCRIPTS true 0

Then log in, or navigate from the Dashboard to Add New Post, and confirm the prefetched URLs match what the next screen requests and are served from cache. That last step 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 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.

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>
@manzoorwanijk

Copy link
Copy Markdown
Member

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, LOCAL_DIR=build, minified assets, SCRIPT_DEBUG=false) with the Chrome DevTools MCP server.

Summary

  • Cold loads (cache bypassed) on a throttled connection are where concatenation matters: removing it makes Dashboard FCP/LCP go from 616 ms to 1304 ms with gzip on (+688 ms, +112%) and from 1296 ms to 1484 ms with gzip off (+188 ms, +15%).
  • With a primed browser cache the difference is noise: +12 ms (+5%) with gzip on, within a few ms with gzip off.
  • Unthrottled on localhost, concat off is marginally faster (160 ms vs 172 ms), i.e. the PHP cost of load-styles.php / load-scripts.php outweighs the request savings when latency is near zero.
  • The whole effect comes from CSS: concat collapses 26 render-blocking stylesheets into one load-styles.php request. Scripts are barely concatenated on the Dashboard (only 6 handles in 2 load-scripts.php bundles; the other ~80 scripts load individually in both modes because they carry inline data or translations).
  • FCP and LCP were identical (or within 30 ms) in every run; the LCP element is the "Welcome" H2 heading, so both metrics are gated by render-blocking CSS.

Results

Fast 4G is Chrome DevTools' built-in preset applied via CDP Network.emulateNetworkConditions. N = 10 recorded runs per cell after 2 unrecorded warm-ups. IQR is p25 to p75.

Throttle gzip Cache Concat N Median FCP (ms) FCP IQR Median LCP (ms) LCP IQR Requests Transfer
Fast 4G on bypassed on 10 616 612 to 619 616 612 to 619 89 1354 KB
Fast 4G on bypassed off 10 1304 1300 to 1307 1304 1300 to 1307 117 1375 KB
Fast 4G on primed on 10 252 248 to 260 252 248 to 260 90 1 KB
Fast 4G on primed off 10 264 261 to 264 264 261 to 264 118 1 KB
Fast 4G off bypassed on 10 1296 1293 to 1296 1296 1293 to 1296 89 4360 KB
Fast 4G off bypassed off 10 1484 1481 to 1487 1484 1481 to 1487 117 4369 KB
Fast 4G off primed on 10 260 256 to 260 276 273 to 280 90 3 KB
Fast 4G off primed off 10 256 256 to 260 282 280 to 284 118 3 KB
None on bypassed on 10 172 172 to 175 172 172 to 175 89 1354 KB
None on bypassed off 10 160 153 to 163 160 153 to 163 117 1375 KB

Delta of "concat off" versus "concat on" (positive = removing concat is slower):

Throttle gzip Cache FCP on FCP off Delta ms Delta % LCP on LCP off Delta ms Delta %
Fast 4G on bypassed 616 1304 +688 +112% 616 1304 +688 +112%
Fast 4G on primed 252 264 +12 +5% 252 264 +12 +5%
Fast 4G off bypassed 1296 1484 +188 +15% 1296 1484 +188 +15%
Fast 4G off primed 260 256 -4 -2% 276 282 +6 +2%
None on bypassed 172 160 -12 -7% 172 160 -12 -7%

Request shape per condition (from the network log):

  • Concat on: 3 stylesheet requests (1 load-styles.php bundling 26 handles, thickbox.css, colors.min.css) plus editor.min.css, and 2 load-scripts.php bundles (jquery-core,jquery-migrate,utils and hoverIntent,wp-dom-ready,wp-hooks) plus ~80 individual scripts.
  • Concat off: 28 stylesheet requests and 84 script requests, all individual files.
  • Transferred bytes are essentially the same in both modes (1354 vs 1375 KB gzipped, 4360 vs 4369 KB raw); the difference is request count and HTTP/1.1 connection queuing, not payload.

Raw per-run values (ms)

  • Fast 4G, gzip on, bypassed, concat on: FCP 620, 612, 612, 616, 624, 616, 612, 616, 620, 612; LCP same as FCP
  • Fast 4G, gzip on, bypassed, concat off: FCP 1304, 1296, 1304, 1300, 1308, 1312, 1300, 1300, 1304, 1308; LCP same as FCP
  • Fast 4G, gzip on, primed, concat on: FCP 248, 260, 248, 260, 252, 268, 340, 252, 244, 248; LCP same as FCP
  • Fast 4G, gzip on, primed, concat off: FCP 264, 252, 264, 264, 268, 264, 264, 260, 264, 256; LCP same as FCP
  • Fast 4G, gzip off, bypassed, concat on: FCP 1296, 1296, 1288, 1296, 1292, 1300, 1292, 1296, 1300, 1296; LCP same as FCP
  • Fast 4G, gzip off, bypassed, concat off: FCP 1484, 1484, 1488, 1480, 1484, 1480, 1476, 1484, 1488, 1488; LCP same as FCP
  • Fast 4G, gzip off, primed, concat on: FCP 260, 264, 260, 256, 256, 260, 256, 260, 260, 256; LCP 276, 280, 284, 272, 272, 276, 280, 276, 288, 272
  • Fast 4G, gzip off, primed, concat off: FCP 256, 260, 264, 256, 260, 256, 252, 256, 260, 256; LCP 272, 284, 280, 280, 284, 284, 280, 284, 284, 280
  • Unthrottled, gzip on, bypassed, concat on: FCP 172, 168, 168, 176, 172, 172, 176, 172, 172, 196; LCP same as FCP
  • Unthrottled, gzip on, bypassed, concat off: FCP 164, 164, 152, 160, 156, 160, 160, 148, 152, 164; LCP same as FCP

Method

  • Fixture: trunk at 3150f656e2 (includes the gzip toggle from PR Build/Test Tools: Enable text compression in the local Docker environment #12529), npm run build, .env with LOCAL_DIR=build and LOCAL_NGINX_COMPRESSION=on|off, env restarted on every gzip switch and verified with curl -I (Content-Encoding: gzip present or absent).
  • Toggle: wp-config.php defines SCRIPT_DEBUG and CONCATENATE_SCRIPTS from $_GET['script_debug'] / $_GET['enable_concat'] (exact true/false strings; defaults false/true), sets WP_DEBUG_DISPLAY false and DISABLE_WP_CRON true. URLs measured: /wp-admin/?enable_concat=true&script_debug=false and /wp-admin/?enable_concat=false&script_debug=false.
  • Static asset caching: the local nginx template got a location ~* \.(js|css)$ { expires 1y; add_header Cache-Control "public"; } block so individual assets are cacheable like on production hosts (otherwise the primed comparison is unfairly tilted toward load-*.php, which sends its own 1 year max-age).
  • Browser: dedicated isolated Chrome context, logged in as admin, viewport 1440x900, no extensions, tab in foreground, no interaction during loads.
  • Cache bypassed: CDP hard reload (Page.reload with ignoreCache), which refetches every subresource (verified: 114 of 117 resources with non-zero transferSize each run). Primed: one priming load, then soft reloads; verified all subresources served from cache (transferSize 0).
  • Metrics: read in-page after load + 1.5 s settle via performance.getEntriesByName('first-contentful-paint') and a buffered largest-contentful-paint PerformanceObserver (last entry), recorded per run, median and IQR computed offline. Raw JSON per cell is in the session scratchpad results/ folder.
  • Preflight per condition confirmed the presence/absence of load-scripts.php / load-styles.php, request counts, .min assets and Content-Encoding.

Caveats

  • Run-to-run spread is tiny (IQR 3 to 8 ms) because DevTools throttling is a deterministic simulation on top of a localhost server. Real networks will be noisier; the direction and magnitude of the cold-load penalty on HTTP/1.1 is what to take from this, not the exact ms.
  • "Cache bypassed" reuses warm TCP connections across reloads and is a repeated-navigation scenario, not a true first visit (no DNS/TCP handshake cost is included). A real first visit would make the concat-off penalty on HTTP/1.1 slightly larger.
  • Only the Dashboard was measured. Screens with larger CSS/JS bundles (post editor: 36 style handles, customizer) will show a bigger cold-load gap.
  • Chrome's Fast 4G preset values are what the current Chrome build ships; the observed document TTFB under throttling was ~100 ms.

Interpretation for the removal decision

  • Removing concatenation with no replacement is a clear regression for cold loads on HTTP/1.1: roughly 2x FCP/LCP on the Dashboard with gzip, ~15% without gzip. Returning visitors with a warm cache are unaffected.
  • The regression is entirely about the number of render-blocking CSS requests. Any replacement only needs to address CSS delivery on first load (fewer stylesheet requests, preload, or 103 Early Hints); the script side is already effectively unconcatenated on this screen.
  • HTTP/2 and HTTP/3 (phase 2) should shrink this gap substantially since request multiplexing removes the 6-connection HTTP/1.1 limit; that measurement is the next step before deciding.

Environment state left in place (revert notes)

These local changes are still applied so phase 2 can continue; none are committed:

  • .env: LOCAL_DIR=build, LOCAL_NGINX_COMPRESSION=on (was src, unset). Revert and npm run env:restart to get back to the normal dev setup.
  • wp-config.php: SCRIPT_DEBUG / CONCATENATE_SCRIPTS query toggles, WP_DEBUG_DISPLAY false, DISABLE_WP_CRON true (was SCRIPT_DEBUG true, WP_DEBUG_DISPLAY true).
  • tools/local-env/default.template: the expires 1y block for .js/.css (shows in git diff).
  • concat-perf-strategy.md and this file are untracked in the repo root.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Comment thread src/wp-includes/default-filters.php Outdated
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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/wp-includes/script-loader.php Outdated
@manzoorwanijk

manzoorwanijk commented Aug 16, 2026

Copy link
Copy Markdown
Member

Combined review of Claude and Codex

Preload the admin's unconcatenated assets from the login screen

Reviewed against concat-perf-findings.md and concat-perf-strategy.md (HTTP/1.1, Dashboard, Fast 4G, local Docker env, LOCAL_DIR=build, gzip on). The PR branch was checked out and its two PHP files were copied into build/ for testing; the copies were reverted afterwards. Codex reviewed the same diff independently; its findings are folded in and attributed below.

Verdict

The 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 wp-login.php, uses rel="preload" for a next-navigation resource (which Chrome does warn about, contrary to the PR description), and pushes 25 speculative requests onto every login-family screen. Worth continuing as a complementary optimization if the handle list is derived rather than hardcoded and the semantics are switched to prefetch, but it should not be the argument for retiring load-styles.php.

What was verified

  • Handle lists match exactly what concatenation produces on the Dashboard today: the 25 style handles are the two load-styles.php chunks (wp-pointer is split across the chunk boundary, which is why the findings doc counted 26) and the 6 script handles are the two load-scripts.php bundles.
  • With CONCATENATE_SCRIPTS=false, SCRIPT_DEBUG=false: 25 <link rel='preload'> tags after the 7 login stylesheets, none with concat on. All 25 URLs match Dashboard request URLs byte for byte, and after login all 31 preloaded assets (including the 6 shared with login) are served with transferSize 0.
  • Effect on the first Dashboard load after login, Fast 4G, cold cache, concat off: FCP 1128 ms (trunk) vs 572 ms (PR). For reference the findings doc has concat on at 616 ms and concat off at 1304 ms on a hard reload, so the PR recovers the whole FCP gap for this one path. load went 3738 ms to 3052 ms; 41 vs 20 of 118 resources from cache.
  • Cost on the login screen itself, Fast 4G, cold cache: FCP 572 ms (trunk) vs 580 ms (PR), so render is not hurt. load event moved from 900 ms to 1475 ms and resource count 23 to 44. Extra transfer is 129 KB gzipped (557 KB raw), about 2.6x the login page's own CSS payload.
  • Chrome logs 21 "was preloaded using link preload but not used within a few seconds from the window's load event" warnings on the login screen (every preload except jquery-core, jquery-migrate, wp-dom-ready, wp-hooks, which the login page consumes itself). The PR description says no such warnings appeared; that is not what a default install shows.

Findings

Design

  1. Scope of the win is narrow. It only helps a cold cache reached via wp-login.php. Cold-cache admin loads that never see the login screen are common and arguably more common for logged-in users: every core update changes every ver string, plugin updates change theirs, and remember-me cookies last 14 days. Those paths still pay the full concat-off penalty from the findings doc (+688 ms FCP on HTTP/1.1 with gzip). The PR should be framed as a complement, not a replacement.
  2. preload vs prefetch. The resources are for the next navigation, which is what prefetch means. Consequences of using preload here: unused-preload console warnings on every login page load (verified), preload priority semantics rather than idle-time semantics, and cross-navigation reuse depends entirely on the static files' HTTP cache headers, which core does not control. On hosts with no Cache-Control/Expires on .css/.js, freshly deployed files get short heuristic freshness and the Dashboard will revalidate each one (304 per file, still one HTTP/1.1 round trip each). Chrome keeps prefetch responses reusable for 5 minutes regardless of cacheability, which fits this use case better. This needs a browser-by-browser check either way (Codex raises the same point).
  3. Runs on every login_head, not just the login form: lostpassword, resetpass, register, logout confirmation, interim-login, failed logins. Speculatively fetching 129 KB gzipped for a user who lands on "check your email" is wasted, and Save-Data is not honored (Codex).
  4. Scripts are not worth preloading. Both the findings doc and the PR's own numbers show the gap is CSS. Dropping the 6 script handles removes the custom script URL resolver (and finding 6 below) for negligible loss.
  5. Hardcoded list will drift. Nothing ties it to what WP_Styles::do_item() actually concatenates. site-health is capability-gated on the Dashboard, admin-bar can be disabled, wp-auth-check can be filtered off (Codex). A more robust shape: register the list next to the wp-admin alias in wp_default_styles(), or derive it from the wp-admin handle's dependency tree plus a small explicit set, and add a test that compares it against the concat output on the Dashboard.
  6. Locale mismatch. The login screen resolves URLs with the site (or wp_lang) locale; the admin uses the user locale. An RTL user on an LTR site preloads the LTR files and then loads the RTL ones. Edge case, but it is silent waste.

Correctness in _wp_resolve_dependency_urls()

  1. Script branch drops $dependencies->args[ $handle ] and the #fragment handling that WP_Scripts::do_item() has (class-wp-scripts.php around the $added_args block). No core handle in the list uses either, but the helper claims to mirror do_item() and a plugin passing handle?arg would preload a URL that never gets requested. Goes away if scripts are dropped (finding 4).
  2. script_loader_src / style_loader_src run in a logged-out, non-admin request. Filters that branch on is_admin(), screen or user (CDN rewriters, per-user asset URLs) can produce a URL that differs from the one the admin request will load, defeating the cache hit. Also 31 extra filter invocations on an unauthenticated page (Codex). Worth a sentence in the docblock at least.
  3. Gate: script_concat_settings() honors a pre-set $concatenate_scripts global and plugins can define CONCATENATE_SCRIPTS only when is_admin(); the constant-only prediction can be wrong in both directions (Codex). Acceptable given the login-screen quirk the PR describes, but the docblock should say the gate is a prediction.
  4. Double escaping is harmless: _css_href() already returns esc_url() output and esc_url() is idempotent (verified), but the RTL branch string-replaces on an escaped URL exactly like do_item() does, so it is at least consistent.
  5. Filter contract: the docblock says the login_preload_admin_assets filter takes the same shape as wp_preload_resources, but the printer only emits href, as, fetchpriority; crossorigin, type, media are dropped (Codex). Either print the same attribute set as wp_preload_resources() or narrow the docblock.
  6. Not idempotent: calling wp_preload_admin_assets() twice prints the tags twice (Codex). Minor.

Verified as fine

  • Hook priority 10 after print_admin_styles/wp_print_head_scripts at 9, so the done check correctly skips the 6 login-shared handles.
  • RTL replace/append logic matches WP_Styles::do_item(); core admin styles all use replace with a suffix.
  • Escaping of filtered output (esc_url, esc_attr, type checks) is fine.
  • Login FCP is not regressed by the extra requests on Chrome with fetchpriority="low" (measured +8 ms).

Suggested direction

  • Keep the idea, switch to rel="prefetch" (or measure both across Chrome, Firefox, Safari with and without static cache headers before deciding).
  • Styles only; drop the script preloads and the script resolver.
  • Restrict to the login form action, skip Save-Data, skip interim-login.
  • Derive the handle list or add a test that fails when it drifts from the Dashboard concat output.
  • Re-benchmark the login screen on Fast 3G / mobile, since the cost is bandwidth rather than render.
  • Do not use this as the basis for removing concatenation; the phase 2 HTTP/2 and HTTP/3 measurement in the strategy doc is still the missing input.

Codex review (verbatim summary of its verdict)

"Overall verdict: not a sound approach. The URL resolver has fixable correctness gaps, but the larger problem is architectural: preload is being used for a future navigation and forces a sizable speculative download on every login-family screen. A CSS-only concatenation strategy, or admin-response Early Hints where supported, is substantially safer."

Where I differ from Codex: it rated the login-screen cost as blocking; measured, login FCP is unaffected and the cost is bandwidth plus a later load event, so I would rate it important rather than blocking. Its wp_installing() point does not apply (the login screen is not shown during install). Its architectural point stands. Full Codex output: session 01a00c8d-755c-7f70-bd92-98cecb0b185c (codex resume 01a00c8d-755c-7f70-bd92-98cecb0b185c).

@haqadn

haqadn commented Aug 16, 2026

Copy link
Copy Markdown
image

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Claude analysis of benefit of preloading

Dashboard load after logging in (10 runs per arm, fresh cache each run)

Metric No preload (control) With preload Δ
FCP 1256 ms 714 ms −542 ms (−43%)
LCP 1256 ms 714 ms −542 ms (−43%)
DOMContentLoaded 3880 ms 3252 ms −628 ms (−16%)
Load event 4096 ms 3466 ms −630 ms (−15%)
Login POST + 302 291 ms 293 ms +2 ms
Requests 123 123 0
Served from cache 21 42 +21
Transferred 1366.9 KB 1271.9 KB −95.0 KB

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:

Scenario FCP
Concat ON, cold, direct nav (earlier) 740 ms
Concat OFF + preload, redirect-adjusted 421 ms
Concat OFF, no preload, redirect-adjusted 965 ms
Concat OFF, cold, direct nav (earlier) 1342 ms

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

Metric No preload With preload Δ
Login FCP 570 ms 568 ms −2 ms
Login load event 908 ms 1597 ms +689 ms
Last preload finished 1570 ms

fetchpriority="low" is doing its job: the login screen's own paint is untouched. What does move is the load event, +689 ms, because it waits on the preloads. Worth knowing if anything hooks window.onload there.

Two caveats on reading this

Why 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.

westonruter and others added 3 commits August 16, 2026 15:28
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>
@westonruter westonruter changed the title Script Loader: Preload the admin's unconcatenated assets from the login screen Script Loader: Pfetch the admin's unconcatenated assets from the login screen Aug 16, 2026
@westonruter westonruter changed the title Script Loader: Pfetch the admin's unconcatenated assets from the login screen Script Loader: Prefetch the admin's unconcatenated assets from the login screen Aug 16, 2026
Comment thread src/wp-includes/script-loader.php Outdated
westonruter and others added 2 commits August 16, 2026 16:14
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>
@westonruter westonruter changed the title Script Loader: Prefetch the admin's unconcatenated assets from the login screen Script Loader: Prefetch assets for the next admin screen Aug 18, 2026
westonruter and others added 10 commits August 18, 2026 13:56
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>
Comment on lines +2698 to +2699
$script_handles = array();
$style_handles = array();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants