He revisado código donde un desarrollador usó debounceTime en un handler de scroll y no podía entender por qué la actualización del header fijo se sentía rota. El header solo se actualizaba después de que el usuario dejara de hacer scroll. La solución fue una palabra: throttleTime. Los dos operadores no son intercambiables. Resuelven problemas diferentes, y usar el incorrecto produce bugs que parecen problemas de timing pero que en realidad son semánticos.
Debounce: Dispara Solo Después de Que Todo Se Calme
debounceTime(ms) retrasa la emisión hasta que la fuente ha estado en silencio durante la duración especificada. Cada nuevo evento reinicia el temporizador. Nada se dispara hasta que los eventos dejan de llegar.
El ejemplo más claro: búsqueda mientras escribes. Un usuario escribe 'angular signals'. Son 15 pulsaciones de tecla, cada una disparando una emisión de valueChanges. Con debounceTime(350), solo se dispara una petición HTTP, después de que el usuario hace una pausa. Sin él, envías 15 peticiones, las primeras 14 de las cuales son desperdiciadas.
Throttle: Dispara Regularmente, Pero No Demasiado Seguido
throttleTime(ms) permite una emisión por intervalo, sin importar cuántos eventos lleguen. Los eventos siguen fluyendo, pero solo uno pasa por ventana de tiempo.
El ejemplo más claro: seguimiento de posición de scroll. El evento de scroll puede dispararse en cada frame renderizado durante un scroll, 60 veces por segundo o más. Quieres que tu UI se actualice mientras el usuario hace scroll, no después de que se detenga. throttleTime(150) te da aproximadamente 6 actualizaciones por segundo, que es suficientemente fluido para cualquier feedback visual sin quemar CPU.
El Modelo Mental en Una Línea
- Debounce: te importa el valor FINAL después de que la actividad se detiene
- Throttle: te importan las actualizaciones REGULARES mientras la actividad está en curso
- Usa debounce para: inputs de búsqueda, disparadores de validación de formularios, resize de ventana donde importan las dimensiones finales
- Usa throttle para: posición de scroll, seguimiento de posición del mouse, límites de refresco de dashboards en tiempo real
Componente de Búsqueda: debounceTime + distinctUntilChanged + switchMap
Estos tres operadores van juntos en todo input de búsqueda. debounceTime reduce las llamadas a la API. distinctUntilChanged evita disparar cuando el valor no ha cambiado realmente (el usuario hace tab de ida y vuelta sin editar). switchMap cancela la petición en vuelo si una nueva comienza.
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Product, ProductService } from './product.service';
@Component({
selector: 'app-product-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `<input [formControl]="searchControl" placeholder="Search products" />`
})
export class ProductSearchComponent {
private productService = inject(ProductService);
searchControl = new FormControl('');
results: Product[] = [];
constructor() {
this.searchControl.valueChanges.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap(query => this.productService.search(query ?? '')),
takeUntilDestroyed()
).subscribe(products => this.results = products);
}
}Rastreador de Scroll: throttleTime
En páginas de aplicación de formato largo, rastrear la profundidad de scroll para entender el abandono de usuarios es un requisito común. throttleTime mantiene la tasa de eventos razonable sin retrasar las actualizaciones hasta que el scroll se detenga.
import { Component, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { fromEvent, map, throttleTime } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-scroll-tracker', standalone: true, template: '' })
export class ScrollTrackerComponent {
private document = inject(DOCUMENT);
scrollPercent = 0;
constructor() {
const view = this.document.defaultView;
if (!view) {
return;
}
fromEvent(view, 'scroll').pipe(
throttleTime(150),
map(() => {
const scrollTop = view.scrollY;
const docHeight = this.document.documentElement.scrollHeight - view.innerHeight;
return Math.round((scrollTop / docHeight) * 100);
}),
takeUntilDestroyed()
).subscribe(percent => this.scrollPercent = percent);
}
}Todo pasa por el document inyectado y su defaultView, nunca por los globales window o document. Ese es el punto de inyectar DOCUMENT: en el servidor no hay window, defaultView es null, y el componente sale temprano en lugar de lanzar un error durante el renderizado del lado del servidor.
Errores Que He Visto en Producción
- debounce en scroll: el header fijo o la barra de progreso solo se actualiza después de que el usuario se detiene. Parece roto.
- throttle en búsqueda: las peticiones siguen disparándose mientras se escribe. Reduces la cantidad pero no eliminas el desperdicio.
- debounceTime sin switchMap: reduces las peticiones, pero las respuestas obsoletas pueden llegar desordenadas y sobreescribir resultados más recientes.
- debounceTime por encima de 500ms: los usuarios notan el retraso y asumen que la app es lenta. 300-400ms es el punto ideal para búsqueda.
- throttleTime por encima de 300ms en scroll: stuttering visible en los elementos de UI que rastrean la posición.
auditTime: El Que Probablemente No Has Usado
auditTime(ms) es como throttleTime pero dispara al FINAL del intervalo con el valor más reciente, en lugar del PRIMER valor que llegó. Esto es útil cuando quieres comportamiento de throttle pero necesitas el último valor en la ventana, no el primero. Lo uso para autoguardado de formularios en tiempo real: disparar cada 2 segundos, pero con el estado más actual del formulario, no el estado de hace 2 segundos.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { auditTime, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DraftService } from './draft.service';
@Component({
selector: 'app-application-draft',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input formControlName="amount" />
</form>
`
})
export class ApplicationDraftComponent {
private fb = inject(FormBuilder);
private draftService = inject(DraftService);
form = this.fb.group({ amount: [0] });
constructor() {
this.form.valueChanges.pipe(
auditTime(2000),
switchMap(value => this.draftService.save(value)),
takeUntilDestroyed()
).subscribe();
}
}Debounce es sobre la respuesta final. Throttle es sobre el progreso regular. Entiende eso bien y la elección siempre será obvia.
Referencias
- RxJS debounceTime. https://rxjs.dev/api/operators/debounceTime
- RxJS throttleTime. https://rxjs.dev/api/operators/throttleTime
- RxJS auditTime. https://rxjs.dev/api/operators/auditTime
- RxJS Marbles interactive. https://rxmarbles.com/