What shipped for profile performance after service cleanup

15 October 2026 Baseline 7a7f894767, release-0.0.25 including PR 10994 Branch feature/release-0.0.25/profile-performance

What I actually did

I implemented the remaining items scored higher than 6 from the combined review. Score 6 and below stayed out. I did not restore PR 10977's posts-and-ads forkJoin, and I left the idle modal defer block alone.

Service cleanup was already on origin/release-0.0.25, so there was nothing to cherry-pick. The worktree started at merge 7a7f894767.

Score cut

Item Remaining This change
Profile banner priority9Shipped
S3 cache headers9Shipped. HTML is re-uploaded so old immutable metadata is replaced
Visible profile SSR8Shipped. Server and client both read TransferState, then a logged-in browser refreshes
Bootstrap CSS reduction7Shipped. Compiled. Gzip went from 34.3 KB to 30.6 KB for Bootstrap alone
Defer sidebar and welcome video7Shipped. Component imports stay. Modal defer stays. Placeholders are sized
Size the header logo7Shipped with max width and contain, not a 290×32 crop
Delay secondary profile work6Skipped. Higher than 6 was the cutoff
Optional-request resilience, ad skeleton wrappers, skip 2×, font-display swap, translation cookie3 or belowSkipped

Banner priority

The optimization directive already knew how to set fetchpriority="high" and eager loading. The profile banner never asked for it, and it shipped loading="lazy", which blocked the directive from flipping loading. I turned priority on and dropped the lazy attribute. The subscription-modal copy of the banner is still lazy. That image is not LCP.

src/app/modules/shared/components/profile/profile.component.html

appMobileOptimization [priority]="true" [originalUrl]="userProfile?.banner_image_url" [width]="800" [height]="400" loading="lazy" decoding="async"

Header logo sizing

PR 10977 asked for a 290×32 WebP. That is a 9:1 box. Tenant logos are not all 9:1, and the CSS already does object-fit: contain at 32px tall with a 290px max. Forcing both width and height on the image handler would squash the file before CSS could save it.

I request width only, 290 or 240 for the pro header, with fit="contain". WebP is already the default. I also set [priority]="true" because this logo was already loading="eager" and it is LCP on marketing chrome.

header-layout.component.html

[originalUrl]="logoUrl" [width]="isProPageLogo() ? CONST.LOGO.REQUEST_PRO_WIDTH : CONST.LOGO.REQUEST_WIDTH" [fit]="CONST.LOGO.FIT" [priority]="true"

S3 cache headers

Every rclone copy used Cache-Control: public,max-age=31536000,immutable, including index.html and prerendered HTML. Express never sees those objects. A checksum skip also leaves old metadata on unchanged HTML, so a deploy can look like it did nothing.

deployment_script/rclone-upload-dist.sh now does three passes:

  1. Non-HTML, non-manifest files: immutable, checksum skip as before.
  2. HTML: max-age=0,must-revalidate, --ignore-times so existing objects get new headers.
  3. ngsw.json, worker scripts, and manifest.webmanifest: same revalidate treatment.

TopFans still only excludes explore/**. Tenant scripts set RCLONE_TENANT_EXCLUDES=1 so splash, favicon, and sitemap stay off this path the way they already were. I did not hand-edit generated white-label projects.

After the first deploy, open an existing index.html in the bucket and read Cache-Control. If it still says immutable, the helper did not run, or CloudFront is holding the old object.

Bootstrap CSS

styles.scss used to @use the full Bootstrap barrel. It now @uses _bootstrap-partials.scss. Bootstrap 5.2 is still an @import library, so the partials file uses @import. The app boundary stays @use.

I dropped navbar, list-group, offcanvas, and placeholders after searching the templates. I kept spinners because a few SCSS files still style .spinner-border. I compiled both stacks against the main checkout's node_modules:

SheetUncompressedGzip
Full bootstrap/scss/bootstrap276,971 bytes34,340 bytes
Partials237,254 bytes30,602 bytes

About 4 KB gzip. Not a miracle. Enough to be worth the compile check, not enough to pretend we deleted Bootstrap.

Defer sidebar and welcome video

Modals already defer on idle. Sidebar and welcome video did not. Both now defer on viewport and prefetch on idle.

Angular still needs the components in the parent imports array. Removing them is how you get a build failure with a selector still in the template. I left ProfileSidebarComponent and WelcomeVideoPostComponent declared.

The welcome-video placeholder is a 16:9 block in --configSecondaryBgColor, so the feed does not collapse while the chunk loads. The sidebar placeholder reuses app-right-sidebar-skeleton, the same skeleton the loading path already shows.

profile.component.html, abbreviated

@defer (on viewport; prefetch on idle) { <app-welcome-video-post ...></app-welcome-video-post> } @placeholder { <div class="welcome-video-defer-placeholder" aria-hidden="true"></div> } @defer (on viewport; prefetch on idle) { <app-profile-sidebar ...></app-profile-sidebar> } @placeholder { <app-right-sidebar-skeleton ...></app-right-sidebar-skeleton> }

Visible profile SSR

The whole profile shell sat behind cs.isBrowser && isBasicProfileLoaded. The server rendered a skeleton and a hidden name/bio. First paint in the document was empty chrome.

Two things had to move together. Un-gating the template without consuming TransferState would refetch on the client and mismatch hydration. Using PRERENDERED on the server and REFRESH_EVERY_N in the browser, which is what PR 10977 did, never reads the TransferState entry. Only PRERENDERED does.

The first request is now always CacheTypes.PRERENDERED. If TransferState has the payload, of(data) emits in the same turn as subscribe, so isBasicProfileLoaded is true before the first client render. If the visitor is logged in and we just consumed transferred anonymous data, a second REFRESH_EVERY_N call updates follow and privacy. That second call does not wipe the feed unless privacy actually flipped. Feed loading, ads, and fetchPostsWithSkyscraperAds stay as service cleanup left them.

profile.component.ts, abbreviated

this.apiCacheService.get(APIENDPOINT.user(userId), CacheTypes.REFRESH_EVERY_N) const hadTransferredProfile = this.transferState.hasKey(stateKey); this.apiCacheService.get(endpoint, CacheTypes.PRERENDERED) // ... handleUserProfileResponse(res) // if hadTransferredProfile && browser && authenticated // get(REFRESH_EVERY_N) then handleUserProfileResponse(fresh, false)

The hidden prerender name/bio block only renders when the profile has not loaded yet. Once first-paint content is in the document, duplicating it is noise.

What I refused from PR 10977

  • Posts waiting on ads through forkJoin.
  • Eight-second timeouts on tags, merchandise, and welcome video.
  • afterNextRender plus a timer as a stand-in for LCP.
  • Removing sidebar and welcome video from imports.
  • Moving the six modals out of @defer (on idle).
  • Skipping 2× srcset globally.
  • isPlatformBrowser() inside the image directive. That path already uses CommonService.isBrowser.
  • The generator inlineCritical assignment. Prod and QA already enable it.

Checks I ran

CheckResult
npx tsc -p tsconfig.app.json --noEmitPass. 8.62s, 2581 files
ng lint on touched profile and header files8 template errors and 1 warning, all on pre-existing lines, none in the new hunks
Sass compile of partials vs full BootstrapBoth compiled. Gzip 30,602 vs 34,340
bash -n on the four upload scriptsPass
Unit testsNot run. No specs, TDD not in the matrix
prerender-qa / live SSR HTMLNot run. No local 4200/4000 server, and a QA prerender is a 10 GB build
In-browser LCP, CLS, hydration consoleNot run. T3 browser is blocked by AppArmor on this host

Route generation was not touched. /${username} already comes from generate-static-routes.mjs. White-label prerender lists still come from POST rest/prerender-routes. No backend change.

Human QA checklist

I could not load a profile in a browser from this session. This list is the part a person has to do. Check a box when you have seen it, not when it sounds right.

First paint and LCP

Logo and tenant chrome

Deferral

CSS and cache