Skip to content

Restore landing page performance and serve the site over HTTP/2 - #1836

Open
David Pine (IEvangelist) wants to merge 8 commits into
microsoft:mainfrom
IEvangelist:ievangelist-landing-page-performance
Open

David Pine (IEvangelist) wants to merge 8 commits into
microsoft:mainfrom
IEvangelist:ievangelist-landing-page-performance

Conversation

@IEvangelist

@IEvangelist David Pine (IEvangelist) commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

The landing page's Lighthouse score slipped from the high 80s to the high 70s (mobile, simulated slow 4G), and PageSpeed Insights reported 70. Rather than guess, I built a production-like lab (the production build output served over HTTPS + HTTP/1.1 + Brotli with the same cache headers, because that is what aspire.dev serves today), ran Lighthouse 13 with the mobile preset (simulated Slow 4G, 4× CPU) in a fresh browser profile per run, and bisected the page across the commits that changed it. Every number below is the median of five cold runs. There is one commit per cause, so any of them can be reverted on its own.

Where it regressed

Build (HTTP/1.1) Score FCP LCP CLS Requests
Before the redesign (0b54dce5) 87 2.33 s 3.60 s 0.004 43
Landing redesign (#1420) 84 2.63 s 3.96 s 0.005 75
Release 13.5 content (includes the banner auto-expiry, #1448) 82 2.63 s 3.84 s 0.093 75
main (a097c7e7) 78 2.78 s 4.28 s 0.105 88
This PR 86 (85, 86 and 87 in three separate batches) 2.25 s 3.77 s 0.001 61

Serving the same bytes over HTTP/2: main 90 → this PR 93.

Causes and fixes

I measured each lever on main in isolation before changing any code.

Cause Fix Alone
The banner (#1448) is rendered hidden and a bundled script reveals it after first paint, shifting the whole page down (CLS 0.09). Render it visible and hide it with a tiny pre-paint script when the reader dismissed it or its sunset/auto-dismiss window has passed. A test runs the emitted script against resolveBannerVisibility so the two cannot drift. 78 → 80
Poppins is declared in site.css, so it is only discovered after that stylesheet downloads; text re-flows when it swaps in (CLS 0.08). Preload the three weights the first viewport uses. Each is still fetched once. 78 → 81 (FCP −0.30 s)
The redesign set loading="eager" on 35 icons thousands of pixels below the fold; on HTTP/1.1 they compete with CSS and fonts for six connections. Use the default lazy loading again. Native lazy loading never starts for display: none content, so a small helper (loadLazyImagesNearViewport) loads the icons in hidden environment panels and the inactive AppHost language as their section nears the viewport, and switching panels or language stays instant. 78 → 81 (LCP −0.32 s, 88 → 61 requests)
AsciinemaPlayer imported its CSS from a bundled <script>. Astro attaches that to the whole docs route, so every page shipped it. Import it in the frontmatter. It is now attached only to pages whose content renders the player (in the default locale: /get-started/ai-coding-agents/, /reference/overview/ and /reference/cli/commands/aspire-agent-init/). 78 → 79
The dashboard carousel's ~23 KB script was is:inline, so it was re-sent in every homepage response. Bundle it. The document drops from 45.6 to 40.4 KiB transferred, under the ~43 KB a cold connection delivers in two round trips, so its simulated download is one round trip shorter (1.05 s → 0.90 s). FCP follows in some runs and not others (2.25 s in 4 of 6 batches with the script bundled, 0 of 3 inline), so treat this one as a modest win. A bundled <script> is TypeScript and editors check it under the project's strict settings (the untyped body reported 108 errors), so the script is typed with erasable syntax only; the built chunk is byte-identical with and without the annotations. ≤ +2
aspire.dev is served over HTTP/1.1. The apex resolves to an App Service front end (an unknown host sent to that IP gets "Microsoft Azure Web App – Error 404" with the *.azurewebsites.net certificate, and responses carry no Front Door headers). Its TLS handshake only offers http/1.1, while github.com and learn.microsoft.com select h2 from the same client. App Service leaves HTTP/2 off unless http20Enabled is set. Last commit: set SiteConfig.IsHttp20Enabled in the AppHost. aspire publish before and after differs by exactly http20Enabled: true. 86 → 93 (original page 78 → 90)

Together the frontend changes take CLS from 0.105 to 0.001. I also added guardrail tests for the two mistakes behind this (CSS imported from a bundled script, stray eager images), e2e coverage for the banner's first-paint state, and e2e plus unit coverage for icons that sit behind tabs and language toggles.

Measured and deliberately not changed

  • UnoCSS icon sheet (page-ssr.css, 100 KB raw, 21 KiB brotli, ~0.5% used on the homepage). Removing it entirely was worth at most one point, and every way to scope it per page needs custom build plumbing.
  • AVIF hero (−37 KiB transferred). No change in score.
  • Deferring the consent script to load. About +1 point and it is compliance-sensitive, so I left it alone.
  • Hero preload. No gain.
  • Low-priority module scripts. About +2 (FCP −0.4 s), but Astro cannot express it without rewriting generated HTML.

What this does not reach

LCP is still 3.77 s over HTTP/1.1 (3.15 s over HTTP/2) against the 2.5 s goal. In the final build the simulated LCP chain ends in the third-party consent script (52 KiB) and the module-script waterfall, not in anything this PR can remove cleanly.

The lab is a proxy: it scored the original page 78 where PageSpeed Insights reported 70, so expect PSI to read lower than the numbers here. The HTTP/2 commit only takes effect after the next deployment and I could not test it against production, so check the negotiated protocol (ALPN should select h2) after deploying. It is a separate commit so it can be dropped or applied through your deployment pipeline instead.

Third-party links and affiliations

None. This pull request does not add or change third-party links.

Validation

  • pnpm test:unit: 79 files, 1000 passed, 1 skipped.
  • pnpm lint: passed.
  • pnpm exec playwright test tests/e2e/homepage.spec.ts tests/e2e/ui-regressions.spec.ts tests/e2e/banner.spec.ts -g "loads icons hidden|keeps the environment frame stable|carousel|announcement banner|finite motion|dashboard screenshot" --workers=4 on the desktop, tablet and mobile projects against the dev server: 25 passed, 2 skipped (the carousel client-navigation test only runs on desktop by design). With the default worker count (10 on this machine) the dev server cannot keep up and unrelated image and navigation tests time out intermittently, a different few each run; they pass serially and with four workers. The new hidden-icons test waits up to 60 s for that reason.
  • The same command in CI's configuration (CI=1, astro preview of a production build pruned to the homepage and the pages that render the player, two workers, no retries): 25 passed, 2 skipped, in 41 s. All of homepage.spec.ts and banner.spec.ts against that build: 136 passed, 10 skipped, 4 failed. The four failures navigate to pages the pruned build does not contain (/hub/, and the ~90 links requested by "serves every internal homepage link"), so they only mean something in the full CI build.
  • Mutation checks that the new tests can fail: a pre-paint script that never hides the banner fails the two "stays hidden" specs on desktop and mobile; changing its auto-dismiss comparison from >= to > (a one-millisecond shift) fails the parity test at "exactly at the auto-dismiss boundary"; a lazy-image helper that matches no images fails the hidden-icons spec on desktop, tablet and mobile.
  • dotnet test tests/Aspire.Dev.AppHost.Tests: 4 passed. aspire publish before and after: the only difference is http20Enabled: true in aspiredev.bicep.
  • Production build output (pruned to the homepage plus the pages that render the player): the player stylesheet appears only on /get-started/ai-coding-agents/, /reference/overview/ and /reference/cli/commands/aspire-agent-init/; the banner renders without hidden; the hero is the only eager image; the homepage document is 40.4 KiB transferred.
  • Browser checks against that output: few image requests on load; every environment icon and both AppHost-language icons are loaded once their section nears the viewport (including the ones that are not displayed) and after selecting each panel; a dismissed banner is never painted visible across client-side navigation (all 117 frames sampled after the swap were hidden); the player stylesheet is added by client-side navigation and the player renders styled; on the homepage, a docs page, a community page, /fr/ and a reference page the three preloaded fonts are each fetched exactly once and are the faces the page uses.
  • Types of the bundled carousel script, which CI does not check: tsc with the project's strict settings (astro/tsconfigs/strict) over the extracted script reported 108 errors before and 0 after, and the HomePage, HomeEnvironment and AsciinemaPlayer scripts touched here report 0. Stripping the types gives JavaScript identical to the previous inline body (22,835 characters, byte for byte), and the production build emits the same carousel chunk with and without the annotations (same content hash, size and SHA-256; the same comparison flags a one-token logic change). All carousel e2e tests pass against the typed script (11 passed, 4 skipped by viewport).
  • Lighthouse lab runs: the tables above.
  • CI on this head (fff220c5): all 19 checks passed, including the full production build, all six e2e shards (desktop, tablet and mobile: 1346 tests, no failures, retries or flaky tests, including the 15 new banner and hidden-icon results), validation, the AppHost build, CodeQL and the CI gate.

@IEvangelist
David Pine (IEvangelist) force-pushed the ievangelist-landing-page-performance branch from e75368d to 4e85c4d Compare October 8, 2026 04:47
@IEvangelist David Pine (IEvangelist) changed the title Fix landing page CLS and critical asset loading Restore landing page performance and serve the site over HTTP/2 Oct 8, 2026
The banner shipped `hidden` and a bundled script revealed it after first
paint, shifting the whole page down. It was the largest layout shift on the
landing page (CLS 0.09 in Lighthouse mobile).

Render it visible and hide it with a tiny pre-paint script when the reader has
dismissed it or its sunset or auto-dismiss window has passed. `data-astro-rerun`
re-applies the decision to the incoming page on client-side navigation.

A unit test runs the emitted script against `resolveBannerVisibility` for
dismiss, sunset, auto-dismiss and malformed-storage cases so the two cannot
drift, and a Playwright spec with scripts blocked covers the first-paint state.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Poppins is declared in site.css, so the browser only discovers it after that
stylesheet has downloaded and parsed. First paint waited on it, and text
re-flowed when it swapped in (CLS 0.08 on the landing page).

Preload latin 400, 600 and 700, the weights the first viewport renders. They are
the same files the @font-face rules use, so each is still fetched once.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@IEvangelist
David Pine (IEvangelist) force-pushed the ievangelist-landing-page-performance branch from 4e85c4d to d746799 Compare October 8, 2026 06:15
The landing page redesign set `loading="eager"` on 35 icons thousands of pixels
below the fold. Over HTTP/1.1 they competed with the render-blocking CSS and
fonts for six connections: 88 requests, or 61 without them.

Use Astro's default lazy loading again. Native lazy loading never starts for
`display: none` content, so `loadLazyImagesNearViewport` loads the icons in
hidden environment panels and the inactive AppHost language as their section
nears the viewport. Panel and language switches stay instant without adding
those icons to the initial page load.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
AsciinemaPlayer imported its stylesheet from a bundled <script>. Astro follows
dynamic importers when it attaches CSS, so that import landed on the whole docs
route and every page shipped the player's 15 KB of CSS, including the landing
page, which has no player.

Import it in the component frontmatter instead, which attaches it only to the
pages that render the component.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@IEvangelist
David Pine (IEvangelist) force-pushed the ievangelist-landing-page-performance branch from d746799 to 5f41bd3 Compare October 8, 2026 06:49
The carousel's ~23 KB script was `is:inline`, so it was re-sent inside every
homepage response. Bundling it takes the document from 45.6 to 40.4 KiB
transferred, under the ~43 KB a cold connection delivers in two round trips,
which shortens the simulated document download by one round trip (1.05 s to
0.90 s) and leaves the script cacheable.

In the lab that reaches FCP in some runs and not others (2.25 s in 4 of 6
batches with the script bundled, 0 of 3 with it inline), so the benefit is
modest and sits on a TCP-window boundary.

A bundled <script> is TypeScript, and editors check it under the project's
strict settings, where the untyped body reported 108 errors. Type it with
erasable syntax only: generics on querySelector, parameter and variable
annotations, and `!` where TypeScript drops narrowing inside hoisted function
declarations. Stripping the types gives JavaScript identical to the previous
inline body, and the built chunk is byte-identical with and without them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two mistakes quietly added render-blocking CSS and early requests to every first
view of the site. Both are invisible in review and only show up in a Lighthouse
run, so check them statically:

- a component importing CSS from a bundled <script> instead of its frontmatter
  (attached to the whole docs route, so every page pays for it);
- `loading="eager"` outside the few components that render first-viewport
  images.

Each check also has tests that prove it flags the mistake it exists for and
ignores the near-misses (frontmatter imports, `is:inline` scripts, property
assignments in client scripts).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
aspire.dev negotiates HTTP/1.1 today. The apex resolves to an App Service front
end: an unknown host sent to that IP gets the "Microsoft Azure Web App - Error
404" page with the *.azurewebsites.net certificate, and responses carry no
Front Door headers. Its TLS handshake only selects http/1.1, while github.com
and learn.microsoft.com select h2 from the same client. App Service leaves
HTTP/2 off unless `siteConfig.http20Enabled` is set, so browsers are limited to
six connections per origin and the landing page's 60-90 requests queue behind
them.

Set `SiteConfig.IsHttp20Enabled` in the AppHost. Publishing the AppHost before
and after this change shows `http20Enabled: true` in aspiredev.bicep as the
only difference in the generated infrastructure.

Lighthouse lab, mobile, five cold runs, identical bytes served over HTTP/1.1
versus HTTP/2: the original page scores 78 -> 90 and the page with the
frontend fixes 86 -> 93.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@IEvangelist
David Pine (IEvangelist) force-pushed the ievangelist-landing-page-performance branch from 5f41bd3 to fff220c Compare October 8, 2026 08:01
@IEvangelist
David Pine (IEvangelist) marked this pull request as ready for review October 8, 2026 12:41
Copilot AI balanced review requested due to automatic review settings October 8, 2026 12:41

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.

🟡 Changes recommended

The pre-paint banner script skips absolute expiry when local storage is unavailable, allowing a renewed layout shift.

1 open finding
What changed in this PR

Restores landing-page performance by reducing layout shifts and unnecessary requests, while enabling HTTP/2 for production deployment.

Changes:

  • Preloads fonts, lazy-loads below-fold icons, and scopes player CSS.
  • Prevents banner CLS and bundles the dashboard carousel.
  • Adds performance guardrails, browser tests, and HTTP/2 configuration.
File Description
src/​frontend/​tests/​unit/​page-weight-guardrails.vitest.test.ts Adds static performance guardrails.
src/​frontend/​tests/​unit/​lazy-images.vitest.test.ts Tests deferred image loading.
src/​frontend/​tests/​unit/​banner-render.vitest.test.ts Tests banner rendering and visibility parity.
src/​frontend/​tests/​e2e/​homepage.spec.ts Verifies hidden icons preload near view.
src/​frontend/​tests/​e2e/​banner.spec.ts Covers banner first-load visibility states.
src/​frontend/​src/​utils/​lazy-images.ts Adds near-viewport image loading.
src/​frontend/​src/​components/​starlight/​Head.astro Preloads first-viewport fonts.
src/​frontend/​src/​components/​starlight/​Banner.astro Moves banner visibility decisions before paint.
src/​frontend/​src/​components/​home/​HomePage.astro Defers model-story icon requests.
src/​frontend/​src/​components/​home/​HomeEnvironment.astro Defers environment icon requests.
src/​frontend/​src/​components/​DashboardCarousel.astro Bundles and types carousel logic.
src/​frontend/​src/​components/​AsciinemaPlayer.astro Scopes player CSS to rendered pages.
src/​apphost/​Aspire.Dev.AppHost/​AppHost.cs Enables App Service HTTP/2.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread src/frontend/src/components/starlight/Banner.astro Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@IEvangelist
David Pine (IEvangelist) enabled auto-merge (squash) October 8, 2026 15:43
*
* Returns a function that stops waiting for `root` to approach the viewport.
*/
export function loadLazyImagesNearViewport(root: Element, rootMargin = '1500px 0px'): () => void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is the default of 0px correct?

*/
export function loadLazyImagesNearViewport(root: Element, rootMargin = '1500px 0px'): () => void {
const loadImages = () => {
root.querySelectorAll<HTMLImageElement>('img[loading="lazy"]').forEach((image) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't this load all images under root? Is that what we want?

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