I build locus.uz, a real-estate search site for Uzbekistan — think Zillow for Tashkent. The https://locus.uz/buy page is the hard one: a Leaflet map with hundreds of listing pins on the left, an infinite-feeling list of photo cards on the right, three languages (Uzbek, Russian, English), all server-side rendered.
The stack, so you know exactly what this applies to: Angular 21 (standalone components, signals, zoneless change detection — the default for new apps since v21), @angular/ssr on an Express 5 server, Leaflet 1.9 for the map, deployed on a VPS behind nginx and Cloudflare. No NgRx, no component library. Everything here was written against Angular 21.x, and I've checked that none of the framework behavior described has changed in v22.0 (released June 2026).
One weekend I ran Lighthouse against /buy and got Performance 88, Accessibility 83, Best Practices 54. A few days of work later:
| Category | Before | After |
|---|---|---|
| Performance | 88 | 93 (LCP 1.0s, CLS 0, TBT 90ms) |
| Accessibility | 83 | 96 |
| Best Practices | 54 | 96 |
| SEO | 100 | 100 |
This article covers the Angular techniques that got us there — and three production incidents along the way, because those taught us more than the wins.
The foundation: SSR + hydration, and the flag everyone misses
The app uses Angular's application builder with SSR and full hydration. Event replay (withEventReplay()) has been the CLI default for new SSR apps since v19 — the interesting line is the second one:
provideClientHydration(
withEventReplay(),
withHttpTransferCacheOptions({ includePostRequests: true })
)
Angular's HTTP transfer cache carries responses fetched during SSR to the browser so the client doesn't refetch them during hydration. But it only caches GET and HEAD by default (ALLOWED_METHODS = ['GET', 'HEAD'] in the source, unchanged through v22). Our listings come from a POST /map-search — the request body carries viewport bounds and filters, too much for a query string. So the server rendered twenty listing cards, the client re-issued the POST during hydration, and — because the response is async — Angular's first client render hit the empty @if branch while the server HTML contained the populated list. That structural mismatch is what throws NG0500 (the docs classify it under "differing server/client render output", not as an HTTP problem — if our loading state had the same DOM shape, we'd have gotten a silent duplicate request and a flicker instead of an error).
One flag fixed the mismatch, the duplicate fetch, and the console error at once. Two honest footnotes: the global flag is one way to do it — since v17 you can opt in a single request with http.post(url, body, { transferCache: true }), which is safer when only one endpoint is an idempotent read. And never enable this for POSTs that mutate state; ours is a search query that just happens to travel as POST.
Why not incremental hydration? Angular 20 stabilized it (withIncrementalHydration() plus @defer (hydrate on viewport) and friends), and from v22 it's the default. We stayed on full hydration because our main content is a client-only map that never gets server-rendered anyway — there's little dehydrated DOM to skip. If your page has heavy server-rendered sections users rarely touch, hydrate triggers are the modern way to cut initial JS execution, and you should reach for them before anything in this article.
Why not @defer for the map? Deferrable views (introduced in v17, stable since v18) with @defer (on viewport) are the idiomatic way to lazy-load heavy components, and on the server they render only their placeholder — so they'd also keep Leaflet out of SSR. We used route-level lazy loading plus a guarded dynamic import('leaflet') instead, for one reason: the map is the page's primary above-the-fold content. Angular's own defer guide warns against deferring initial-viewport content (layout shift), and a viewport trigger would fire immediately anyway. For a below-the-fold widget, @defer would have been the right call.
One more foundation piece: afterNextRender for anything that must not race hydration. We restore the user's saved language from localStorage — doing that in a constructor changes the first client render, which then no longer matches the server HTML (hello again, NG0500). Registering it in afterNextRender means the first render matches the server byte-for-byte and the preference applies immediately after.
The map: hundreds of dots without melting the main thread
The map renders ~280 pins at city zoom plus colored district polygons for all of Tashkent, and the page stays at 90ms Total Blocking Time.
A word on the library choice, since someone will ask: at this scale — hundreds of DOM features on raster tiles — Leaflet is a lighter, simpler tool than MapLibre GL, which earns its complexity with vector tiles and thousands-plus of GPU-rendered features. Leaflet 1.9.4 is still the current stable in mid-2026 (2.0 has been in alpha since August 2025; 1.x is in maintenance mode), and we'll migrate when 2.0 lands.
1. The viewport is the query. We never fetch "all listings". The map's moveend event sends the current bounds to the backend:
lMap.on('moveend', () => {
const b = lMap.getBounds();
this.api.loadListings({ northEast: b.getNorthEast(), southWest: b.getSouthWest(), ... });
});
The database does the spatial filtering; the browser only ever holds what's visible.
2. Signals drive the markers — which matters more in a zoneless app. The API service exposes listings as a signal, and the map redraws in an effect() (third-party rendering is explicitly one of the documented use cases for effects):
effect(() => {
if (isPlatformBrowser(this.platformId) && this.mapReady()) {
const markers = this.api.markers();
const lang = this.i18n.currentLang(); // redraw popups on language switch
this.drawMarkers(markers);
}
});
Since the app is zoneless, the classic "wrap Leaflet in NgZone.runOutsideAngular" advice is moot — there's no zone to escape, and Leaflet's internal mousemove/drag storms can't trigger change detection in the first place. The risk actually inverts: a Leaflet event handler that should update the UI does nothing unless it writes to a signal. Ours all do, which is why the pattern above isn't just tidy — it's the mechanism.
3. Cheap markers, canvas polygons — and know which is which. Each pin is a divIcon (one styled <div>, no image request). The district boundaries are detailed GeoJSON polygons, and rendering them as SVG (Leaflet's default) means thousands of path nodes in the DOM — so that layer gets renderer: L.canvas(), painting every district into a single canvas element. We also simplify the geometry first: interior rings and all but the largest part of each MultiPolygon get stripped, because a boundary overlay doesn't need survey-grade precision.
One precision note, because map people will check: L.canvas()/preferCanvas only affects vector path layers like our polygons. divIcon markers are always DOM elements — if you need to scale markers into the thousands, the answer is clustering, circleMarkers, a canvas-marker plugin, or WebGL, not the canvas renderer. At ~280 pins, plain DOM markers are comfortably fine; the community rule of thumb is that you reach for Leaflet.markercluster (proven to tens of thousands of points) somewhere past 500–1,000.
4. Kill the feedback loops before they kill you. The map reloads listings on moveend. Search-by-district calls fitBounds. fitBounds fires moveend. Which reloads listings, which can move the map… We guard every programmatic move:
this.programmaticMove = true;
this.leafletElement.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
// in the moveend handler:
if (this.programmaticMove) { this.programmaticMove = false; return; }
Same defensive posture around sizing: Leaflet 1.9's trackResize only watches window resize, so container-driven changes (sidebar toggles, flex reflows) need a manual ResizeObserver calling invalidateSize() — guarded to skip when the container is hidden, because Leaflet does not enjoy being resized while display: none. (The 2.0 alpha observes the container natively, so this workaround is 1.x-specific.)
5. Markers are UI, so they follow UI rules. Leaflet has made every keyboard-enabled marker a focusable role="button" since 1.8 — and by default a nameless one, which is a Lighthouse accessibility failure multiplied by 280. The built-in fix is the title option (Leaflet sets it on the icon element; the alt option only applies to image icons, so it's silently ignored for divIcons):
const title = [formatPriceCurrency(item.price, item.currency), item.location || item.district]
.filter(Boolean).join(' — ');
const marker = this.leaflet.marker([lat, lng], { icon, title });
Now a screen reader announces "$52,500 — Sergeli" instead of "button". (A title attribute is a fairly weak name source; if you build custom divIcon HTML anyway, an aria-label inside it is more robust.)
The listing images: fast, stable, and no broken pictures
The right half of the page is a grid of photo cards, each with a swipeable gallery. Every card thumb looks like this:
<img class="thumb"
[src]="imageError ? '/no_image.jpg' : photos()[current]"
(error)="handleImageError($event)"
[attr.loading]="priority() ? 'eager' : 'lazy'"
[attr.fetchpriority]="priority() ? 'high' : null"
[alt]="listing().location || listing().district || (…fallback | translate)" />
The reasoning, attribute by attribute:
-
loading="lazy"— for everything except the LCP image. Twenty cards render per page; only the ones near the viewport fetch their images, which (with the other fixes) took the page payload from 11MB to 3.4MB. But lazy-loading the image that is your Largest Contentful Paint is a well-documented anti-pattern — Angular has flagged it in dev mode since v17 (the NG0913 check inspects plain<img>tags too). So the first cards get apriorityinput: eager loading plusfetchpriority="high". -
The container owns the layout, not the image. The wrapper has
aspect-ratio: 4 / 3withobject-fit: cover, so cards occupy their final size before a single image byte arrives. That's the entire reason CLS is a flat 0 — explicitwidth/heightattributes achieve the same thing, and map directly onto NgOptimizedImage's requirements if you migrate later. -
One
<img>per card, not one per photo. A listing can have fifteen photos, but the carousel is a single image element whosesrcswaps on navigation. Photos you never swipe to are never downloaded. The dots under the photo are real<button>s, visually 8px but with a 24×24 hit area viabackground-clip: content-box. -
decoding="async"on the lazy images — a cheap, no-downside hint, though honestly a marginal one in 2026 (browsers largely decode off the main thread anyway), and deliberately not set on the priority image. -
Broken images have two safety nets. Client-side,
(error)swaps in a placeholder. Server-side, nginx doestry_files $uri /no_image.jpg;so a missing photo returns a 200 placeholder instead of a 404 — Lighthouse counts every failed request as a console error, and twenty cards can generate a lot of 404s.
Why not NgOptimizedImage? Fair question — ngSrc has been Angular's documented image best practice since v15, and it's the first thing any reviewer will ask. The directive's headline wins are automatic srcset generation and placeholders, and both require an image loader backed by a resizing CDN (Cloudinary, ImageKit, Imgix…). Our photos are self-hosted in a single size — there's no srcset to generate, and automatic placeholders actually throw without a loader. What the directive would still give us loaderless — enforced dimensions, lazy-by-default, and the priority handling with SSR preload hints — we've replicated manually above. If we adopt an image CDN, switching to ngSrc with priority on the first cards is the drop-in upgrade, and it's the honest next step on this list.
Best Practices 54 → 96: security headers on the Express server
The score of 54 had a blunt cause: the site sent no security headers at all. The fix lives in the Express SSR server — a global middleware for HSTS, COOP, X-Frame-Options, nosniff and Referrer-Policy, plus a per-request nonce-based CSP on rendered HTML. The server mints a nonce, stamps it onto every <script> tag (and every <link rel="modulepreload"> — miss those and 'strict-dynamic' blocks your chunk preloads), and sends:
script-src 'nonce-...' 'strict-dynamic' 'self' 'unsafe-inline' https: http:
The trailing values are backward-compat fallbacks that nonce-aware browsers ignore. Angular's hydration and event-replay inline scripts all accept the nonce.
Except one thing did not, and it took the whole site's CSS down with it.
Strict CSP vs. Angular's critical CSS: know your options
Minutes after the CSP went live, our map disappeared, replaced by Canvas exceeds max size errors. Measuring the map container in a headless browser: 4,194,366 pixels tall — 2²², a doubling feedback loop that had run thirteen times, with 6,393 map tiles loaded into it.
A commit-by-commit bisect with a headless browser pointed at… my own CSP commit. The mechanism: Angular's inlineCritical optimization (on by default; since v19 powered by Beasties, the maintained Critters fork) inlines critical CSS and emits the full stylesheet as:
<link rel="stylesheet" href="styles.css" media="print" onload="this.media='all'">
A non-blocking load that flips to media="all" via an inline event handler — which a strict CSP blocks. The stylesheet stayed stuck as a print stylesheet, so the page — including all of Leaflet's CSS — rendered unstyled. Unpositioned map tiles became ordinary in-flow images stacking vertically, the container grew, more tiles loaded, repeat. (Not literally silent, in fairness: the CSP violations were in the console. But nothing tells you "your stylesheet is now print-only", and the visible symptom — a canvas error from the map — pointed everywhere except CSS delivery.)
Here's the part I only fully understood afterwards, and it changes the recommendation. This failure only happens when Angular doesn't know your nonce. Since v16, if the served HTML carries an ngCspNonce attribute on the app root, Angular's build and @angular/ssr handle everything: the onload handler is replaced with a nonce'd loader script, inline critical styles get the nonce, and so do the hydration/event-replay scripts. That's the framework-blessed path for strict CSP with SSR, and it keeps the critical-CSS FCP win. (Two footnotes: the CSP_NONCE injection token alone is not enough for SSR — build-emitted scripts won't get the nonce; and the CLI's newer hash-based autoCsp option, experimental since v19, throws "Cannot set both SSR and auto-CSP" — it's a no-go for SSR apps.)
We chose the other lever:
"optimization": { "styles": { "minify": true, "inlineCritical": false } }
One line, no per-request HTML templating, at the cost of the stylesheet becoming a normal render-blocking link — a few milliseconds of first paint on our numbers. For a small self-hosted stylesheet that's a fine trade; for a bigger app already generating per-request nonces, wiring ngCspNonce is the better answer. What you cannot do is ship a strict CSP without choosing one of them — nothing in the build warns you about the combination, and the failure mode looks like anything but CSS.
Two more bugs the audit smoked out
Production was serving a month-old build. After the first round of fixes, the Lighthouse score didn't move — because nginx was serving a stale prerendered index.html from a June build. Our GitHub Actions deploy used scp, which copies but never deletes, and old files shadowed five consecutive deploys. The tell was in the response headers all along: last-modified: Fri, 26 Jun. HTML responses from an SSR server don't have a last-modified date. Now the pipeline wipes the target directories before copying, and nginx proxies everything except hashed assets to the Node server. If your deploy is scp/rsync without --delete, go check your server right now.
The language switcher un-switched itself in 6ms. Our i18n uses URL prefixes (/ru/buy) via a custom UrlSerializer whose parse() records the language as a side effect. (Angular's official i18n guidance is a different architecture entirely — one build per locale under a URL subPath — so a single-build runtime-locale setup like ours is community-pattern territory.) It turns out the Router re-parses the raw browser URL with your serializer during internal same-URL checks — isUpdatedBrowserUrl() in navigation_transition.ts, verified unchanged through 21.x. So if the address bar still said /ru/buy when you clicked "O'zbek", an internal re-parse stomped the language state back to Russian before the UI could blink. I found it by monkey-patching localStorage.setItem with stack traces. The fix: location.replaceState(newPrefixedUrl) before router.navigateByUrl, so any re-parse sees the new prefix. The lesson: the Router makes no promises about when or how often parse() runs — treat it as something that must be pure.
What actually mattered
-
includePostRequests: true(or per-request{ transferCache: true }) if your SSR app reads data via POST — fixes hydration mismatches and double-fetching at once. Idempotent reads only. -
Viewport-bounds queries + signal-driven
effect()redraws +divIcon/canvas rendering carry a Leaflet map to hundreds of markers without jank. In a zoneless app, map-event-writes-to-signal isn't a style choice — it's the only way the UI updates. -
loading="lazy"everywhere except the LCP image, which getsfetchpriority="high"— plusaspect-ratiocontainers and single-img carousels. CLS 0 is a design decision, not luck. -
Strict CSP + SSR requires choosing:
ngCspNonce(keeps critical CSS, framework-supported since v16) orinlineCritical: false(one line, render-blocking styles). Choosing neither breaks your CSS in a way that looks like anything but CSS. - Audit in incognito — DevTools Lighthouse runs your extensions inside the page, and their deprecation warnings count against you. My first "after" run said 73; a clean profile said 96.
- Trust nothing about production until you've curled it. The most impactful fix of the week wasn't Angular at all — it was discovering which build users were actually receiving.
What's still red, honestly: the touch-target audit flags overlapping map pins — that's WCAG 2.2's target-size criterion (24×24 CSS px of unobscured area), and 280 pins at city zoom obscure each other by definition. The standard fix is Leaflet.markercluster with spiderfying for coincident pins; at our scale it's an accessibility-and-UX decision rather than a performance one (and note the default cluster icons need accessible names of their own). That's the next project.
The scores are the headline, but the real gains were quieter: pages hydrate cleanly instead of re-rendering, the map redraws only what changed, users stopped downloading photos they'd never see, and — after an embarrassing discovery — they're finally getting the build we shipped.
Top comments (0)