Performance
Two levers do most of the work: `@defer` for work the user may never need, and `track` for lists. Everything else is measurement.
@defer: ship less, later
A component inside @defer is compiled into its own chunk and not downloaded until a trigger fires. For a heavy chart below the fold, that is the difference between a 400 kB first paint and a 90 kB one:
@defer (on viewport; prefetch on idle) {
<app-heavy-chart [data]="data()" />
} @placeholder (minimum 200ms) {
<div class="chart-skeleton"></div>
} @loading (after 150ms; minimum 800ms) {
<p>Loading the chart…</p>
} @error {
<p>The chart could not be loaded.</p>
}The triggers, and when each is the right one:
on viewport— the default choice for anything below the fold. Combined withprefetch on idle, the chunk is fetched when the browser is quiet and rendered when it scrolls into view, so the user normally never sees@loading.on interaction/on hover— for a panel behind a click (a comment box, a country picker). The download starts on the intent to use it.on idle— for something genuinely optional: a footer widget, a feedback prompt.on timer(5s)— when you want it after the page has settled, not at a moment the user chose.when condition— the escape hatch, driven by your own signal.
@placeholder and @loading both take minimum to avoid a flash of nothing, and @loading takes after so a fast load shows no spinner at all. @defer is also what ssr uses to decide what to skip during prerendering, which is why it matters twice — see SSR & hydration.
track, and why it is the whole list story
@for requires track, and the expression you give it decides whether inserting one row re-uses the existing DOM or rebuilds it:
@for (row of rows(); track row.id) {
<app-row [row]="row" />
}Tracking by index is legal and is the behaviour *ngFor gave you by default: every item is treated as new when the array changes shape, so each row is destroyed and recreated — with its subscriptions, its animated state, and its scroll position. If your data has no natural identity, add one.
computed is the cache
Derived values belong in computed, not in the template. A computed runs at most once per change and only when something it reads changes; a template expression runs on every check:
<p>Total: {{ basket().reduce((sum, i) => sum + i.price * i.quantity, 0) }}</p>
<!-- better -->
<p>Total: {{ total() }}</p>The same rule applies to method calls in templates — {{ format(item) }} is a function call on every check, {{ formatted() }} is a cached signal read.
Images
NgOptimizedImage is the supported way to do images, and it enforces the things that actually cost you: explicit dimensions (so there is no layout shift), responsive srcset, and a priority flag for the one image above the fold:
<img ngSrc="/assets/hero.webp" width="1200" height="630" priority alt="Angular Primer" />
<img ngSrc="/assets/chart.webp" width="800" height="450"
ngSrcset="400w, 800w, 1600w" sizes="(max-width: 600px) 100vw, 800px" alt="Throughput" />A plain <img src="..."> still works; linting will suggest ngSrc and the framework will not help you get the dimensions right.
Budgets catch you before your users do
angular.json carries build budgets, and the CI failure is more useful than a post-mortem on a slow page:
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" },
{ "type": "bundle", "name": "main", "maximumWarning": "300kB" }
]An initial budget that grows after a dependency upgrade is a prompt to look at what arrived; a component style budget that trips usually means someone pasted a framework into a component sheet.
Zoneless is a performance feature
Worth restating, because it is easy to file it under architecture: with zoneless and signals, a change re-renders the components that read the changed value rather than checking the tree. On a page with a hundred components, that is the difference between checking a hundred templates on every keystroke in a search box and checking the two that display the result.
The dev server is the build
Since v20 the CLI uses esbuild and Vite for ng serve as well as for builds, so development HMR and the production bundle go through the same pipeline. Practically: no more "works in dev, differs in prod" from two different bundlers, and rebuild times measured in hundreds of milliseconds rather than seconds.
Measure before optimising, and measure the thing a user feels. A quick waterfall in the browser's performance panel tells you whether the page is waiting on the network, on the main thread, or on layout — and only one of those is usually fixed by adding @defer.
What to take away
Performance in a signals app is mostly about not doing work: not shipping chunks nobody scrolls to (@defer), not rebuilding lists that did not change (track), and not recomputing values on every check (computed). The framework's own work is already scoped by the change-detection model — see Zoneless change detection — and the next page, SSR & hydration, adds a third lever: not doing the work on the client at all.