A maioria dos artigos sobre performance em Angular fala sobre OnPush change detection e trackBy. Isso importa, mas não é por onde eu começo. Todo app Angular que já perfilei já tinha OnPush em alguns componentes e trackBy em alguns loops, e continuava lento. Os problemas estavam em outro lugar: um bundle inicial inchado, vazamentos de subscription mantendo componentes destruídos vivos, e listas longas renderizando centenas de nós DOM de uma vez. OnPush era irrelevante até que esses fossem corrigidos. Os exemplos de código abaixo usam um domínio de solicitação de empréstimo puramente como ilustração; nenhum deles vem da base de código de um empregador.
Comece pelo Bundle
Execute source-map-explorer no seu chunk principal antes de fazer qualquer outra coisa. O resultado te mostra, em bytes, o que está consumindo seu carregamento inicial. Em toda auditoria que fiz, os maiores culpados são bibliotecas third-party que foram importadas sem pensar e código de features que acabou no bundle principal porque alguém importou um componente diretamente ao invés de usar loadComponent.
- npx source-map-explorer dist/your-app/browser/main-*.js dá um breakdown visual do conteúdo do bundle (faça o build com --source-map primeiro, já que builds de produção omitem os source maps por padrão)
- Qualquer biblioteca acima de 50KB no bundle principal que não é necessária na primeira tela é candidata a ser movida
- Bibliotecas de datas, bibliotecas de gráficos e renderizadores de PDF são os culpados mais comuns
- Toda rota de feature que usa loadComponent ou loadChildren ao invés de import direto é uma fronteira de chunk
Converter rotas de feature importadas eagerly para lazy loading é, de forma consistente, o maior corte isolado no bundle inicial, e o score mobile acompanha. Todo o resto desta lista é incremental em comparação.
OnPush é o Básico, Não uma Bala de Prata
ChangeDetectionStrategy.OnPush diz ao Angular para pular um componente durante o change detection a menos que um dos seus inputs mude por referência, um evento se origine dele, ou um async pipe ou signal o marque como dirty. Em uma árvore de componentes com centenas de nós, isso reduz drasticamente o trabalho desperdiçado por ciclo de evento.
import { CurrencyPipe } from '@angular/common';
@Component({
// Attribute selector, not an element selector. <app-application-row> inside a <tbody> is
// invalid HTML, and the browser hoists it out of the table before Angular gets a say.
selector: 'tr[appApplicationRow]',
standalone: true,
imports: [CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<td>{{ application.applicantName }}</td>
<td>{{ application.requestedAmount | currency:'CAD' }}</td>
<td [class]="'status-' + application.status">{{ application.status }}</td>
`
})
export class ApplicationRowComponent {
@Input({ required: true }) application!: LoanApplication;
}O porém: OnPush exige padrões de input imutável. Se você muta um array e passa a mesma referência, OnPush não vai re-renderizar. Isso pega desenvolvedores que fazem array.push() e esperam que a view atualize. Signals não eliminam essa regra. Um signal só notifica quando recebe um valor novo, comparado com Object.is por padrão, então update(list => [...list, item]) re-renderiza e um push() no mesmo array não.
Vazamentos de Subscription Matam a Performance Silenciosamente
Subscriptions não gerenciadas são o problema de performance que nunca aparece no Lighthouse. Um componente faz subscribe em um interval ou um WebSocket e nunca faz unsubscribe. O componente é destruído pela navegação. A subscription continua rodando. O callback dela continua trabalhando, ela mantém o componente destruído na memória, e em um app com Zone.js cada emissão ainda dispara uma passada de change detection em toda a aplicação. Multiplique isso por um usuário que navega no app por trinta minutos e você tem dezenas de subscriptions ativas em componentes destruídos.
Em qualquer base de código rodando Angular 16 ou superior, takeUntilDestroyed entra em toda feature que eu entrego. Depois de caçar vazamentos desse tipo em mais de uma base de código, é a primeira coisa que procuro ao revisar uma subscription.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';
@Component({ standalone: true })
export class ApplicationStatusComponent implements OnInit {
private statusService = inject(ApplicationStatusService);
private destroyRef = inject(DestroyRef);
@Input({ required: true }) applicationId!: string;
status = signal<ApplicationStatus | null>(null);
ngOnInit(): void {
// Polling every 10s for status updates
this.statusService.pollStatus(this.applicationId).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(s => this.status.set(s));
}
}Virtual Scrolling para Listas Grandes
Uma tabela de 500 linhas não são 500 nós DOM. São 500 linhas mais cada célula dentro delas, então três colunas já te levam além de 2000 elementos. Cada um carrega estilos, event listeners e overhead de change detection. O virtual scrolling do CDK renderiza apenas o que cabe no viewport, que é a única abordagem sensata para qualquer lista que pode crescer além de 100 itens.
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
standalone: true,
imports: [ScrollingModule, ApplicationRowComponent],
// The viewport has to be the scrolling container, so the table lives inside it. Set
// table-layout: fixed and a row height that matches itemSize, or the recycling maths drifts.
template: `
<cdk-virtual-scroll-viewport itemSize="64" style="height: 70vh">
<table class="application-table">
<tbody>
<tr appApplicationRow
*cdkVirtualFor="let app of applications(); trackBy: trackById"
[application]="app"
></tr>
</tbody>
</table>
</cdk-virtual-scroll-viewport>
`
})
export class ApplicationListComponent {
applications = input.required<LoanApplication[]>();
trackById = (_: number, app: LoanApplication) => app.id;
}Pure Pipes Ao Invés de Métodos no Template
Uma chamada de método no template roda em todo ciclo de change detection. Um pure pipe roda apenas quando seus inputs mudam. Isso é um flag de code review que eu levanto toda vez que vejo.
// In the template. Bad: runs on every CD cycle
// <td>{{ formatAmount(application.requestedAmount) }}</td>
// Good: runs only when requestedAmount changes
// <td>{{ application.requestedAmount | loanAmount }}</td>
@Pipe({ name: 'loanAmount', standalone: true, pure: true })
export class LoanAmountPipe implements PipeTransform {
transform(value: number): string {
return new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: 'CAD',
maximumFractionDigits: 0
}).format(value);
}
}A Ordem Que Realmente Funciona
- 1. Audite o bundle. Corte tudo que é grande e não é necessário na primeira renderização.
- 2. Aplique lazy loading em todas as rotas de feature. Essa é a mudança de maior impacto por hora de trabalho.
- 3. Encontre vazamentos de subscription. Adicione takeUntilDestroyed em toda a codebase como padrão.
- 4. Adicione OnPush em todos os componentes. Corrija violações de imutabilidade de input conforme aparecerem.
- 5. Adicione virtual scrolling em qualquer lista que pode crescer além de 100 itens.
- 6. Substitua chamadas de método no template por pure pipes.
O código mais rápido é o código que não roda. Toda otimização desta lista funciona adiando ou eliminando trabalho que o Angular estava fazendo desnecessariamente.
Nada disso exige uma reestruturação arquitetural. Tudo pode ser aplicado incrementalmente, feature por feature, sprint por sprint. A análise de bundle por si só te diz por onde começar. Depois disso é só execução.
Referências
- Angular change detection with OnPush. https://angular.dev/best-practices/skipping-subtrees
- Angular CDK virtual scrolling. https://material.angular.io/cdk/scrolling/overview
- takeUntilDestroyed. https://angular.dev/api/core/rxjs-interop/takeUntilDestroyed
- Core Web Vitals. https://web.dev/articles/vitals