Volver al Blog
Angular Development8 de enero de 20267 min de lectura

Debounce vs Throttle: La Diferencia Que Salvará Tu App Angular

DR

Doug Ribeiro

7 min de lectura

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.

debounceTimeEspera silencio antes de emitirReinicia el temporizador con ca…Emite solo el ÚLTIMO valor de u…Ideal para: campos de búsqueda,…Riesgo: nunca emite si los even…Marble: --a--b--c------| → ----…throttleTimeEmite el primer evento y luego …El temporizador NO se reinicia …Emite a intervalos regulares du…Ideal para: handlers de scroll,…Riesgo: descarta valores interm…Marble: --a--b--c------| → --a-…VS

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.

typescript
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.

typescript
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.

typescript
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

AngularRxJSPerformanceTypeScript