Skip to content

Script Loader: Prefetch the admin's unconcatenated assets from the login screen - #13084

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

Script Loader: Prefetch the admin's unconcatenated assets from the login screen#13084
westonruter wants to merge 5 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: 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 uses rel="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 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 handle list is 6 scripts and 24 styles. Six of those are already printed by the login screen itself and are skipped via the done check, so a default install emits 24 tags.

Nothing is printed when:

  • concatenation is enabled, since load-scripts.php and load-styles.php already collapse these handles into a handful of requests;
  • the screen is not the login form — password reset, registration, logout confirmation and check-your-email all render through login_head too, and none leads to the admin;
  • it is an interim login, which re-authenticates in a modal on a page that already has these assets;
  • redirect_to points outside the admin.

Two functions in src/wp-includes/script-loader.php:

  • wp_prefetch_admin_assets() — builds and prints the list, hooked to login_head at priority 10, just after print_admin_styles. Exposes a login_prefetch_admin_assets filter 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, 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.

Why the login screen

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

Metric No links (control) With links Δ
FCP 1256 ms 714 ms −542 ms (−43%)
LCP 1256 ms 714 ms −542 ms (−43%)
DOMContentLoaded 3880 ms 3252 ms −628 ms
Load event 4096 ms 3466 ms −630 ms
Served from cache 21 42 +21
Transferred 1366.9 KB 1271.9 KB −95.0 KB

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 load 908 → 1597 ms.

What needs re-running before any of this should be quoted for the current code:

  1. Whether prefetched responses are reused across the navigation at all. This was verified for preload (all URLs matched byte for byte and were served with transferSize 0). It is the single behaviour that differs most between the two link types and the entire benefit rests on it.
  2. The login-screen load regression. +689 ms is a preload artifact — preloads are part of the document's own fetches and block load. A prefetch is dispatched at idle priority and should not. That regression may already be gone; it is unmeasured either way.
  3. Fast 3G, per the review. The cost here is bandwidth rather than render, and contention against a six-connection HTTP/1.1 pool would show up there first if it shows up at all.

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, 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 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, so fetchpriority="low" has nothing left to lower. The attribute is defined for external resource links and browsers wire it to preload, modulepreload, scripts, images and iframes rather than to prefetch. as is kept — it gives the request the same destination the admin will later ask for, which is what lets the response be reused.

login_head, not login_footer. prefetch is 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-admin is an alias handle enqueued on every admin screen that pulls in dashboard, edit, themes, nav-menus, widgets, revisions and 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-health was the sole exception and has been dropped.

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, which is what script_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 when is_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-Data is 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/#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

  1. Set CONCATENATE_SCRIPTS to false and SCRIPT_DEBUG to false.
  2. Log out and load wp-login.php. View source: 24 <link rel="prefetch"> tags, after the login screen's own stylesheets.
  3. Set CONCATENATE_SCRIPTS to true and reload — none. Set SCRIPT_DEBUG to true with it still true — they return, since the admin will not concatenate.
  4. Visit wp-login.php?action=lostpassword, ?action=register, and ?interim-login=1 — none.
  5. Visit wp-login.php?redirect_to=%2Fhello-world%2F — none. With ?redirect_to=%2Fwp-admin%2Fpost-new.php — 24.
  6. Log in and confirm the prefetched URLs match what the admin requests and are served from cache.

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.

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