Fourteen performance issues documented across the booking flow, with measured evidence, root cause and fixes prioritised by impact and effort.
Sample report. This is a real audit, anonymised. The figures, findings and recommendations are the originals; the client name, the sector and the third-party vendors are fictional. The charts are vector reconstructions of the capture data, not DevTools screenshots: the specific evidence file behind each one is named in the tables of every chapter.
<head>P130
PERF-007Consolidate and defer third-party scripts828 KB transferred, 5.35 MB uncompressedP133
PERF-008Preload the critical chunks2.8 MB not discovered until the bundle runsP237
PERF-009Speculation Rules for the confirmation step1–3 s at the highest-intent point of the funnelP240
PERF-010content-visibility on off-screen sections225 of 242 nodes enter every layout passP343
PERF-011Deduplicate the API calls57 calls where ~40 are expectedP147
PERF-012Enable HTTP/2 103 Early Hintsup to 413 ms of idle window per cold loadP351
PERF-013Load the maps SDK on demand666 KB at 4.7 s with no interactionFixed55
PERF-014Optimise the icon sprite694 icons shipped, 76 usedP259
Every issue is a self-contained chapter. The front matter holds everything that would otherwise be repeated fourteen times: the measurement environment, the Core Web Vitals thresholds and the evidence conventions.
Each chapter opens with a number, a title, a one-line summary of the finding, and a metadata strip: priority, scope, affected metrics, effort and owning team. Directly below sits the single most important measured figure for that issue. The body always follows the same order:
| Section | What it holds |
|---|---|
| Problem | What happens in the application and why, with the responsible code path where it has been identified. |
| Core Web Vitals impact | Which metrics are affected and through what mechanism. Definitions are in Section C. |
| Measured impact | Figures taken from a real capture. Anything not measured is labelled as an estimate. |
| Recommended fix | Concrete changes, ordered by return on effort. The code is illustrative, not a ready-to-apply patch. |
| Evidence | Which capture files exist, which are missing, and the charts derived from them. |
| Expected improvement | Projected saving per optimisation, plus the remote-market multiplier. Estimates unless stated otherwise. |
| References | External sources supporting the diagnosis and the fix. |
Priority combines impact and effort, not severity alone. A very high-impact issue that demands cross-team infrastructure work only jumps ahead of a quick win when its multiplier effect justifies it — which is exactly the case for one of the fourteen.
The colours follow the same ramp as the Core Web Vitals thresholds: red, amber and green. The label always accompanies the colour, so the document reads the same in black and white or with a colour vision deficiency.
| Level | Meaning | Issues |
|---|---|---|
| P0 | Attack first — very high impact, amplifies everything else | PERF-005 |
| P1 | High-impact quick wins | PERF-001, PERF-003, PERF-004, PERF-006, PERF-007, PERF-011, PERF-013 Fixed |
| P2 | Strategic improvements | PERF-002, PERF-008, PERF-009, PERF-014 |
| P3 | Polish and infrastructure | PERF-010, PERF-012 |
Metrics are grouped by what they describe:
LCPFCPTTFB loading · TBTINPTTI interactivity and responsiveness · CLS visual stability
Collected the capture exists in the evidence directory. Pending still to be captured; the claim around it rests on the evidence that is present, and says so.
The charts in this report are vector reconstructions of the capture data, not DevTools screenshots. Each one names the evidence file it comes from in its caption. No figure has been rounded or smoothed to make a chart look better.
Measured values are given as they were recorded. Projections use the word estimated or a range, and always name the baseline they extrapolate from. Wherever a value is a lower bound relative to the real user — which is nearly everywhere in this document — the chapter says so.
Impact against effort for the fourteen issues. The shaded zone is the high-impact, low-effort quadrant: the work worth scheduling first.
| ID | Title | Scope | Metrics | Effort | Owner |
|---|---|---|---|---|---|
| PERF-005 | US-hosted backend and CDN caching disabled by origin headers | All routes | LCPTTIINP | Very high | Infra + Backend |
Every other network issue in this report is amplified by the round trip to the US, and again by the distance to the Latin American market. Fixing PERF-005 does not remove the other thirteen, but it changes the size of all their numbers.
| ID | Title | Scope | Metrics | Effort | Owner |
|---|---|---|---|---|---|
| PERF-013 Fixed | Load the maps SDK on demand | /reserva/crear | TBTTTI | Low | Frontend |
| PERF-006 | Defer non-critical JavaScript | All | FCPLCPTBT | Low-medium | Frontend + Platform |
| PERF-003 | Preconnect to external domains | All | LCPFCP | Low | Frontend + Platform |
| PERF-004 | Preload the fonts | All | LCPCLS | Low | Frontend + Platform |
| PERF-011 | Deduplicate the API calls | /reserva/crear | TTIINP | Medium | Frontend |
| PERF-007 | Consolidate and defer third-party scripts | All | FCPLCPTBTINP | Medium | Frontend + Analytics |
| PERF-001 | Improve form request chaining | /reserva/crear | LCPTTI | High | Frontend + Backend |
| ID | Title | Scope | Metrics | Effort | Owner |
|---|---|---|---|---|---|
| PERF-008 | Preload the critical application chunks | /reserva/crear | LCPTTI | Low | Frontend + Platform |
| PERF-009 | Speculation Rules for the confirmation navigation | /crear → /confirmada | LCP TTI (next page) | Medium | Frontend |
| PERF-014 | Optimise the SVG icon sprite | All | LCPTBT | Medium | Frontend + Design |
| PERF-002 | Ship only the CSS you need | All | FCPLCP | Medium | Frontend |
| ID | Title | Scope | Metrics | Effort | Owner |
|---|---|---|---|---|---|
| PERF-010 | content-visibility on off-screen sections | /reserva/crear | TBTINP | Low-medium | Frontend |
| PERF-012 | HTTP/2 103 Early Hints | All | FCPLCP | Medium | Infra + Platform |
The thresholds every chapter refers to. Field metrics are what the user experiences; lab metrics are approximations used during diagnosis.
| Metric | Full name | Good threshold | What it measures |
|---|---|---|---|
| LCP | Largest Contentful Paint | ≤ 2.5 s | Loading — when the main content appears |
| FCP | First Contentful Paint | ≤ 1.8 s | Loading — when the first pixel appears |
| TTFB | Time to First Byte | ≤ 800 ms | Server and network response time |
| TBT | Total Blocking Time | ≤ 200 ms | Interactivity proxy — lab only |
| INP | Interaction to Next Paint | ≤ 200 ms | Responsiveness across all interactions |
| TTI | Time to Interactive | ≤ 3.8 s | Full interactivity — deprecated, still measured here |
| CLS | Cumulative Layout Shift | ≤ 0.1 | Visual stability |
Applies to every capture in this report unless a chapter says otherwise. Reading it once here saves reading it fourteen times.
| Variable | Value |
|---|---|
| Environment | Production, authenticated session |
| Analyst location | Spain (EU) — roughly 120 ms RTT to the US-hosted origin |
| Device | MacBook Pro M3 |
| Connection | Symmetric fibre ~600 Mbps, ~10 ms local latency |
| Artificial throttling | None, except in PERF-010 where 4× CPU throttling is applied and noted |
| Browser | Chrome stable. Chrome Canary has a DevTools bug that hides the real calls and shows only the OPTIONS preflights: unusable for these captures |
| Target locale | /es-mx/reserva/crear — the Latin American market, the worst affected |
A MacBook on European fibre is close to the best case this application will ever see. The audience that matters — Latin America, on modest devices and mobile networks — operates at real RTTs of 300 to 500 ms. Every remaining serial hop is multiplied by an estimated 2 to 4× against this baseline. Read every measured value as the floor, not as the typical user.
A capture taken from Mexico, Colombia or Chile is the single most valuable contribution this analysis can receive. Use the same /es-mx/reserva/crear URL, follow the CAPTURE-GUIDE.md in the relevant evidence directory, and drop the files alongside the existing ones with an -mx or -latam suffix — for example PERF-001-no-cache-mx.har.
Each issue keeps its evidence in its own subdirectory. Not every file exists for every issue: the evidence table in each chapter states the set actually collected.
docs/performance/issues/evidence/
└── PERF-XXX/
├── CAPTURE-GUIDE.md step-by-step capture instructions
├── PERF-XXX-no-cache.har cold network capture
├── PERF-XXX-with-cache.har warm network capture
├── PERF-XXX-no-cache-trace.json.gz cold performance trace
├── PERF-XXX-with-cache-trace.json.gz warm performance trace
├── PERF-XXX-*.png waterfall and flame chart screenshots
└── PERF-XXX-recording.mp4 screen recording (PERF-001, -002, -010 only)
The source material carries two different figures for the EU→US round trip: roughly 120 ms in the per-issue evidence notes, and roughly 250 ms in the PERF-005 summary. Both have been preserved exactly as recorded, without reconciling them. Before using these numbers to build a business case, confirm one and correct the other.
Fourteen chapters, one per issue, in identifier order. Chapter n is PERF-0nn.
Thirty-two schema requests in a single load, seventeen of them redundant, and every one of them waiting behind a bootstrap call that takes 3.9 seconds on its own.
/es-mx/reserva/crear. From navigation start to the last schema: ~8,537 ms.
The booking form loads its structure through a sequential chain of requests. Each one has to complete before the next begins:
GET /reservas/schemas/defaultPOST /reservas/schemas/guest-detailsPOST /reservas/schemas/rate-detailsPOST /reservas/schemas/v2/extra-servicesThe chain is not purely serial: once /schemas/default responds, the remaining schemas go out in parallel waves. But every wave sits behind that first response, and that first response takes 3,871 ms.
| Metric | Effect |
|---|---|
| LCP | Delayed — the form skeleton, which is the Largest Contentful Paint candidate, cannot render until schema data arrives. |
| TTI | Badly degraded — the serial requests keep the main thread busy and the form unresponsive. |
Every additional round trip translates directly into LCP and TTI. PERF-005 explains why each of those round trips costs what it costs.
Captured with devtools-snippet-perf001.js against production, warm cache, Spain (EU) → /es-mx/reserva/crear.
| Metric | Value |
|---|---|
| Total window, first → last schema | 6,173 ms |
| Sum of individual durations | 10,911 ms |
| Overlap recovered by parallelism | 4,738 ms |
| Navigation start → last schema | ~8,537 ms |
/schemas/default responds, none of the five following waves can start. It occupies 43% of the whole window. Reconstructed from PERF-001-Highlight-schema-requests.json.The snippet surfaced a finding the waterfall alone does not show: 15 unique endpoints generate 32 HTTP requests within a single page load.
Schemas that are always needed regardless of booking type — guest details, room, rates — can be requested alongside the bootstrap schema rather than after it.
// libs/booking/data-access/src/services/bootstrap.service.ts
forkJoin({
bootstrap: this.fetchBootstrapResponse(context),
guestDetails:
this.httpClient.get(this.configuration.fetchGuestDetailsSchema(context)),
roomDetails:
this.httpClient.post(this.configuration.fetchRoomDetailsSchema(context), payload),
}).pipe(...)
Warm the schemas when the user hovers the "New booking" link, using either a custom prefetch strategy or a direct HttpClient call.
Evaluate whether the backend can return the most common schemas inside /reservas/schemas/default, removing the separate round trips entirely.
Seventeen of the thirty-two requests repeat a schema already loaded. Add shareReplay(1) per URL in each schema service, or a shared Map<url, Observable> at interceptor level, so each unique endpoint is requested at most once per load. See PERF-011, which addresses the same class of problem at interaction level.
Authenticated production, Spain (EU) → /es-mx/reserva/crear, MacBook Pro M3 on fibre, no throttling. Full conditions in Section D. These values are a lower bound for the Latin American market: every remaining serial hop is multiplied by an estimated 2 to 4× at 300–500 ms RTT.
| File | Status | Notes |
|---|---|---|
| PERF-001-no-cache-waterfall.png | Collected | Full waterfall including asset downloads |
| PERF-001-with-cache-waterfall.png | Collected | API-only waterfall, isolating the schema chain |
| PERF-001-recording.mp4 | Collected | Screen recording of the warm-cache delay |
| PERF-001-Highlight-schema-requests.png | Collected | Snippet console output |
| PERF-001-Highlight-schema-requests.json | Collected | Structured output — 32 requests, 6,173 ms |
| PERF-001-no-cache.har · with-cache.har | Collected | — |
| PERF-001-*-trace.json.gz | Collected | Cold and warm traces |
Based on the measured data. The savings in the first two rows overlap: the combined target is not their sum.
| Optimisation | Estimated saving |
|---|---|
| Remove the 17 redundant refetches | ~400–800 ms |
Parallelise secondary schemas with default | up to ~3,871 ms |
Merge default and common schemas server-side | up to ~3,871 ms |
| Realistic combined target | 1,500–3,500 ms |
For users in Latin America at 300–500 ms RTT, every remaining serial hop is multiplied by an estimated 2 to 4× against the European baseline.
A single 1.25 MB stylesheet drags in every section of the application, and the chat widget's sheet blocks render for 108 ms while sitting 99.3% unused.
assistant-launcher.css goes unused on initial render — and still costs a blocking 108 ms round trip on every load, even when cached.
All application styles are bundled into a single main.scss entry point that imports every section unconditionally:
| Stylesheet | Critical? | Reason |
|---|---|---|
| body.scss | Critical | Base layout |
| card.scss | Critical | Card container |
| compact-form-grid.scss | Critical | Form grid |
| form.scss | Critical | Form controls |
| navigation.scss | Critical | Navigation bar |
| skeleton.scss | Critical | Loading skeleton |
| utils.scss · z-index.scss | Critical | Shared utilities and stacking |
| guided-tour.scss | Non-critical | Only after interaction |
| voucher.scss | Non-critical | Only after confirmation |
| table.scss | Non-critical | Secondary views |
| toast.scss | Debatable | Rarely needed at first paint |
CSS for sections not visible at initial render — billing, extra services, documentation, voucher, guided tour — is parsed and applied anyway, adding unnecessary style recalculation. The icon sprite (861 KB uncompressed, see PERF-014) and third-party styles are also loaded globally.
| Metric | Effect |
|---|---|
| FCP | Delayed — blocking CSS must be fully parsed before the browser paints anything. |
| LCP | Delayed — the form skeleton cannot render until the CSS is applied. |
CSS blocks rendering by design. Every extra kilobyte on the critical path delays FCP directly.
Captured with devtools-snippet-perf002.js, production, warm cache, Spain (EU) → /es-mx/reserva/crear.
design-system-core.css arrives via an @import inside the main bundle, which makes it invisible to a <link> filter. Reconstructed from PERF-002-coverage.png and PERF-002-CSS_Stylesheet_Audit.json.Even though the file is cached, the browser still issues a conditional GET and receives a 304 Not Modified. Rendering stays blocked while it waits for that response. A cached file is not a free file.
Identify the styles needed to render the visible skeleton without shifting — grid, navigation bar, card container, field base — and inline them in a <style> block in the shell's index.html, or use Angular's inlineStyleLanguage with a critical CSS extractor.
private loadGuidedTourStyles(): void {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/assets/guided-tour.css';
document.head.appendChild(link);
}
Run PurgeCSS, or @angular-builders/custom-webpack with a CSS tree-shaking pass, against actual template usage.
Billing, documentation and booking-detail styles should move their SCSS to component level. Worth verifying no duplication remains in the global bundle.
It is third-party, render-blocking, fires at 2,673 ms, costs 108 ms per load even when cached, and is 99.3% unused at initial render. It should load asynchronously once the form is interactive, or only when the chat opens.
private loadAssistantStyles(): void {
const link = document.createElement('link');
link.rel = 'stylesheet';
// URL taken from the <link> injected by the assistant script
link.href = 'https://chat.assistant-vendor.com/...assistant-launcher.css';
document.head.appendChild(link);
}
Authenticated production, Spain (EU) → /es-mx/reserva/crear, MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-002-coverage.png | Collected | Coverage tab with real CSS usage |
| PERF-002-CSS_Stylesheet_Audit.png / .json | Collected | Snippet output, console and structured |
| PERF-002-recording.mp4 | Collected | Screen recording of the render delay |
| PERF-002.har | Collected | Waterfall with blocking position and sizes |
| PERF-002-trace.json.gz | Collected | Trace with Parse Stylesheet and Recalculate Style entries |
| Optimisation | Estimated saving |
|---|---|
Defer assistant-launcher.css removes the 108 ms round trip | ~100–120 ms |
| Defer non-critical global styles | ~50–150 ms of FCP |
Remove or split the design-system-core.css @import | ~100–300 ms of parsing |
| Realistic combined target | 200–500 ms of FCP |
For users in Latin America at 300–500 ms RTT, the assistant-launcher.css round trip alone can cost 300 to 500 ms on a cache miss.
Four external origins are contacted during load and not one of them has a connection hint. The browser pays full DNS, TCP and TLS at exactly the moment it can least afford it.
preconnect hints in the document. The resource-hint audit found 2 preload and 1 prefetch, all three injected by third parties — and not a single preconnect or dns-prefetch.
The page requests resources from several external origins with no advance connection hint. The browser discovers those origins only after parsing HTML and CSS or executing JavaScript, and at that point it has to pay the full connection cost before receiving a single byte of content.
| Origin | Purpose | Discovered at |
|---|---|---|
| maps.geo-vendor.com | Maps SDK and autocomplete | ~3.5 s — on JS execution |
| tiles.geo-vendor.com | Map tiles and assets | After the SDK loads |
| assets.tagmanager-vendor.com | Tag manager | HTML parse |
| cdn.vantia.com | Corporate assets: fonts, header and footer | HTML parse |
| chat.assistant-vendor.com | Virtual assistant | Page load |
DNS resolution plus the TCP handshake plus TLS negotiation costs between 100 and 300 ms per origin from Spain, and it is paid in full during load.
| Metric | Effect |
|---|---|
| FCP | Delayed — scripts and styles from external origins block or defer rendering. |
| LCP | Delayed — late-discovered external resources extend the critical path. |
cdn.vantia.com alone spends 208 ms on the handshake before even sending the request. That is time no application code can recover. Reconstructed from PERF-003-tagmanager-timing.png and PERF-003-cdn-timing.png.<head><!-- Critical preconnects: open the connection before JS triggers it -->
<link rel="preconnect" href="https://assets.tagmanager-vendor.com">
<link rel="preconnect" href="https://cdn.vantia.com" crossorigin>
The map must not be preconnected at initialisation: PERF-013 is precisely about its eager loading. The hint is added when the user shows intent by hovering the address field.
// libs/core/main/src/ui/places-autocomplete/maps-api.service.ts
private preconnectToMaps(): void {
['https://maps.geo-vendor.com', 'https://tiles.geo-vendor.com'].forEach((origin) => {
if (!document.querySelector(`link[href="${origin}"]`)) {
const link = this.document.createElement('link');
link.rel = 'preconnect';
link.href = origin;
this.document.head.appendChild(link);
}
});
}
Called on the address field's mouseenter: by the time the script tag is added, the connection is already warm.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D. At 300–500 ms RTT the per-origin cost is 2 to 4× higher.
| File | Status | Notes |
|---|---|---|
| PERF-003-cold-waterfall.png | Collected | Cold waterfall with DNS and connection columns |
| PERF-003-cold.har | Collected | Cold HAR with per-origin detail |
| PERF-003-tagmanager-timing.png | Collected | Tag manager Timing panel |
| PERF-003-cdn-timing.png | Collected | cdn.vantia.com Timing panel |
| PERF-003-resource-hints-audit.png | Collected | Confirmation of preconnect: 0 |
| PERF-003-maps-timing.png | Pending | Maps SDK Timing panel (optional) |
| PERF-003-trace.json | Pending | Trace showing DNS/TCP/TLS cost on the timeline |
Each cold handshake removed saves 100 to 300 ms on fibre from Spain, at roughly 120 ms RTT to US-hosted origins. With three or four external origins, a total saving of 300 to 900 ms on first load is realistic.
For the Latin American market at 300–500 ms RTT the per-origin cost is 2 to 4× higher, so the improvement scales accordingly: between 600 ms and 1.6 s per cold load.
preconnect removes each onepreconnect rather than dns-prefetchpreconnect, dns-prefetch, preload, modulepreload and prefetchThe first font is not discovered until 4,713 ms after navigation, because the browser has to download and parse the entire CSS bundle before it learns the font exists.
<link rel="preload" as="font"> this should be 0 ms. The full font-loading window spans 3,542 ms.
The corporate fonts load as web fonts referenced by @font-face rules in the CSS. The browser cannot discover a font URL until it has:
@font-face ruleThis three-step chain produces 300 to 600 ms of invisible or fallback text before the real face paints. FOUT and FOIT are two visible symptoms of the same discovery delay.
| Metric | Effect |
|---|---|
| LCP | Delayed — the LCP element, typically a heading or a form label, cannot paint in the correct face until the file arrives. |
| CLS | At risk — swapping the fallback for the real face shifts layout when the font metrics differ. |
Bold and Regular are each requested twice. Reconstructed from PERF-004-no-cache-font-duplicates.png and the snippet output.VantiaSans-Bold.woff2 and VantiaSans-Regular.woff2 are each requested twice. Every duplicate wastes bandwidth and adds load to the critical path. Worth investigating alongside the preload work: preloading a font that is then requested twice does not fix the underlying double reference.
<head><!-- Only the fonts on the critical rendering path -->
<link rel="preload" href="/assets/fonts/VantiaSans-Regular.woff2"
as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/VantiaSans-Bold.woff2"
as="font" type="font/woff2" crossorigin>
Only those used above the fold. Preloading every weight and style wastes bandwidth and competes with more critical resources.
font-display: swap to every rule@font-face {
font-family: 'VantiaSans';
src: url('../fonts/VantiaSans-Regular.woff2') format('woff2');
font-display: swap; // text stays visible while loading
}
Trace which two declarations reference the same Bold and Regular files, and consolidate them.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-004-no-cache.har · with-cache.har | Collected | Warm serves fonts from disk, but the discovery delay persists |
| PERF-004-*-trace.json.gz | Collected | Confirm the gap is not a caching problem |
| PERF-004-no-cache-font-detail.png | Collected | Request detail: "Initiated by main-[hash].css" |
| PERF-004-no-cache-font-duplicates.png | Collected | Font filter: Bold and Regular ×2 |
| PERF-004-*-waterfall.png | Pending | Cold and warm waterfalls |
Preloading two or three critical files removes the discovery chain and can improve LCP by 200 to 400 ms on an uncached first visit, measured from the Spain/fibre baseline. Repeat visits are unaffected, since the fonts come from cache.
At 300–500 ms RTT the discovery delay is proportionally larger, putting the estimated improvement 2 to 4× higher: roughly 400 to 800 ms per cold load.
preload, font-display, self-hosting versus CDN, and their impact on Core Web VitalsThe CDN sits in front of the backend and caches nothing, because the origin sends no-store on every response. Fifty-five calls per session, 342 ms on average, every one of them reaching the origin.
This is the issue that makes every other one more expensive. Even after PERF-001 removes the serial chaining, each individual call will still carry 235 to 342 ms of overhead. Fixing the chaining reduces the number of round trips; fixing this reduces the price of every one that remains.
The booking backend is deployed in the corporate cloud, in a US region, behind the corporate CDN. But the CDN effectively acts as a pass-through: every request is a cache miss, because the origin sends Cache-Control: no-cache, no-store, max-age=0 on all its responses. Every request reaches an origin that is nowhere near the users.
The server-timing header on each schema response states it unambiguously:
server-timing: cdn-cache; desc=MISS server-timing: edge; dur=108 server-timing: origin; dur=75
Two captures, Chrome stable, Spain, 1 June 2026.
| Metric | Warm HAR, 29 calls | Snippet, full session, 55 | Served from the edge |
|---|---|---|---|
| CDN cache status | MISS on all | — | HIT |
| Duration, warm requests | 210–345 ms mean ~235 ms | 173–957 ms mean ~342 ms | ~10–20 ms |
Cold start · schemas/default | 4,328 ms TTFB | — | ~10–20 ms |
Cold start · /feature-toggle | — | 1,671 ms | ~10–20 ms |
Cold start · /user | — | 958 ms | ~10–20 ms |
| Edge processing, warm | 103–136 ms | — | — |
| Origin processing, warm | 70–90 ms | — | — |
| Total calls | 29 | 55 | — |
| Cumulative duration | — | 18.8 s | ~0.5–1 s |
| Overhead versus a static CDN asset | — | ~211 ms per call | ~0 ms |
The HAR was captured on a single navigation to /reserva/crear. The snippet ran across a full session with form interactions, which is what exposes the real call volume and the cold starts beyond schemas/default.
cache-control: no-cache, no-store, max-age=0, must-revalidate pragma: no-cache expires: 0
no-store tells the CDN never to cache the response. The CDN infrastructure is already deployed and paid for: the headers are what stop it being effective.
schemas/defaultThe first schema request of each session takes 4,328 ms TTFB, of which 3,667 ms is spent at the origin. Subsequent ones drop to around 75 ms. That signature is consistent with lazy initialisation: connection pooling, VM warm-up or an on-demand computation.
Even warm, a TTFB of 210–345 ms indicates real geographic distance between edge and origin. With 29 calls in the form load, that accumulates fast.
This issue sat as "pending investigation" because Chrome Canary DevTools only recorded the OPTIONS preflight requests and hid the real calls. Chrome stable captures all of them. Every HAR attached here was taken with Chrome stable.
Options by return on effort.
no-store from the schema responsesHighest return, lowest effort. The infrastructure is already there; the only blocker is the origin's Cache-Control.
Cache-Control: private, max-age=30
Or, to let the CDN cache but not the browser:
Cache-Control: no-cache, s-maxage=60 Surrogate-Control: max-age=60
Schema responses are largely deterministic per account and role. A short TTL with a cache key that includes the account identifier would be safe and effective, taking TTFB from ~235 ms to ~10–20 ms on cached responses.
The 3.67 s of origin time on the first call needs backend investigation. Likely causes: lazy service initialisation, connection pool expiry after idle, or an expensive first computation. Fixes: keep-alive connections, eager initialisation at deploy time, or a scheduled warm-up ping.
Deploy a lightweight gateway near the market that terminates TLS regionally and proxies to the origin. Shortens the transatlantic leg regardless of caching policy.
Deploy the backend in a region close to the market. Maximum latency reduction; requires coordination with backend, infrastructure and the data residency and compliance teams.
Use CDN edge functions to batch several schema requests into a single round trip to the origin, reducing the number of trips even if the origin stays where it is.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling, Chrome stable only. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-005-warm-cache.har | Collected | 29 calls, cdn-cache: MISS, TTFB 210–345 ms |
| PERF-005-with-cache.har | Collected | Second warm capture; confirms the pattern across sessions |
| PERF-005-no-cache.har | Collected | Cold; schemas/default TTFB ~4,328 ms |
| PERF-005-cdn-cache-miss-headers.png | Collected | cdn-cache; desc=MISS next to Cache-Control: no-store |
| PERF-005-cold-start-timing.png | Collected | Timing panel with the TTFB bar at ~4,300 ms |
| PERF-005-TTFB-01.png · -02.png | Collected | Snippet output: TTFB per endpoint |
| PERF-005-network-ttfb.png | Pending | Network panel with a readable TTFB column |
| PERF-005-us-baseline.har | Pending | US baseline to measure the RTT delta |
| PERF-005-trace.json | Pending | Trace with per-call TTFB and the cold-start spike |
| Fix | TTFB per call | Cold start |
|---|---|---|
| Current baseline Spain/fibre | 210–345 ms | 4,328 ms |
| Turn on CDN caching | ~10–20 ms on a hit | removed on cached endpoints |
| Fix the cold start only | 210–345 ms | ~75 ms |
| Regional gateway | ~30–60 ms | ~30–60 ms |
| Full regional deployment | ~5–15 ms | ~5–15 ms |
The baseline figures reflect Spain on fibre. For the Latin American market at 300–500 ms RTT, warm TTFB is estimated between 500 and 900 ms per call, which worsens the cumulative session cost considerably (55 calls × ~700 ms average) and makes the caching fix proportionally more valuable.
server-timing header · CDN and origin breakdown visible directly in the network panelCache-Control headersThree scripts in the shell <head> carry neither defer nor async. None is needed to paint the form skeleton, and all three stop HTML parsing at 463 ms.
<head>, all starting at 463 ms and blocking the 643 ms FCP.
Several sizeable JavaScript files load synchronously in the <head>, blocking parsing and rendering.
| Script | Wire size | Behaviour |
|---|---|---|
| application chunk [hash] | 62 KB 185 KB uncompressed | Render-blocking — static, no defer/async |
| rumagent-[hash].js RUM agent | 130 KB | Render-blocking — static, no defer/async |
| app-initialization.production-[hash].js | 1 KB | Render-blocking — static, no defer/async |
datalayer-core.js (~900 KB) and the header-and-footer script (~904 KB) are injected by Angular at runtime. The browser assigns them low network priority and does not classify them as blocking: renderBlockingStatus is "non-blocking". They still execute on the main thread during bootstrap and contribute to TBT, so delaying their injection to idle time (fix 2) still applies.
| Metric | Effect |
|---|---|
| FCP | Heavily blocked — HTML parsing stops at every synchronous <script> until it is downloaded, parsed and executed. |
| LCP | Delayed — the browser cannot paint until every blocking script finishes. |
| TBT | High — large scripts generate long main-thread tasks that block interactivity. |
| Metric | Measured | Conditions |
|---|---|---|
| Blocking JS transferred | 193 KB | Cold cache, Spain, fibre, no throttling |
| Blocking JS uncompressed | ~378 KB | Cold cache, Spain, fibre, no throttling |
Blocking scripts in <head> | 3 | Static, no defer/async |
| Download start | ~463 ms | Cold cache |
| FCP | ~643 ms | Cold cache |
| Data layer injection start | ~1,239 ms | Cold cache |
defer to every static script in the <head><!-- Before --> <script src="/booking/assets/app-initialization.production-[hash].js"></script> <script src="/booking/rumagent-[hash].js"></script> <!-- After --> <script src="/booking/assets/app-initialization.production-[hash].js" defer></script> <script src="/booking/rumagent-[hash].js" defer></script>
// libs/core/main/src/datalayer/datalayer-loader.service.ts async load(): Promise<void> { if (this.configuration.dataLayer.scriptUrl) { await new Promise<void>((resolve) => 'requestIdleCallback' in window ? requestIdleCallback(() => resolve()) : setTimeout(resolve, 200) ); // existing injection logic... } }
async and when defer| Attribute | Behaviour | Use for |
|---|---|---|
async | Downloads in parallel and executes as soon as ready; can interrupt parsing | Fully independent scripts, such as analytics |
defer | Downloads in parallel and executes after parsing, in document order | Scripts that depend on the DOM, such as header and footer |
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-006-no-cache.har · with-cache.har | Collected | Warm still delays render through parse and evaluation |
| PERF-006-*-trace.json.gz | Collected | "Evaluate Script" blocks before the FCP marker |
| PERF-006-network-blocking.png | Collected | The three blocking scripts at high priority |
| PERF-006-trace-fcp-blocked.png | Collected | FCP delayed by evaluation tasks |
| PERF-006-coverage.png | Collected | Unused percentage of each blocking script |
Adding defer to the three static scripts removes the parse-blocking phase at 463 ms. On European desktop fibre FCP is already at ~643 ms, so the headroom under ideal conditions is modest — but the benefit multiplies on slow connections and at 300–500 ms RTT.
Delaying the data layer injection (269 KB, starting at 1,239 ms) to idle time additionally reduces TBT.
<script> defer / async · execution order guarantees and timing relative to parsingAnalytics and tracking are the largest avoidable source of network and CPU cost in the application — and the chat widget keeps polling for eight minutes for users who never open it.
<script> tagEvaluateScript for the main bundle: a 51.3 ms long task on the main threadchat-launch.js and chat-loader.min.js each load twice: once from the page context and once from a high-priority parsed iframe. This is structural duplication caused by the eager initialisation pattern.Twelve beacons per page load to the collection endpoint. Each URL carries a unique random suffix, which is the platform's standard cache-busting mechanism, not a per-event identifier.
Eight of the twelve share the same activation event type, but each carries a different flag identifier and variant: these are eight feature flags active on /reserva/crear, not eight copies of the same event. The problem is that they are sent as eight serial HTTP requests within 430 ms where one batched request would do. The analytics platform supports event batching; the experimentation platform fires one beacon per activation with no batching at all.
The remaining four are genuinely distinct events: page view (×2), form ready, and a click on the rate schema response.
An additional tracker loads from a personalisation vendor's CDN. Its scope and impact are not yet measured. It should be included in the lazy-loading audit alongside the tag manager.
// Replace the eager script with a post-render injection
afterNextRender(() => {
const script = document.createElement('script');
script.src = '//assets.tagmanager-vendor.com/container-xxx.min.js';
script.async = true;
document.head.appendChild(script);
});
// libs/shell/src/services/virtual-assistant-display.service.ts initOnInteraction(): void { const trigger = document.querySelector('[data-chat-trigger]'); trigger?.addEventListener('click', () => this.loadChatWidget(), { once: true }); } private loadChatWidget(): void { // inject the assistant script here, and only now }
private readonly pendingActivations: FlagActivation[] = [];
private flushScheduled = false;
trackFlagActivation(activation: FlagActivation): void {
this.pendingActivations.push(activation);
if (!this.flushScheduled) {
this.flushScheduled = true;
queueMicrotask(() => this.flushActivations());
}
}
private flushActivations(): void {
// send a single beacon with all pending activations
this.flushScheduled = false;
this.pendingActivations.length = 0;
}
Determine whether the personalisation script is needed for initial render. If not, apply the same afterNextRender() pattern.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-007-no-cache.har · with-cache.har | Collected | — |
| PERF-007-*-trace.json.gz | Collected | Confirm the 51.3 ms EvaluateScript and the iframe duplication |
| PERF-007-tracking-waterfall-tagmanager.png | Collected | 13 requests, 466 KB / 2,294 KB |
| PERF-007-tracking-waterfall-assistant.png | Collected | Iframe duplication visible |
| PERF-007-beacon-burst.png | Collected | All 12 beacons and the headers panel |
| PERF-007-tagmanager-timing.png | Pending | Detail: queued 4.06 s, handshake 61 ms, total 138 ms |
| PERF-007-assistant-zoom.png | Pending | Same capture zoomed to 0–15 s |
Two chunks totalling 2.8 MB are required to paint the form, and the browser does not learn they exist until it has executed the main bundle. Two gaps, 1,550 ms of pure waiting.
Two large lazy-loaded chunks are required to render the form, but are only discovered after the initial bundle executes.
| Chunk | Size | Discovery point |
|---|---|---|
| chunk-[hash-a].js | ~1.5 MB ~396 KB compressed | After the main bundle executes |
| chunk-[hash-b].js | ~1.3 MB ~262 KB compressed | After the main bundle executes |
Because the chunks are discovered through dynamic import(), the browser cannot begin requesting them until the Angular router runs, which in turn depends on the main bundle being parsed and executed. The result is a serial dependency where 2.8 MB of critical code waits behind the bundle.
| Metric | Effect |
|---|---|
| LCP | Delayed — the form does not paint until the 1.5 MB chunk is downloaded and parsed. |
| TTI | Delayed — Angular cannot activate the route until both chunks are ready. |
Add modulepreload hints in the shell <head>:
<link rel="modulepreload" href="/now-web/chunk-[hash-a].js"> <link rel="modulepreload" href="/chunk-[hash-b].js">
They change on every build, so a hand-written hint goes stale silently. It has to be kept in sync through one of the three mechanisms below.
// scripts/inject-modulepreload.js (inside the build pipeline) const stats = require('./dist/now-web/stats.json'); const criticalChunks = stats.assets .filter(a => a.name.startsWith('chunk-') && a.size > 1_000_000) .map(a => `<link rel="modulepreload" href="/${a.name}">`); // inject into the shell template...
The build already sets namedChunks: true. It only remains to map names to hashed files in a build artifact manifest.
If the shell is generated by the corporate platform, the modulepreload hints need to be included in its own pipeline.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-008-no-cache.har · with-cache.har | Collected | Four serial waves; the two large chunks in the third and fourth |
| PERF-008-*-trace.json.gz | Collected | Main bundle finishes and a visible gap precedes the chunks |
| PERF-008-key-chunks-waterfall.png | Collected | Waterfall with the waves and both gaps |
| devtools-snippet-perf008.js | Collected | Reusable snippet |
The two measured gaps total ~1,550 ms on fibre. Preloading both chunks in parallel with the main bundle can remove them, for a TTI improvement of 500 to 1,500 ms.
At 300–500 ms RTT each discovery round trip costs considerably more, and the improvement is estimated 2 to 4× higher. The Spain/fibre values are a lower bound.
rel=modulepreload versus rel=preload: semantic differences and module graph pre-reading<link rel="modulepreload"> · specification: as, crossorigin and ES module integrationloadChildren and its effect on the graph--stats-json · identifying the largest chunks and their dependency treeThe user has spent two to five minutes filling in the form. The final click — the one that confirms the booking — then costs them another one to three seconds of waiting.
In comfortable mode, pressing "View summary" opens a modal layer within /reserva/crear without changing the URL. The real navigation happens when the confirm button inside that layer is pressed, leading to /reserva/confirmada. In compact mode, "Confirm booking" navigates directly. There is no intermediate review route, although earlier drafts of this tracker described one.
Once the form is complete, the user navigates to /reserva/confirmada. At that point the browser has to:
There is no prefetch strategy for this route. The delay lands exactly where frustration is most expensive: the user has already invested minutes of work and is committing to a purchase.
| Metric | Effect on /reserva/confirmada |
|---|---|
| LCP | No longer a wait — the assets are already preloaded before navigation. |
| TTI | Reduced — the confirmation-specific chunk is already cached. |
| Metric | Value |
|---|---|
| Navigation delay, create → confirmed | 1–3 s |
LCP on /reserva/confirmada | Trace pending |
TTI on /reserva/confirmada | Trace pending |
Inject the speculation rules once the user has shown real progress through the form — for example, on validating the first step — rather than on page load.
// In comfortable-navigation.component.ts — from viewSummary() // or from the showViewSummaryButton$ subscription private injectSpeculationRules(): void { if (!HTMLScriptElement.supports?.('speculationrules')) return; // detection if (document.querySelector('script[type="speculationrules"]')) return; // already injected const script = document.createElement('script'); script.type = 'speculationrules'; script.textContent = JSON.stringify({ prefetch: [{ urls: ['/booking/es-mx/reserva/confirmada'], eagerness: 'moderate', }], }); document.head.appendChild(script); }
For browsers without support — Safari and Firefox — fall back to a standard prefetch:
<!-- Fallback for browsers without Speculation Rules -->
<link rel="prefetch" href="/booking/es-mx/reserva/confirmada">
| Eagerness level | Behaviour |
|---|---|
immediate | Prefetches or prerenders straight away |
moderate | When the link is visible in the viewport — the default, and the right choice here |
conservative | Only on hover or pointer down |
moderate avoids spending bandwidth on users who abandon the form early.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-009-baseline.har | Collected | Warm cache, no rules active. Every confirmation-page resource starts from scratch on navigation. |
| devtools-snippet-perf009.js | Collected | Reusable snippet |
| PERF-009-baseline-trace.json | Pending | Full navigation timeline; the LCP and TTI cost the fix would remove |
| PERF-009-nav-waterfall.png | Pending | Waterfall with resources starting from scratch |
| PERF-009-spec-rules-panel.png | Pending | Empty Speculation Rules panel, confirming no rules exist |
Unlike the rest of the report, PERF-009 rests on a single HAR. The 1–3 s baseline is real but coarse: the trace that would break it down into chunk time versus API time is missing. The improvement figures below are projections, not measurements.
For the Latin American market at 300–500 ms RTT, the baseline is estimated between 3 and 6 s and the projected improvement scales in the same proportion.
prefetch and prerender fields, eagerness and where selectors<link rel="prefetch"> · the fallback that works in Safari and Firefoxcontent-visibility to off-screen sectionsOpening a sidebar menu repaints the entire form. The browser lays out 225 of the document's 242 nodes in a single pass, because nothing in the form is contained.
#document as the root. Consecutive frames of 133.5 ms and 108.4 ms — eight times the 16.6 ms threshold.
The compact form view renders every section in the DOM at once, even though only one is visible at a time. The summary panel additionally renders the full state of every completed step.
The browser lays out, recalculates style and paints all DOM nodes regardless of visibility. With fifteen or more sections mounted — billing, extra services, guest details, documentation and the rest — that is a lot of unnecessary render work. The step configuration defines more than thirty components, and all of them can exist in the DOM during transitions.
This is a problem felt most on modest devices, where the bottleneck is rendering rather than the network.
| Metric | Effect |
|---|---|
| TBT | Reduced by the fix — less layout and paint work means shorter main-thread tasks. |
| INP | Improved by the fix — fewer rendered elements means faster response to scrolling and input. |
#document as root and, as first invalidation, Angular's change detection. Reconstructed from PERF-010-flame-chart.png.With paint flashing enabled in DevTools, opening the sidebar submenu triggers repaint flashes across the main form content: fields with no relationship whatsoever to the interaction. This happens because the sections have no paint containment: any DOM change the browser cannot scope forces a repaint of the whole document. content-visibility: auto implicitly applies contain: layout style paint, which confines repaints to the element boundary.
content-visibility, no JavaScript changes// In the compact form section styles .booking-form-section { content-visibility: auto; contain-intrinsic-size: auto 300px; // approximate height, avoids CLS }
@defer (on viewport) — recommendedThis additionally defers component initialisation and change detection, not just layout and paint.
<guest-details-form [state$]="state$" /> <!-- critical: always -->
@defer (on viewport) {
<billing-form [state$]="state$" />
}
@placeholder {
<div class="section-placeholder" style="min-height: 300px"></div>
}
@defer (on viewport) {
<extra-services-form [state$]="state$" />
}
@placeholder {
<div class="section-placeholder" style="min-height: 300px"></div>
}
Authenticated production, Spain (EU), MacBook Pro M3 on fibre. No network throttling. 4× CPU throttling is applied where noted, because for a rendering problem the representative bottleneck is CPU rather than network. Full baseline in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-010-flame-chart.png | Collected | Layout task with 225 dirty nodes |
| PERF-010-paint-flashing.png | Collected | Repaint triggered by an unrelated interaction |
| PERF-010-recording.mp4 | Collected | Screen recording of the repaint |
| PERF-010-baseline-trace.json | Pending | Total Layout, Recalculate Style and Paint duration, with and without the fix |
| PERF-010.har | Pending | Low priority: this is a rendering problem, not a network one |
Applying content-visibility: auto or @defer (on viewport) excludes off-screen sections from the layout pass and brings the dirty node count down to the 40–60 actually in the viewport. Expected improvement: a 40 to 60% reduction in layout and paint time. On modest devices, 200 to 500 ms off Time to Interactive and noticeably smoother scrolling.
The expected remote-market multiplier is 2 to 4×, and here it is driven by weaker hardware rather than the network.
auto, hidden and visible values and their relationship to CSS containmentcontent-visibility: auto skips layouton viewport, on idle and on interaction triggers, plus @placeholder, @loading and @error blocksChanging a single form field takes the session from 32 calls to 57. Seventeen of them are surplus round trips to endpoints that had already answered.
Every duplicate call costs a full round trip to the origin. Deduplicating removes the call; fixing PERF-005 makes the remaining ones cheaper. Both are worth doing, and they multiply each other.
Network analysis shows several endpoints being called far more often than necessary.
Every duplicate call wastes a 150 to 250 ms round trip against the Spain/fibre baseline, backend CPU for an identical request, and 50 to 200 KB of the user's bandwidth per response.
| Metric | Effect |
|---|---|
| TTI | Degraded — redundant calls compete for the HTTP/2 connection's bandwidth and delay schema availability. |
| INP | Degraded — the calls triggered by a field change fire multiple times and produce visible jank. |
| Metric | Page load | After one field change |
|---|---|---|
| Total calls | 32 | 57 |
| Excess over the ~40 expected | — | +17 |
| Endpoints with duplicates | 4 | 11 |
| Wasted network time (estimated) | ~2.4 s | ~21.8 s |
| Worst case | v2/extra-services×5, mean 324 ms | v2/extra-services×7, mean 670 ms |
debounceTime and distinctUntilChangedformValueChanges$.pipe( debounceTime(300), distinctUntilChanged((a, b) => isEqual(a, b)), // ignore identical payloads switchMap((payload) => this.schemaService.fetch(payload)), // cancel in-flight )
switchMap is the critical operator: it cancels in-flight requests when a new trigger arrives, preventing an older response from landing after a newer one.
// libs/shared/schemas/data-access — deduplication layer
@Injectable({ providedIn: 'root' })
export class SchemaRequestDeduplicator {
private readonly inFlight = new Map<string, Observable<unknown>>();
deduplicate<T>(key: string, request$: Observable<T>): Observable<T> {
if (!this.inFlight.has(key)) {
const shared$ = request$.pipe(
share({ resetOnComplete: true, resetOnError: true }),
finalize(() => this.inFlight.delete(key)),
);
this.inFlight.set(key, shared$);
}
return this.inFlight.get(key) as Observable<T>;
}
}
Use a hash of the booking payload as the key.
For endpoints whose response is deterministic given the same input payload, cache client-side keyed on the payload hash, and invalidate when the booking state resets.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D. At 300–500 ms RTT each duplicate costs 2 to 4× more.
| File | Status | Notes |
|---|---|---|
| PERF-011.har | Collected | Warm cache, every call from a complete load |
| PERF-011-trace.json.gz | Collected | Repeated XHR blocks: no cancellation or deduplication in place |
| PERF-011-duplicate-calls.png | Collected | Full listing, sorted by name |
| PERF-011-endpoint-detail.png | Collected | Worst-case detail, with one request cancelled in flight |
| PERF-011-call-summary.png / .txt | Collected | Snippet output |
| PERF-011-call-summary.json · _after.json | Collected | Baseline and post-interaction |
After a single interaction, 57 calls are observed where roughly 40 are expected: 17 surplus round trips. At 150 to 670 ms each against the Spain/fibre baseline, removing them cuts the estimated wasted network time from ~21.8 s to essentially zero.
At 300–500 ms RTT the gain scales in the same proportion: the fix is worth considerably more to the priority market than the European figures suggest.
debounceTimeFor the first 463 ms of a cold load the browser has sent its request and received nothing. It cannot download a single resource. Early Hints turns that dead window into download time.
The booking engine's TTFB sits around 250 ms, and in the capture below it measured 338 ms. For that entire window the browser has sent its request and received nothing: it cannot start requesting any resource because it does not yet know what they are.
HTTP/2 103 Early Hints lets the server send a preliminary response carrying Link preload headers before the full HTML response is ready. The browser can act on those hints immediately.
| Metric | Effect |
|---|---|
| FCP | Improved — critical CSS and JS begin downloading during server processing time. |
| LCP | Improved — critical resources arrive earlier and unblock rendering. |
The technique pays off precisely when TTFB is high, which per PERF-005 is the normal condition here.
/es-mx/| Phase | Duration |
|---|---|
| DNS resolution | 20.11 ms |
| Initial connection | 38.98 ms |
| SSL | 21.90 ms |
| Request sent | 0.91 ms |
| Waiting for response — TTFB | 338.11 ms |
| Content download | 2.18 ms |
| Total | 402.58 ms |
The 338 ms TTFB is above the ~250 ms baseline in PERF-005, consistent with server variability. DNS, TCP and TLS together total around 81 ms: four times less than TTFB alone, which makes the idle window the dominant cost by a wide margin.
| Phase | Duration |
|---|---|
| Queueing and stalling | 1.57 ms |
| Request sent | 0.23 ms |
| Waiting for response — TTFB | 228.40 ms |
| Content download | 1.89 ms |
| Total | 232.09 ms |
Warm TTFB is lower because there is no connection cost, but it is still 228 ms during which nothing can download.
Link: header, so Early Hints is not in operation.<link rel="preload"> or <link rel="preconnect"> in the DOM. The first critical resource starts at 463 ms, against an HTML responseStart of 403 ms.location /reserva/crear {
add_header Link "</now-web/main.js>; rel=preload; as=script";
add_header Link "</now-web/styles.css>; rel=preload; as=style";
return 103;
proxy_pass http://upstream;
}
Most modern CDNs offer Early Hints as a one-click option, and some even infer the Link headers from the previous HTML response. The specific assets to preload should come from the build's stats.json: the initial CSS and JS chunks needed on every load.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D. In the Latin American market TTFB is estimated higher still, between 400 and 600 ms, which makes the technique proportionally more worthwhile.
| File | Status | Notes |
|---|---|---|
| PERF-012-no-cache.har | Collected | Document TTFB ≈ 338 ms; all CSS and JS start after it |
| PERF-012-no-cache-trace.json.gz | Collected | TTFB gap before any download bar |
| PERF-012-ttfb-timing.png · -warm.png | Collected | Cold and warm connections |
| PERF-012-response-headers-01.png · -02.png | Collected | No Link: header |
| PERF-012-resource-hints.png / .txt | Collected | No application-owned preload or preconnect in the DOM |
| PERF-012-ttfb-summary.png | Collected | Numeric confirmation of the idle window |
| PERF-012-ttfb-summary.txt | Pending | Text version of the snippet output |
103 Early Hints can recover up to ~413 ms of idle time per cold load. Critical resources would begin downloading during the TTFB wait rather than after it.
Link fields and expected client behaviourLink header inference666 KB of maps SDK loaded 4.7 seconds into every visit, with no map on screen and no prior interaction. There were two independent triggers; both had to be fixed.
Before reaching for micro-optimisations — preconnect, preload, compression — it is worth asking whether the resource does anything in this view. Here it does not: no map renders on /reserva/crear; the SDK is only needed the moment someone focuses an address field and starts typing; and anyone who types their address by hand never needs it.
On-demand loading and the facade pattern are the correct first move for any heavy third party off the critical path. Preconnect is a secondary improvement, useful only after the eager loading is fixed: while the script loads unconditionally it contributes nothing at all.
The SDK is only needed when the user focuses an address field and types (autocomplete), when they select a suggestion (geocoding to extract the address components), or when they view the property map, which is a different route entirely and unrelated to /reserva/crear.
libs/booking/feature-create/src/components/address-input/address-input.component.ts:58
private readonly placesLibraryStatus =
toSignal(this.mapsApiService.placesLibraryStatus$, {
requireSync: true, // ← synchronous subscription at construction
});
placesLibraryStatus$ derives from api$, which injects the SDK's <script> tag into the DOM:
Form renders
→ AddressInputComponent constructed
→ toSignal(placesLibraryStatus$, { requireSync: true })
→ placesLibraryStatus$ → api$
→ <script src="maps.geo-vendor.com/..."> injected
→ network request at 4,716 ms ← confirmed in the trace
It is unconditional: it fires whether or not the user ever touches an address field.
preloadLib() after view initconstructor() {
effect(() => {
if (!this.viewInitialized()) return;
if (this.autocompleteEnabled()) {
this.autocompleteApiService.preloadLib(); // ← fires on AfterViewInit
}
});
}
Because api$ is memoised, the script is injected only once, so trigger 1 wins the request. But preloadLib() would cause it anyway if the first were removed. Both had to be fixed.
The component used placesLibraryStatus$ only to decide which interface to show: search field if the SDK loaded, manual field if it failed or was still loading. That pattern forces an SDK load purely to paint the interface.
Replace it with an optimistic value: show the search field by default, assume the SDK will be available, and fall back to the manual field only on a real failure. The loading skeleton is unnecessary: the user gains nothing from knowing the SDK is initialising before they touch the field.
// Before — triggers the SDK load at construction: private readonly placesLibraryStatus = toSignal(this.mapsApiService.placesLibraryStatus$, { requireSync: true }); protected readonly showManualAddressInput = computed( () => this.placesLibraryStatus().failed || this.overrideShowManualAddressInput() || this.hasSomeAddressFieldFilledIn(), ); // After — nothing loads until there is interaction. private readonly mapsLoadFailed = signal(false); protected readonly showManualAddressInput = computed( () => this.mapsLoadFailed() || this.overrideShowManualAddressInput() || this.hasSomeAddressFieldFilledIn(), ); protected readonly showSearchAddressInput = computed( () => !this.showManualAddressInput(), ); // Called by AddressSearchInputComponent when the SDK fails: onMapsLoadFailed(): void { this.mapsLoadFailed.set(true); }
preloadLib() to the focus event// After: the SDK loads only when the user shows intent private handleTextInputFocus(): Subscription { return fromEvent(this.textInput, 'focus').subscribe(() => { this.autocompleteApiService.preloadLib(); // ← on first focus if (this.currentPredictions().length > 0 || this.showLastAutocompleteOption()) { this.showDropdown(); } }); }
preloadLib() already has an internal guard, so calling it on every focus is safe: the script is injected only once.
With the eager load removed, a preconnect to the SDK domain on the address container's mouseenter gives the browser a head start on the handshake before the user reaches the field. This matters most where RTT is high.
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-013-no-cache.har · with-cache.har | Collected | The SDK loads eagerly even with everything else cached |
| PERF-013-*-trace.json.gz | Collected | No mousedown, click, focus or keydown events before the request |
| PERF-013-maps-waterfall.png | Collected | — |
| PERF-013-maps-flamechart.png | Collected | — |
| devtools-snippet-perf013.js | Collected | Reusable snippet |
| Scenario | Before | After |
|---|---|---|
| User never touches an address field | 666 KB downloaded, parsed and executed | 0 KB — the SDK never loads |
| User focuses an address field | 666 KB at 4,716 ms | 666 KB on first focus, deferred |
| User in the remote market | Several seconds of download before parsing even begins | Fully deferred; with preconnect on hover, the RTT cost drops further still |
The baseline is Spain (EU) on fibre: the best possible case. At 300–500 ms RTT and on modest devices, the "before" figures were 2 to 4× worse.
focusin as the delegable alternative, and differences from clickThe design system sprite carries 694 icons. The application uses 76. The other 618 are shipped to every user on every load and never render once.
design-system-icons.svg is the corporate design system's icon sprite and loads as a static asset on every page. It is referenced in the project's asset configuration:
{
"glob": "design-system-icons.svg",
"input": "node_modules/@vantia/design-system",
"output": "/assets"
}
This monolithic asset blocks the two-second render milestone and contributes to Total Blocking Time.
| Metric | Effect |
|---|---|
| LCP | Blocked — the sprite competes with critical resources right at the two-second mark. |
| TBT | Increased — downloading and parsing a large asset feeds long main-thread tasks. |
/reserva/crear. Reconstructed from PERF-014-icon-audit.txt and PERF-014-used-icons.txt.| Metric | Value |
|---|---|
| Total icons in the sprite | 694 |
| Icons used across the application | 76 |
Icons visible on /reserva/crear | 26 |
| Usage ratio | 11% |
| Wire size | 292 KB compressed |
| Uncompressed size | 861 KB |
| Download start | 3,077 ms |
| Download duration | 244 ms |
grep -r 'icon="[^"]*"' apps/now-web libs --include="*.html" | \ grep -o 'icon="[^"]*"' | sort -u
Result: 76 unique icons across all templates, out of 694 available.
// scripts/build-icon-sprite.js — inside the build pipeline const svgstore = require('svgstore'); const usedIcons = require('./used-icons.json'); // output of step 1 const sprite = svgstore(); usedIcons.forEach(iconId => { sprite.add( iconId, fs.readFileSync(`node_modules/@vantia/design-system/icons/${iconId}.svg`) ); }); fs.writeFileSync('dist/assets/now-icon-sprite.svg', sprite.toString());
Then swap the asset glob for the generated sprite.
<!-- Instead of <vds-icon icon="status_check_small"> -->
<svg aria-hidden="true" width="16" height="16" focusable="false">
<use href="#status_check_small"></use>
</svg>
afterNextRender(() => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.type = 'image/svg+xml';
link.href = '/assets/design-system-icons.svg';
link.setAttribute('fetchpriority', 'low');
document.head.appendChild(link);
});
Authenticated production, Spain (EU), MacBook Pro M3 on fibre, no throttling. Full conditions in Section D.
| File | Status | Notes |
|---|---|---|
| PERF-014-no-cache.har · with-cache.har | Collected | Starts at 3,077 ms, 292 KB wire / 861 KB uncompressed |
| PERF-014-*-trace.json.gz | Collected | Download bar visible in the 3–3.3 s window |
| PERF-014-sprite-waterfall.png | Collected | The sprite alongside concurrent critical resources |
| PERF-014-sprite-flamechart.png | Collected | Wide download bar overlapping the LCP window |
| PERF-014-icon-audit.txt · used-icons.txt | Collected | Full audit and the 76 referenced icons |
On the Spain/fibre baseline the sprite starts at 3,077 ms and takes 244 ms, completing around 3,321. At 300–500 ms RTT, the download window for 861 KB is estimated 2 to 3× longer, adding two or three seconds for the priority market.
<symbol> elements<use> element · href referencing, shadow DOM behaviour and cross-origin restrictionsStated plainly, so nobody has to discover it in the meeting.
| Issue | Gap |
|---|---|
| All | No capture has been taken from the Latin American market. Every figure for that market is an extrapolation from the European baseline. |
| PERF-003 | The maps SDK Timing panel and the performance trace are missing. |
| PERF-004 | The cold and warm waterfall screenshots are missing. |
| PERF-005 | There is no US baseline, so the RTT delta is inferred rather than measured. The network capture with the TTFB column and the trace are also missing. |
| PERF-007 | The tag manager timing detail and the zoomed assistant waterfall are missing. The personalisation tracker's scope is unmeasured. |
| PERF-009 | Rests on a single HAR. No trace, no screenshots, and no breakdown of the 1–3 s into chunk time versus API time. |
| PERF-010 | The baseline trace and the post-fix comparison are missing. |
| PERF-012 | The text version of the TTFB summary is missing. |
The source material carries two different figures for the EU→US round trip: roughly 120 ms in the per-issue evidence notes, and roughly 250 ms in the PERF-005 summary. Both have been preserved exactly as recorded. Confirm one and correct the other before using these numbers to build a business case.
Sample report produced by PerfReviews. This is a real audit, anonymised: the figures, findings and recommendations are the originals; the client, the sector and the third-party vendors are fictional. The charts are vector reconstructions of the capture data.
perf.reviews · mail@perf.reviews
The report you have just read is the full deliverable of the Audit plan, with the client and the vendors fictionalised. On your own site, with your data and your priorities, this is the format.
And as the section just above explains, a report is a snapshot: without field data and regression tests, there will be no way to know in three months whether the fixes held. That is what the plans with continuous monitoring and pipeline regression detection are for.