Skip to content

Performance: Coalesce JavaScript chunks around the Backoffice to reduce the number of requests#22896

Merged
madsrasmussen merged 2 commits into
v17/devfrom
v17/improvement/67983-core-bundling
May 22, 2026
Merged

Performance: Coalesce JavaScript chunks around the Backoffice to reduce the number of requests#22896
madsrasmussen merged 2 commits into
v17/devfrom
v17/improvement/67983-core-bundling

Conversation

@iOvergaard

@iOvergaard iOvergaard commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

One-line config change. Sets experimentalMinChunkSize=10_000 as the default in the shared Vite helper so every workspace coalesces sub-10 KB Rollup chunks. Pure build-config change — no source moved, no public import paths changed, runtime semantics unchanged. Entry chunks (one per public @umbraco-cms/backoffice/<sub> subpath) are never removed, so every importmap entry keeps resolving against the same dist file.

Additive toward the broader perf concern in #21152 (too many tiny JS files at backoffice startup), not a full resolution.

What changed

File Change
src/Umbraco.Web.UI.Client/src/vite-config-base.ts New minChunkSize arg with default 10_000. Threads through to Rollup's output.experimentalMinChunkSize. Workspaces can override (pass 0 to disable).

Build output (dist-cms/)

Metric Before After Δ
.js files in packages/core 981 272 −72 %
.js files in external/monaco-editor 93 58 −38 %
Total .js files across dist-cms 2 194 1 401 −36 %
Raw bytes (concat .js) 4 779 847 4 846 054 +1.4 %
Gzipped (concat .js) 1 285 720 1 269 993 −1.2 %

Raw bytes tick up slightly because merged chunks add a small overhead of preserved import statements; gzip wins it back and then some.

Browser-side requests

Measured live via Playwright on https://localhost:44339/umbraco. The Performance Resource Timing API caps at 250 entries by default, so these come from Playwright's own (uncapped) network capture.

Scenario Before After Δ
Welcome dashboard (first paint) 510 .js 497 .js −13 (−2.5 %)
Doc editor with RTE + Block Grid + real content 671 .js 622 .js −49 (−7.3 %)

The win scales with page complexity. On the heavy editor case (the user-reported pain point in #21152), packages/core accounts for −32 of the requests, packages/ufm for −11, and documents/block/relations/multi-url-picker deliver the rest.

Time-to-First-Edit, network-throttled

Fresh Chromium context per run, login NOT included in the timing. Measurement: navigate to the doc editor (RTE + Block Grid) and time to a typed character landing in the Rich Text Editor. Chrome DevTools Protocol's Network.emulateNetworkConditions applied after login so only the measured navigation is throttled. Medians of n=5–10 runs per cell.

Network Before After Δ
Real internet — 50 ms / 20 Mbps (typical office/home over real internet) 5 321 ms 4 394 ms −927 ms (−17.4 %)
Fast 3G — 150 ms / 1.6 Mbps 15 231 ms 15 149 ms −82 ms (−0.5 %)
Slow 3G — 400 ms / 0.5 Mbps 42 320 ms 42 266 ms −54 ms (−0.1 %)

Where the PR wins: typical content-editor networks (decent bandwidth, moderate latency). About one second faster to first keystroke on a RTE + Block Grid document — driven by ~50 fewer HTTP request round-trips multiplied against ~50 ms RTT.

Why mobile/3G is flat: those measurements are throughput-bound (gzipped payload is essentially identical before vs after, ~2.5 MB), so a file-count reduction without a bytes reduction doesn't move the needle there. Reducing bytes is a separate optimization tracked in the v18 followups.

Threshold rationale

10 KB sits at the knee of the sweep curve for packages/core:

Threshold core .js files gzipped (concat) top chunk
off 981 1 285 720 211 933
5 KB 312 1 273 488 211 933
10 KB (chosen) 272 1 269 993 211 933
20 KB 241 1 268 740 211 933
50 KB 198 1 267 510 247 412

10 KB gives the best file-count-vs-largest-chunk balance; 50 KB starts bloating the top chunk past 240 KB without meaningful gzip improvement.

Why stop here

A follow-up prototype consolidated packages/core further (down to 253 files) by collapsing the per-subpath entries into a single index.js with stubs. Build-time wins continued, but at runtime the consolidated bundle hit a TDZ error: dynamic-import descendants that subclass UmbLitElement couldn't access the symbol because the parent index.js hadn't finished initialising. Fixing that requires hoisting shared base classes out of core into stable libs — a multi-PR refactor that doesn't belong in v17.

Test plan

  • npm run check:circular — PASS
  • npm run check:module-dependencies — PASS (14 bidirectional imports, all pre-existing; 5 illegal imports at the threshold, all pre-existing)
  • npm run check:paths — PASS
  • npm run lint:errors — PASS
  • npm test — PASS (253 test files, 1834 tests, 0 failures)
  • npm run build:for:cms end-to-end — PASS
  • Backoffice loads and renders correctly on the running CMS instance (login + welcome dashboard + RTE+BlockGrid document editor verified via Playwright)
  • All 63 core subpath entries preserved in dist; importmap unchanged

Copilot AI review requested due to automatic review settings May 19, 2026 12:56
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown

Claude finished @iOvergaard's task in 2m 34s —— View job


PR Review

Target: origin/v17/dev · Based on commit: 2a20fc76 · Skipped: 6 docs files out of 8 total

Adds opt-in minChunkSize to the shared Vite helper and opts packages/core in at 10 000 bytes, cutting the emitted .js file count from 981 to 272 (−72%) with no changes to public import paths or runtime semantics.

  • Other changes: Build output for packages/core now emits ~710 fewer .js chunks; gzip size decreases by 1.2%. All other workspaces are byte-identical to before.

Suggestions

  • src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts:14: Comment includes (AB#67983) — a task reference that will become stale once the task closes. Per the project's comment policy, task/fix/PR references belong in commit messages, not source. The rest of the comment (the WHY, empirical tuning note, bench reference) is valuable and should stay; just drop the task ID.

Approved with Suggestions for improvement

Good to go, but please carefully consider the importance of the suggestions.


Labels applied: area/frontend, category/performance

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR is a build-config only change that opts packages/core into Rollup's experimentalMinChunkSize (10 KB) to coalesce many tiny non-entry chunks, reducing emitted .js files in dist-cms/packages/core from 981 → 272 (-72%) while keeping all 63 public subpath entries byte-stable. It's an additive perf improvement toward issue #21152 (too many small JS files at backoffice startup); full consolidation is deferred to v18 due to source-level cycles.

Changes:

  • Adds optional minChunkSize to the shared getDefaultConfig helper, threading to Rollup output.experimentalMinChunkSize; default undefined keeps every other workspace byte-identical.
  • Opts packages/core/vite.config.ts in at 10_000 bytes.
  • Adds design spec, plan, sweep/baseline/final bench notes, and a unified-workspace-build spike note.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/Umbraco.Web.UI.Client/src/vite-config-base.ts Adds optional minChunkSize arg and forwards to rollupOptions.output.experimentalMinChunkSize.
src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts Opts core package in at 10 KB threshold.
docs/superpowers/specs/2026-05-19-core-bundling-design.md Design spec for the chunk-coalescing approach.
docs/superpowers/plans/2026-05-19-core-bundling.md Step-by-step implementation plan.
docs/superpowers/plans/bench/bench-baseline.md Pre-change metrics for packages/core dist.
docs/superpowers/plans/bench/bench-sweep.md Threshold-sweep table justifying 10 KB.
docs/superpowers/plans/bench/bench-final.md Post-change delta and verification gate.
docs/superpowers/plans/notes/unified-workspace-build-spike.md Spike note on a future unified Vite build.

Comment thread src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts Outdated
@claude claude Bot added area/frontend category/performance Fixes for performance (generally cpu or memory) fixes labels May 19, 2026
@iOvergaard iOvergaard force-pushed the v17/improvement/67983-core-bundling branch from 2a20fc7 to 732daa1 Compare May 19, 2026 13:06
@iOvergaard iOvergaard force-pushed the v17/improvement/67983-core-bundling branch from 732daa1 to de57505 Compare May 19, 2026 18:23
@iOvergaard iOvergaard changed the title Backoffice: Coalesce small chunks in packages/core (AB#67983) Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) May 19, 2026
@iOvergaard iOvergaard force-pushed the v17/improvement/67983-core-bundling branch from de57505 to 7613441 Compare May 19, 2026 18:46
@iOvergaard iOvergaard force-pushed the v17/improvement/67983-core-bundling branch from 7613441 to 14254c4 Compare May 19, 2026 19:31
…983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.
@iOvergaard iOvergaard force-pushed the v17/improvement/67983-core-bundling branch from 14254c4 to 98f9a5f Compare May 19, 2026 19:56
Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@madsrasmussen madsrasmussen merged commit 5d76706 into v17/dev May 22, 2026
30 checks passed
@madsrasmussen madsrasmussen deleted the v17/improvement/67983-core-bundling branch May 22, 2026 07:28
iOvergaard added a commit that referenced this pull request May 22, 2026
…983) (#22896)

* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iOvergaard

iOvergaard commented May 22, 2026

Copy link
Copy Markdown
Contributor Author

Picked for 17.5 with 04f0e22

iOvergaard added a commit that referenced this pull request May 22, 2026
…983) (#22896)

* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iOvergaard

Copy link
Copy Markdown
Contributor Author

Picked for 18.0 with 26232e0

iOvergaard added a commit that referenced this pull request May 26, 2026
…ts (AB#68478) (#22951)

* Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478)

Adds a UseUmbracoBackOfficeCacheHeaders middleware that sets
Cache-Control: public, max-age=31536000, immutable on responses served
from the cache-busted backoffice path (/umbraco/backoffice/<hash>/*).
The hash in the URL is derived from the Umbraco version, so the URL
itself invalidates on every release - making 'immutable' safe regardless
of whether individual filenames contain a content hash.

In debug mode the cache-bust hash changes per request, so the header is
set to 'no-cache' to avoid filling the browser disk cache with single-use
entries.

Design is non-destructive to consumer customisation, addressing the
review feedback on the v14 attempt (#14475):

- Does not touch StaticFileOptions; consumer
  services.Configure<StaticFileOptions>(...) and OnPrepareResponse
  callbacks continue to work unchanged.
- Sets the header via Response.OnStarting with a ContainsKey guard, so
  any synchronous Cache-Control set upstream wins; consumer OnStarting
  callbacks registered later fire first (LIFO) and also win.
- Skips non-2xx responses to avoid long-lived caching of error responses.

Related: GH #21152, PR #22896.

* Backoffice: Correct rationale for no-cache in debug mode

Reword the XML doc on UseUmbracoBackOfficeCacheHeaders to reflect that
IBackOfficePathGenerator is a singleton, so the cache-bust hash is
computed once at startup even in debug mode (per Copilot review on
#22951). The reason for no-cache is not "hash changes per request" but
that built assets may change in place during dev iteration; no-cache
allows fast 304 revalidation while no-store would force full
re-downloads.

No functional change.

* Backoffice: Add unit tests for UseUmbracoBackOfficeCacheHeaders

Covers six scenarios via a minimal in-process pipeline composed with
Microsoft.AspNetCore.TestHost:

- Production: 200 under hash prefix gets immutable header
- Debug: 200 under hash prefix gets no-cache
- Non-2xx under prefix: header not set (status gate)
- Path outside prefix: header not set (path gate)
- Consumer synchronous override: ContainsKey guard skips, consumer wins
- Consumer OnStarting override: LIFO ordering lets consumer win

Adds Microsoft.AspNetCore.TestHost to Umbraco.Tests.UnitTests (standard
Microsoft package, version pinned in tests/Directory.Packages.props).

* Backoffice: Extract cache-headers logic into IMiddleware class

Matches the existing Umbraco middleware convention (BootFailedMiddleware,
PreviewAuthenticationMiddleware, UmbracoRequestMiddleware, etc.) per
Kenn's note: prefer UseMiddleware<T>() with a DI-resolved class over
inline builder.Use lambdas.

The new UmbracoBackOfficeCacheHeadersMiddleware:
- Implements IMiddleware; registered as a singleton in AddWebComponents
- Computes prefix and header value once in the constructor (both
  dependencies are singletons themselves, so this is stable)
- Behaviour is unchanged from the inline version

The UseUmbracoBackOfficeCacheHeaders extension method becomes a thin
UseMiddleware<T>() wrapper. Tests updated to register the middleware in
the TestServer DI container so it can be resolved through UseMiddleware.

* Backoffice: Document IMiddleware convention in Web.Common CLAUDE.md

Adds an explicit "Convention" note before the middleware list so future
contributors (and AI assistants) default to the IMiddleware class +
AddSingleton + UseMiddleware<T>() pattern rather than inline
builder.Use(async ...) lambdas. Also lists the new
UmbracoBackOfficeCacheHeadersMiddleware in the folder structure and
middleware reference.

* Backoffice: Tighten middleware convention note with full corroboration

Lists every IMiddleware implementer in the codebase (10/10) and calls
out the two known inline-lambda exceptions (CspNonceExtensions,
WebApplicationExtensions) so the rule reads as the established
convention rather than an absolute, while still steering new work
toward IMiddleware + AddSingleton + UseMiddleware<T>().

* Backoffice: Register cache-headers middleware in AddBackOfficeCore

DI scope validation runs in Development/CI and pre-checks every
singleton's dependency graph can be constructed. The middleware was
registered in AddWebComponents (which runs for every Umbraco bootstrap),
but its IBackOfficePathGenerator dependency is only registered by
AddBackOffice(). The previous CI run on this branch surfaced the
problem in four Delivery-only/Website-only bootstrap tests
(CoreWithDeliveryApi_BootsSuccessfully, DeliveryOnlyScenario_BootsSuccessfully,
etc.) with "Unable to resolve service for type 'IBackOfficePathGenerator'
while attempting to activate 'UmbracoBackOfficeCacheHeadersMiddleware'".

Move the registration alongside IBackOfficePathGenerator in
AddBackOfficeCore (Api.Management), which is the same scope as the
backoffice itself. This also matches the wire-up gate in
UmbracoApplicationBuilder.cs that only calls UseUmbracoBackOfficeCacheHeaders
when IBackOfficeEnabledMarker is registered.

CLAUDE.md updated with the rule ("register the middleware next to its
dependencies' registration") and a pitfall note about DI scope validation.

* Backoffice: Address review feedback from AndyButland (PR #22951)

- Move UseUmbracoBackOfficeCacheHeadersTests from Umbraco.Tests.UnitTests
  to Umbraco.Tests.Integration. It uses HostBuilder + TestServer to
  exercise the real HTTP pipeline, which is integration-shaped rather
  than unit-shaped. Drop Microsoft.AspNetCore.TestHost from UnitTests
  (Mvc.Testing in Integration provides it transitively) and from
  tests/Directory.Packages.props.
- Soften the misleading "no trailing slash" comment in
  UmbracoBackOfficeCacheHeadersMiddleware — we trim anyway, so the
  comment is now framed as defensive normalisation.
- Trim the dense middleware convention note in Web.Common/CLAUDE.md to
  one paragraph (rule + the two known inline-lambda exceptions). Move
  the DI-scope-validation pitfall narrative out of CLAUDE.md and into a
  three-line code comment next to the AddSingleton call in
  AddBackOfficeCore where it actually applies.

* Backoffice: HTTP verb gate, 304 inclusion, namespace + unused using (PR #22951 review)

Three more from AndyButland's review:

1. Verb gate + 304 inclusion in UmbracoBackOfficeCacheHeadersMiddleware.
   Restrict the path-prefix match to GET and HEAD so POST/PUT/DELETE
   responses and OPTIONS (CORS preflight) responses don't get tagged as
   immutable. Include 304 alongside 2xx in the status gate so
   intermediate caches (CDN/proxy) receive the Cache-Control directive on
   revalidation responses too. Extended the test suite with four new
   cases: NotModifiedResponseUnderPrefix_SetsImmutable,
   HeadRequestUnderPrefix_SetsImmutable,
   OptionsRequestUnderPrefix_DoesNotSetHeader,
   PostRequestUnderPrefix_DoesNotSetHeader. All 10 tests pass.
2. Test namespace updated to Umbraco.Cms.Tests.Integration.* to match
   the convention used by ~629 other files in Umbraco.Tests.Integration
   (vs the 2 outliers I copied from).
3. Drop unused 'using Umbraco.Extensions;' from the test file.

* Backoffice: Extract conditional checks to satisfy CodeScene complexity gate

CodeScene flagged InvokeAsync with "Complex Conditional" (advisory rule,
code health impact 9.69) after the verb + 304 additions in the prior
commit. Extract the two checks into IsCacheableAssetRequest and
ShouldSetCacheControl helper methods. No behaviour change; tests still
green (10/10, 149 ms).
@iOvergaard iOvergaard changed the title Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) Performance: Coalesce JavaScript chunks around the Backoffice to reduce the amount of requests May 29, 2026
@iOvergaard iOvergaard changed the title Performance: Coalesce JavaScript chunks around the Backoffice to reduce the amount of requests Performance: Coalesce JavaScript chunks around the Backoffice to reduce the number of requests May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants