La mayoría de artículos sobre rendimiento en Angular tratan sobre OnPush change detection y trackBy. Esos importan, pero no es por donde empiezo. Cada app Angular que he perfilado ya tenía OnPush en algunos componentes y trackBy en algunos loops, y seguía siendo lenta. Los problemas estaban en otro lado: un bundle inicial sobredimensionado, fugas de suscripciones manteniendo vivos componentes destruidos, y listas largas renderizando cientos de nodos DOM de una vez. OnPush era irrelevante hasta que esos se arreglaran. Los ejemplos de código de abajo usan un dominio de solicitudes de préstamo puramente como ilustración; ninguno viene del codebase de un empleador.
Comienza con el bundle
Ejecuta source-map-explorer en tu chunk principal antes de hacer cualquier otra cosa. El output te dice, en bytes, qué está consumiendo tu carga inicial. En cada auditoría que he ejecutado, los mayores infractores son librerías de terceros que se importaron sin pensar y código de features que terminó en el bundle principal porque alguien importó un componente directamente en lugar de usar loadComponent.
- npx source-map-explorer dist/your-app/browser/main-*.js da un desglose visual del contenido del bundle (compila primero con --source-map, ya que los builds de producción omiten los source maps por defecto)
- Cualquier librería de más de 50KB en el bundle principal que no se necesite en la primera pantalla es candidata a mover
- Librerías de fechas, librerías de gráficos, y renderizadores de PDF son los infractores más comunes
- Cada ruta de feature que usa loadComponent o loadChildren en lugar de un import directo es una frontera de chunk
Convertir rutas de feature importadas de forma eager a lazy loading es, de manera confiable, el mayor recorte individual al bundle inicial, y el puntaje en móvil lo sigue. Todo lo demás en esta lista es incremental en comparación.
OnPush es lo básico, no una bala de plata
ChangeDetectionStrategy.OnPush le dice a Angular que omita un componente durante el change detection a menos que uno de sus inputs cambie por referencia, un evento se origine desde él, o un async pipe o signal lo marque como dirty. En un árbol de componentes con cientos de nodos, esto reduce dramáticamente el trabajo desperdiciado por ciclo de eventos.
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;
}La trampa: OnPush requiere patrones de input inmutables. Si mutas un array y pasas la misma referencia, OnPush no re-renderizará. Esto afecta a developers que hacen array.push() y esperan que la vista se actualice. Los signals no eliminan esta regla. Un signal solo notifica cuando recibe un valor nuevo, comparado con Object.is por defecto, así que update(list => [...list, item]) re-renderiza y un push() sobre el mismo array no.
Las fugas de suscripciones matan el rendimiento silenciosamente
Las suscripciones no gestionadas son el problema de rendimiento que nunca aparece en Lighthouse. Un componente se suscribe a un interval o un WebSocket y nunca se desuscribe. El componente se destruye por navegación. La suscripción sigue corriendo. Su callback sigue haciendo trabajo, mantiene el componente destruido en memoria, y en una app con Zone.js cada emisión sigue disparando una pasada de change detection en toda la aplicación. Multiplica eso por un usuario que navega la app durante treinta minutos y tienes docenas de suscripciones activas en componentes destruidos.
En cualquier codebase que corra Angular 16 o posterior, takeUntilDestroyed entra en cada feature que entrego. Después de perseguir fugas de este tipo en más de un codebase, es lo primero que busco cuando reviso una suscripción.
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
Una tabla de 500 filas no son 500 nodos DOM. Son 500 filas más cada celda dentro de ellas, así que tres columnas ya te llevan más allá de 2000 elementos. Cada uno carga estilos, event listeners, y overhead de change detection. CDK virtual scrolling renderiza solo lo que cabe en el viewport, que es el único enfoque sensato para cualquier lista que pueda crecer más allá de 100 elementos.
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 sobre métodos en templates
Una llamada a método en un template se ejecuta en cada ciclo de change detection. Un pure pipe se ejecuta solo cuando sus inputs cambian. Este es un flag de code review que levanto cada vez que lo veo.
// 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);
}
}El orden que realmente funciona
- 1. Audita el bundle. Elimina todo lo grande que no se necesite en el primer render.
- 2. Aplica lazy loading a todas las rutas de feature. Este es el cambio de mayor impacto por hora de trabajo.
- 3. Encuentra fugas de suscripciones. Agrega takeUntilDestroyed en todo el codebase como estándar.
- 4. Agrega OnPush a todos los componentes. Arregla las violaciones de inmutabilidad de inputs conforme surjan.
- 5. Agrega virtual scrolling a cualquier lista que pueda crecer más allá de 100 elementos.
- 6. Reemplaza llamadas a métodos en templates con pure pipes.
El código más rápido es el que no se ejecuta. Cada optimización en esta lista funciona difiriendo o eliminando trabajo que Angular estaba haciendo innecesariamente.
Nada de esto requiere una reestructuración arquitectónica. Todo puede aplicarse incrementalmente, feature por feature, sprint por sprint. El análisis de bundle solo te dice por dónde empezar. Después de eso es solo ejecución.
Referencias
- 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