Home / Articles

Faster Angular by Doing Less: Ten Techniques Beyond OnPush and trackBy

Cut wasted network, reactive, DOM and CPU work in modern Angular with @defer, incremental hydration, resource(), computed signals, render phases and Web Workers.

Publisher: REACTAPP.TOP16 min read3569 words

This article is published in English.

Sources: How to Make Angular Extremely Fast: 10 Performance Tricks Most Developers Miss

Angular's runtime is rarely the reason an Angular application feels slow. Far more often, the app downloads, initializes, recalculates and renders things nobody asked for yet. The familiar fixes, such as OnPush, lazy routes, trackBy, AOT compilation, production builds and fewer HTTP calls, still help, but recent Angular versions give you much finer control: when code is downloaded, when components become interactive, when requests are cancelled, what state invalidates, when DOM work runs and on which thread heavy computation happens. This guide walks through ten techniques built on that control, how to combine them, and in what order to apply them once you have measured where your time actually goes.

The guiding idea is simple: instead of making Angular faster at unnecessary work, arrange things so that the work never happens.

Where an Angular application spends its time

Before touching individual APIs, it helps to picture the three resources every Angular app consumes and what typically eats each of them.

Angular Application
                           │
          ┌────────────────┼────────────────┐
          │                │                │
       Network           CPU              DOM
          │                │                │
     JS downloads      Change detection   Layout
     API requests      Computation        Paint
     Dependencies      Parsing            Reflow
          │                │                │
          └────────────────┼────────────────┘
                           │
                    User Experience

These costs interact. Tuning change detection achieves little if the first page load pulls down 5 MB of JavaScript, and a tiny bundle does not save you if every interaction triggers an expensive calculation. The sequence to aim for is: download less, initialize less, calculate less, render less and block the main thread less. The ten techniques below each target one or more of those steps.

1. Make @defer produce genuinely separate chunks

The @defer block is one of the simplest ways to shrink the JavaScript required at startup. Picture a dashboard with charts, analytics, reports, maps, video players and admin widgets. Few of these must exist the moment the page opens, yet a plain template loads all of them eagerly:

<app-heavy-chart />
<app-map />
<app-report />

Wrapping a heavy component in a deferred block tells Angular to fetch its code only when a trigger fires, here when the placeholder scrolls into the viewport:

@defer (on viewport) {
  <app-heavy-chart />
}

Keep deferred components out of barrel imports

There is a subtle catch: @defer can only split out code that is not already reachable through an eager import. If the host component imports the deferred component through a barrel file, the barrel drags every other export and its dependencies into the same graph. So instead of importing from an index file:

import { HeavyComponent } from './index';

import the component from its own file:

import { HeavyComponent } from './heavy.component';

The goal is to keep the dependency graph behind each deferred block as isolated as possible. The first shape to avoid is labelled:

BAD

and it looks like this, with a single barrel tying every widget to the dashboard:

Dashboard
   │
   └── index.ts
        ├── Chart
        ├── Map
        ├── Reports
        ├── Video
        └── Analytics

The preferred shape is labelled:

GOOD

and it gives the heavy chart a direct import and therefore its own chunk:

Dashboard
   │
   └── HeavyChart
          │
          └── heavy-chart.chunk.js

Barrel imports are one of the most common hidden reasons a @defer block fails to reduce the initial bundle. The easiest way to confirm success is to check the build output for a new lazy chunk after adding the block.

The payoff compounds: a smaller initial bundle means less to download, parse, compile and execute, and all of that savings happens before Angular renders anything.

2. Hydrate only what users need first

With server-side rendering, a second bottleneck shows up after the HTML arrives. The server can produce markup quickly, but the browser then has to make the whole page interactive, which means downloading and running the code for every component on it. Consider a typical dashboard layout:

Dashboard
│
├── Header
├── Navigation
├── KPI Cards
├── Huge Chart
├── Map
├── Analytics Table
├── Video Player
└── Recommendations

Not all of these sections need to respond to input immediately. Classic full hydration treats the page as one unit:

SSR HTML
   ↓
Hydrate EVERYTHING
   ↓
Interactive

Incremental hydration lets you split that into stages, so the page is visible at once and sections become interactive progressively:

SSR HTML
   ↓
Show page immediately
   ↓
Hydrate only what is needed
   ↓
Hydrate other sections later

It is expressed with the same @defer blocks, extended with hydration triggers. In the following example, the server still renders the chart's HTML, the client hydrates it when the browser is idle, and on a purely client-side render the block loads on interaction:

@defer (on interaction; hydrate on idle) {
  <app-heavy-chart />
}

The architectural win is that rendering HTML and making it interactive stop being a single, all-or-nothing step. Incremental hydration has to be enabled in your hydration configuration; check the SSR guide for your Angular version for the exact provider option.

3. Cancel obsolete requests with resource()

Search-as-you-type is the classic case of wasted network work. As a user types, the input passes through every intermediate value:

a
an
ang
angu
angul
angula
angular

A naive implementation can fire a request for each one:

Request 1 → "a"
Request 2 → "an"
Request 3 → "ang"
Request 4 → "angu"
Request 5 → "angul"
Request 6 → "angular"

Only the last response matters, so the other five are waste. Modern Angular offers resource() and httpResource() for reactive asynchronous loading. You start with a signal holding the query:

const query = signal('');

Then you declare a resource whose params function reads that signal and whose loader performs the fetch. In the listing below, loader has been run onto the same line as the closing brace of params; the two are separate properties of the options object:

const results = resource({
  params: () => ({
    q: query()
  }),  loader: ({ params, abortSignal }) =>
    fetch(
      `/api/search?q=${params.q}`,
      { signal: abortSignal }
    ).then(res => res.json())
});

Whenever the params change while a load is still running, Angular aborts the previous one. That only works because the loader forwards the provided signal to fetch, which is the line to pay attention to:

{ signal: abortSignal }

Leave it out and the old request keeps running to completion. Two refinements are worth adding in real code: wrap the query in encodeURIComponent before interpolating it into the URL, and remember that cancellation is not debouncing. Each keystroke still starts a request that is then aborted, so combine the resource with a debounced query signal if you also want to avoid sending them. The fastest request is the one you stop as soon as it becomes irrelevant, and the pattern pays off for search, autocomplete, filtering, live dashboards, rapidly changing IDs and chains of dependent calls.

4. Keep signals small and focused

Signals are efficient, but they can still be used in a way that throws that efficiency away. A common pattern is one signal holding an entire page's state:

const pageState = signal({
  user: {},
  filters: {},
  notifications: [],
  products: [],
  statistics: {},
  settings: {}
});

If only the filters change, every consumer of pageState is notified, because the signal's value as a whole was replaced. Splitting independent pieces of state into separate signals narrows the blast radius. The first becomes:

const user = signal(...);

and the rest follow the same pattern (shown here run together on one line; in real code each goes on its own line):

const filters = signal(...);const notifications = signal(...);const products = signal(...);const statistics = signal(...);const settings = signal(...);

Now each consumer depends only on the state it actually reads. A spreadsheet is a good mental model. When a single cell changes:

A1 changes

you do not want the whole sheet recalculated, only the chain that depends on it:

A1
 ↓
Dependent cells
 ↓
Only affected UI

A large object signal invalidates consumers that care about only a fragment of it; small signals give you narrow invalidation scopes. Rather than asking whether all state could fit in one signal, ask what should actually react when a particular value changes. If you do need a composite object, derive it with computed() from the small signals rather than storing it as the source of truth.

5. Move expensive calculations out of templates

Calling a function from a template is one of the easiest ways to create repeated work:

<div>
  {{ calculateTotal(items()) }}
</div>

It looks harmless, but Angular re-evaluates that expression every time the view is checked, so a costly calculateTotal() runs again and again even when items has not changed. The fix is a computed() signal. Declare the source state:

const items = signal<Item[]>([]);

derive the total from it:

const total = computed(() =>
  items().reduce(
    (sum, item) => sum + item.price,
    0
  )
);

and read the derived signal in the template:

<div>
  {{ total() }}
</div>

computed() memoizes its result and recomputes only when a signal it read has changed, instead of on every template evaluation. That makes it a natural home for filtering, sorting, aggregation, statistics, dashboard figures and any other derived UI state. A filtered product list follows the same shape, starting from a search term signal:

const searchTerm = signal('');

and a derived list that depends on both the products and the term:

const filteredProducts = computed(() =>
  products().filter(product =>
    product.name.includes(searchTerm())
  )
);

This replaces template calls like the following, pushing the work into the reactive graph:

{{ filterProducts(products(), searchTerm()) }}

One detail: includes is case-sensitive, so a real search would usually normalize both strings first. Think of computed() as a cache for derived state that invalidates itself correctly.

6. Lazy-load heavy services, not only components

Lazy loading is usually discussed at two levels:

Lazy route
Lazy component

Services, however, can pull in large dependency trees of their own. A Markdown preview service is a good example:

MarkdownService
   │
   ├── marked
   ├── highlight.js
   ├── syntax definitions
   └── other dependencies

If the service is used only when someone clicks a "Preview Markdown" button, there is no reason to ship the parser, the syntax highlighter and their grammars with the initial bundle. The technique is to resolve the service through a dynamic import at the moment it is needed. Conceptually, it looks like this, using an injectAsync() helper:

const markdownService = injectAsync(
  () =>
    import('../markdown.service')
      .then(m => m.MarkdownService)
);

The component then awaits the service inside the handler that needs it:

async preview(content: string) {
  const service = await this.markdownService();

and uses it to render the content:

  this.html = service.render(content);
}

Treat injectAsync() as a pattern rather than a guaranteed core API: verify whether your Angular version or a library you use provides it. The same effect is available with a plain await import('../markdown.service') inside the handler, followed by resolving the class through the injector. Either way, the service and its heavy dependencies land in their own chunk. The broader rule is to load expensive dependencies where they become useful, not merely at route boundaries.

7. Treat DOM reads and writes as a costly API

Sometimes the bottleneck is not JavaScript at all but the browser's layout engine. It happens when code interleaves measurement and mutation:

read DOM
write DOM
read DOM
write DOM
read DOM
write DOM

Each read after a write can force the browser to recalculate layout synchronously, a pattern known as layout thrashing. Angular provides render-aware hooks for this. The first is:

afterNextRender()

and the second, which supports explicit phases, is:

afterRenderEffect()

With afterRenderEffect(), you place measurements in the earlyRead phase:

afterRenderEffect({
  earlyRead: () => {
    return element.getBoundingClientRect();
  },

and mutations in the write phase, which receives the value returned by the read (this fragment is the rest of the same TypeScript call, not CSS):

  write: height => {
    element.style.height = `${height}px`;
  }
});

Angular runs all components' read phases before their write phases, so the browser sees the work grouped like this:

READ
 ↓
READ
 ↓
READ
 ↓
WRITE
 ↓
WRITE
 ↓
WRITE

instead of constantly alternating:

READ → WRITE → READ → WRITE

This phase-based coordination matters most for charts, maps, animations, resize and scroll handling, third-party UI libraries and complex dashboards, where many components touch the DOM after each render.

8. Batch change detection with event and run coalescing

In zone-based applications, several triggers can fire in quick succession for what is logically one user action:

click
 ↓
event bubbles
 ↓
parent handler
 ↓
ngZone.run()
 ↓
another update

Without coalescing, each step can schedule its own change detection pass. Angular can merge them when you enable both options in provideZoneChangeDetection:

bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection({
      eventCoalescing: true,
      runCoalescing: true
    })
  ]
});

Instead of one pass per event:

Event A → Change Detection
Event B → Change Detection
Event C → Change Detection

overlapping work collapses into a single pass:

Event A ─┐
Event B ─┼──→ One Change Detection
Event C ─┘

This is most useful in apps with many event handlers, busy WebSocket streams, frequent ngZone.run() calls or highly interactive dashboards. Keep in mind that these options apply to Zone.js-based change detection; recent Angular versions favour zoneless change detection, where scheduling is already driven by signals and explicit notifications, so check which mode your application uses before reaching for them.

9. Find the dependencies you did not mean to ship

Some of the largest performance problems are not Angular problems but import problems. You may need a single function from a library, yet your import structure can pull in much more:

Application
   │
   └── Library
        │
        ├── Feature A
        ├── Feature B
        ├── Feature C
        ├── Feature D
        └── Feature E

when all you actually used was:

Feature A

That is why bundle analysis belongs in every performance investigation. Generate production build statistics:

ng build --configuration=production --stats-json

and then inspect the output to see which files and libraries dominate the payload. Tools such as source-map-explorer work from source maps, so enable them in the build you analyze; the stats file format depends on the builder your project uses, so check which analyzer it supports. The question that matters is not whether the app feels slow but exactly what you are sending to the browser. Once that is visible, most optimizations become obvious. To keep regressions out afterwards, consider enforcing performance budgets in CI.

10. Move heavy computation off the main thread

Sometimes the framework is fine and the algorithm is the problem. Suppose a dashboard receives a dataset of this size:

100,000 records

and runs statistics, image processing, geometry, large transformations, complex parsing or aggregation over it in the browser. On the main thread, that work competes with everything else:

Main Thread
│
├── Angular
├── Rendering
├── User input
├── Animations
└── 100K-record calculation

The result is jank: frozen input, stuttering animations and delayed rendering. A Web Worker moves the calculation to a separate thread and sends back only the result:

Main Thread                  Worker
     │                          │
     │──── large dataset ─────>│
     │                          │
     │                       Calculate
     │                       Transform
     │                       Analyze
     │                          │
     │<──── result ─────────────│
     │
Render UI

The Angular CLI can scaffold a worker and wire up its configuration:

ng generate web-worker app

and the component creates it with a module-relative URL so the bundler can find and build it:

const worker =
  new Worker(
    new URL('./app.worker', import.meta.url)
  );

A worker does not make Angular's own work cheaper. What it does is keep expensive application logic from blocking rendering and user interaction. Use workers only when the computation is genuinely heavy, because data passed between threads is copied by structured cloning, and that transfer has a cost of its own. For a framework-neutral look at that decision, see when to move CPU work into a Web Worker.

Combining the techniques on one dashboard

These are not ten isolated tricks; they reinforce each other. An unoptimized analytics dashboard does everything up front:

Browser
  │
  ├── Download huge JS bundle
  ├── Initialize everything
  ├── Hydrate everything
  ├── Start every API request
  ├── Recalculate large state
  ├── Execute expensive template functions
  ├── Perform DOM reads/writes
  └── Calculate large datasets on main thread

With the techniques applied, each layer does only what the current moment requires:

Browser
  │
  ├── Smaller initial bundle
  │      └── @defer
  │
  ├── Partial interactivity
  │      └── Incremental hydration
  │
  ├── Only latest API request
  │      └── resource/httpResource
  │
  ├── Narrow reactive updates
  │      └── Fine-grained signals
  │
  ├── Cached derived values
  │      └── computed()
  │
  ├── Heavy services loaded later
  │      └── injectAsync()
  │
  ├── Coordinated DOM work
  │      └── afterRenderEffect()
  │
  ├── Fewer redundant CD cycles
  │      └── Event coalescing
  │
  ├── Smaller dependencies
  │      └── Bundle analysis
  │
  └── Heavy computation elsewhere
         └── Web Worker

The app is not so much running faster as doing far less work.

A compact performance formula

One way to summarize the approach is as a sum of reductions:

Performance
    =
Less JavaScript
+
Less Network Work
+
Less Reactive Work
+
Less DOM Work
+
Less Main-Thread Work

Put even more briefly: fast Angular means doing less work, doing it later, and doing it only when it is needed.

A practical order of optimization

Do not apply everything at once. Measure first, then work through the layers in roughly this order, since earlier steps tend to have the largest effect on startup.

First, reduce the initial JavaScript:

@defer
injectAsync()
bundle analysis

Second, reduce initial hydration work:

Incremental Hydration

Third, cut unnecessary network work:

resource()
httpResource()

Fourth, narrow reactive invalidation:

Fine-grained signals
computed()

Fifth, reduce rendering overhead:

afterRenderEffect()
afterNextRender()

Sixth, remove redundant change detection:

eventCoalescing
runCoalescing

Seventh, eliminate CPU bottlenecks:

Web Workers

Measure before you optimize

The most important rule is to avoid optimizing blind. Sprinkling this everywhere is not a strategy:

OnPush

and neither is introducing any of these without knowing what the bottleneck is:

memoization
workers
RxJS operators
defer blocks

Profile with Chrome DevTools, Lighthouse, Angular DevTools, production bundle analysis and the browser's performance panel. Then ask the basic question:

What is actually slow?

and narrow the answer down to a category:

Network?
JavaScript?
Change detection?
Rendering?
Layout?
CPU?

Only once you know which of these dominates should you pick the matching technique. After each change, measure again to confirm the improvement is real.

From faster change detection to less work

Older Angular performance advice mostly revolved around one question:

"How can I make change detection faster?"

Modern Angular encourages a more useful one:

"Why am I running this work at all?"

That second question generalizes to every layer. Why download this component now? Why hydrate this section now? Why let an HTTP request finish when the user has already changed the query? Why invalidate an entire state object for one field? Why recompute a value whose inputs did not change? Why touch the DOM before rendering has finished? Why run heavy computation on the UI thread? Each unnecessary operation you find is an opportunity.

Key takeaways

The ten techniques target different layers, and each removes a specific kind of waste:

  • @defer with direct imports reduces initial JavaScript.
  • Incremental hydration reduces up-front hydration work.
  • resource() with an abort signal removes obsolete network work.
  • Fine-grained signals limit reactive invalidation.
  • computed() stops repeated derivations.
  • Lazily resolved services avoid eager loading of heavy dependencies.
  • afterRenderEffect() phases prevent layout thrashing.
  • Event and run coalescing remove redundant change detection in zone-based apps.
  • Bundle analysis exposes accidental dependencies.
  • Web Workers take CPU-heavy work off the main thread.

The fastest Angular application is not the one that executes JavaScript most quickly; it is the one that executes the least unnecessary JavaScript. The whole strategy fits in one picture:

MAKE ANGULAR FAST
                     │
          ┌──────────┴──────────┐
          │                     │
       DO LESS              DO LATER
          │                     │
    ┌─────┼─────┐         ┌─────┼─────┐
    │     │     │         │     │     │
  Less   Less  Less      Defer Lazy  Hydrate
  JS    CPU   DOM       code   data  later
    │     │     │         │     │     │
    └─────┴─────┴─────────┴─────┴─────┘
                     │
                     ▼
              FASTER EXPERIENCE

Measure, find the wasted work, remove it, and measure again.