// 02Insight
Performance is a product requirement
If Core Web Vitals are optional, they lose. Treat load, input delay and layout shift as part of the spec.
· Rabin R
The Fiji immigration internal management system ended up roughly 50% faster on the frontend, with about 40% less API consumption. Neither number came from a performance sprint. They came from treating load behaviour as part of the acceptance criteria for the features being built, at the point they were being built, which is the only time the work is cheap.
Why a separate performance phase loses
When performance is its own phase it competes with features for schedule, and it loses, because a feature has a stakeholder asking for it and a percentage does not. Worse, by the time the phase arrives the causes are structural. A component that fires a request in its constructor is a one-line problem on the day it is written, and an architectural problem six months later when forty components do it and the fix is a caching layer nobody budgeted for.
The composition problem
Most of the API reduction on the immigration system was exactly that class of problem, caught late enough to be real work. Case management screens are dense — a single officer view composed reference data, applicant history, document status and audit trail. Each panel had been built independently, and each fetched what it needed on init.
export class DocumentPanelComponent implements OnInit {
countries: Country[] = [];
documentTypes: DocumentType[] = [];
constructor(private readonly api: ReferenceApi) {}
ngOnInit(): void {
this.api.countries().subscribe((c) => (this.countries = c));
this.api.documentTypes().subscribe((t) => (this.documentTypes = t));
}
}Panels shared reference data heavily, so the same lookup endpoints were being called five and six times per page load. The fix was unglamorous — shared lookups behind a service that caches for the session, with in-flight deduplication so concurrent callers join one request rather than starting six:
@Injectable({ providedIn: 'root' })
export class ReferenceDataService {
private readonly cache = new Map<string, Observable<unknown>>();
private lookup<T>(key: string, fetch: () => Observable<T>): Observable<T> {
if (!this.cache.has(key)) {
this.cache.set(
key,
fetch().pipe(shareReplay({ bufferSize: 1, refCount: false })),
);
}
return this.cache.get(key) as Observable<T>;
}
countries(): Observable<Country[]> {
return this.lookup('countries', () => this.api.countries());
}
}The second half of the fix was a rule rather than code: components receive reference data as inputs, and the route resolves it once. The interesting part of this episode is that no individual developer did anything wrong. Every panel was correct on its own. The composition was the defect, and composition is nobody’s ticket.
Layout shift has the same shape
It is almost never introduced deliberately; it accumulates from images without dimensions, content that swaps in after a fetch, and banners injected above the fold. Each instance is trivially fixable by the person who wrote it, on the day they wrote it. Collectively they become a score nobody owns.
<!-- shifts when the image arrives -->
<img [src]="applicant.photoUrl" alt="" />
<!-- reserves its box from first paint -->
<img [src]="applicant.photoUrl" alt="" width="240" height="320" />
<!-- async content: reserve the box, do not collapse it -->
<div class="panel" style="min-height: 18rem">
@if (documents(); as docs) {
<app-document-list [documents]="docs" />
} @else {
<app-skeleton-rows [count]="4" />
}
</div>What I put in the spec
The changes are small. A ticket that adds a view says what it may fetch on load and what it must receive from its parent. A ticket that adds an image or an embed says the space is reserved. Interaction work states what happens on the input that triggers it — whether the UI acknowledges immediately or waits for the server. These are one-line additions to tickets that were being written anyway, and they move the decision to the only moment when it costs nothing.
INP is the one that changed my habits
Interaction to Next Paint measures something users complained about long before there was a number for it. A button that runs a synchronous filter over a few thousand rows on click feels broken even when the total work is well under a second, and no amount of load-time optimisation compensates.
On the insurance administration console the dense table views needed work here specifically. The fix is not to do less work — it is to let the browser paint the acknowledgement before doing it:
applyFilter(filter: PolicyFilter): void {
// Paints this frame: the user sees the click land.
this.pending.set(true);
this.activeFilter.set(filter);
// Yields to the browser, then does the expensive pass.
afterNextRender(() => {
this.rows.set(this.filterPolicies(filter));
this.pending.set(false);
}, { injector: this.injector });
}For genuinely heavy work the same principle scales up: move it to a web worker, or page it so the first screenful renders and the rest streams. The constant is that the main thread must be free to acknowledge the input. A user who sees their click register will wait; a user who sees nothing assumes it failed and clicks again, which is how you get duplicate submissions in a system that handles money.
The measurement discipline
Measure before changing anything. Angular performance work divides into three causes with three different fixes: bundle and lazy-loading problems, change detection running more than it needs to, and network waterfalls from components that fetch on init. They look identical from the outside — "the page is slow" — and the fix for one does nothing for the others. Establishing which one you actually have is most of the work, and skipping that step is how teams spend a quarter optimising something that was never the bottleneck.
If your application is slow and nobody can say precisely why, that is a measurement problem before it is an engineering one. I start these engagements by establishing which of the three usual causes you actually have.
