Skip to content

Performance: Parallelize independent boot API requests (server status/config + public extensions) - #23020

Merged
AndyButland merged 2 commits into
v17/devfrom
v17/improvement/parallelize-boot-api-requests
Jun 1, 2026
Merged

Performance: Parallelize independent boot API requests (server status/config + public extensions)#23020
AndyButland merged 2 commits into
v17/devfrom
v17/improvement/parallelize-boot-api-requests

Conversation

@iOvergaard

@iOvergaard iOvergaard commented May 29, 2026

Copy link
Copy Markdown
Contributor

What

Two boot-sequence requests that were awaited serially, but are independent, now run in parallel:

  1. UmbServerConnection.connect()server/status and server/configuration were awaited one after the other. They share no data, so they now run via Promise.allSettled. allSettled (not Promise.all) is deliberate: both are required for the backoffice to function, so any failure is collected and rethrown for the existing error-page handling, and a rejection in one call never leaves the other's rejection unhandled.
  2. App startup (app.element.ts) — public (login) extension registration was fully awaited before the auth flow. It's now kicked off up front and awaited only just before routing (where the login screen actually needs it), so its manifest/public fetch overlaps the auth/token request.

Why

Each serialized hop is one management-API round-trip. That's negligible on localhost (~14 ms) but ~150 ms each on high-latency hosts (e.g. Umbraco Cloud), where they sit on the critical boot path before the backoffice is interactive. Tracing a document-workspace load on Cloud showed these as a serial chain (status → configuration → … → public extensions), so parallelizing the independent links shortens time-to-interactive on first paint with no behavioural change.

Notes

  • No functional change: same requests, same error handling (connect failures still surface on the app error page; registerPublicExtensions uses tryExecute, which resolves rather than rejects).

How to test

  1. Run the backoffice and confirm normal startup + login still work.
  2. (Optional) Throttle network or test on a high-latency host and compare the boot waterfall — server/status and server/configuration should now overlap, as should manifest/public with the token request.

🤖 Generated with Claude Code

UmbServerConnection.connect() awaited server status and configuration
sequentially even though they are independent reads; run them with
Promise.allSettled so both errors surface (the app cannot function
without either) while saving a round-trip.

During app startup, public (login) extension registration was awaited
before the auth flow; kick it off in parallel and await it only before
routing, where the login screen actually needs it.

Each serialized call costs a full management-API round-trip, which is
negligible locally but ~150 ms each on high-latency (e.g. Cloud) hosts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 29, 2026 13:16
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown

Claude finished @iOvergaard's task in 4m 31s —— View job


PR Review

Target: origin/v17/dev · Based on commit: 52bfbad9

Parallelizes two independent boot-sequence request pairs: server/status + server/configuration now run via Promise.allSettled, and manifest/public (public extension registration) is kicked off before the auth flow and awaited just before routing.

  • Modified public API: UmbServerConnection.connect() — error type changes from a single raw error to AggregateError when both parallel calls fail
  • Other changes: Boot sequence timing improved; no functional behavior change for callers

Suggestions

  • server-connection.ts:49–55: When #setStatus() resolves but #setServerConfiguration() rejects, this.#isConnected is already set to true inside #setStatus() before connect() throws. In practice this is harmless — the object is always used as await new UmbServerConnection(...).connect() so a throw means the reference is discarded — but it's a subtle partial-state observable on the isConnected stream. If you want to make the invariant airtight, you could defer this.#isConnected.setValue(true) until after both promises settle successfully (e.g. move it into connect() after the allSettled check). Fix this →

  • app.element.ts:262–265: If clearTokenStorage() or #setAuthStatus() throws before await registerPublicExtensions is reached, the registration promise floats in the background while the error page renders. tryExecute means it resolves rather than rejects, so there's no unhandled-rejection concern — just a redundant network request completing after the page is in an error state. Worth a comment noting this is intentionally fire-and-forget in the error path, or prefixing with void at the point of use to make the intent explicit.


Approved with Suggestions for improvement

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

The parallelization logic is sound: Promise.allSettled is the right primitive here (vs Promise.all) since both calls must succeed and neither should leave the other's rejection unhandled. The "fire and await later" pattern for public extensions is clean and the await position is correct (after auth, before routing). No breaking changes.

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

Parallelizes two independent boot-time API call chains in the backoffice to reduce time-to-interactive on high-latency hosts, without altering observable behaviour or error handling.

Changes:

  • UmbServerConnection.connect() runs #setStatus() and #setServerConfiguration() via Promise.allSettled, then rethrows a single error or an AggregateError if any failed.
  • App startup kicks off registerPublicExtensions() before the auth flow and only awaits it just before routing, overlapping it with the token request.

Reviewed changes

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

File Description
src/Umbraco.Web.UI.Client/src/packages/core/server/server-connection.ts Run status and configuration fetches concurrently; aggregate errors so neither rejection is lost.
src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts Start public extension registration eagerly and await it just before routing, in parallel with auth init.

@iOvergaard iOvergaard changed the title Backoffice: Parallelize independent boot API requests (server status/config + public extensions) Performance: Parallelize independent boot API requests (server status/config + public extensions) May 29, 2026
Move isConnected.setValue(true) out of #setStatus() into connect() after
the allSettled check, so the observable never reflects a partially
established connection when configuration fails but status succeeded.

Addresses review feedback on the parallelized connect().

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

Copy link
Copy Markdown
Contributor Author

Thanks for the review.

  • Suggestion 1 (partial isConnected state) — applied in 58e22c9: moved isConnected.setValue(true) out of #setStatus() into connect() after the allSettled check, so the observable only fires once both calls succeed.
  • Suggestion 2 (floating registerPublicExtensions in the error path) — left as-is intentionally. registerPublicExtensions goes through tryExecute, which resolves rather than rejects, so there's no unhandled rejection — just a redundant request finishing after the error page renders, which is harmless. The suggested void-at-use doesn't apply since it's genuinely awaited in the success path; the float only occurs if auth throws first. Happy to add a clarifying note if preferred.

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

All looks good @iOvergaard. Code changes make sense to consolidate the #isConnected setting only after both requests succeed, and the parallelisation makes sense.

I've verified login, backoffice rendering and noted via dev tools that the configuration and status request now no longer run sequentially.

@AndyButland
AndyButland merged commit 4a621a1 into v17/dev Jun 1, 2026
30 checks passed
@AndyButland
AndyButland deleted the v17/improvement/parallelize-boot-api-requests branch June 1, 2026 06:20
iOvergaard added a commit that referenced this pull request Jun 19, 2026
Reconciles app.element.ts with #23020 (parallelized public extensions).
Kept the boot gate (await the app-entry-point initializer before routing)
and restored a blocking inline `await registerPublicExtensions()` instead
of the parallelized deferred form — a marginally slower but more robust
boot, identical to the release/17.5.0 fix (no empty-first-pass timing
reliance). extension-initializer-base.ts, the unit test, the acceptance
test and playwright config merge cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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