Já revisei código onde um desenvolvedor usou debounceTime em um handler de scroll e não conseguia entender por que a atualização do header fixo parecia quebrada. O header só atualizava depois que o usuário parava de rolar. A correção foi uma palavra: throttleTime. Os dois operadores não são intercambiáveis. Eles resolvem problemas diferentes, e usar o errado produz bugs que parecem problemas de timing mas na verdade são semânticos.
Debounce: Dispara Somente Após o Silêncio
debounceTime(ms) atrasa a emissão até que a fonte tenha ficado em silêncio pela duração especificada. Cada novo evento reinicia o timer. Nada dispara até os eventos pararem de chegar.
O exemplo mais claro: busca enquanto digita. Um usuário digita 'angular signals'. São 15 teclas pressionadas, cada uma disparando uma emissão de valueChanges. Com debounceTime(350), apenas uma requisição HTTP é feita, após o usuário pausar. Sem isso, você envia 15 requisições, sendo que as 14 primeiras são desperdiçadas.
Throttle: Dispara Regularmente, Mas Não Com Frequência Excessiva
throttleTime(ms) permite uma emissão por intervalo, independentemente de quantos eventos chegam. Os eventos continuam fluindo, mas apenas um passa por janela de tempo.
O exemplo mais claro: rastreamento de posição de scroll. O evento de scroll pode disparar a cada frame renderizado durante uma rolagem, 60 vezes por segundo ou mais. Você quer que sua UI atualize enquanto o usuário está rolando, não depois que ele para. throttleTime(150) te dá aproximadamente 6 atualizações por segundo, o que é suave o suficiente para qualquer feedback visual sem queimar CPU.
O Modelo Mental de Uma Linha
- Debounce: você se importa com o valor FINAL após a atividade parar
- Throttle: você se importa com atualizações REGULARES enquanto a atividade está em andamento
- Use debounce para: inputs de busca, triggers de validação de formulário, resize de janela onde as dimensões finais importam
- Use throttle para: posição de scroll, rastreamento de posição do mouse, limites de refresh de dashboards em tempo real
Componente de Busca: debounceTime + distinctUntilChanged + switchMap
Esses três operadores pertencem juntos em todo input de busca. debounceTime reduz chamadas de API. distinctUntilChanged previne disparos quando o valor não mudou de fato (usuário dá tab pra dentro e pra fora sem editar). switchMap cancela a requisição em andamento se uma nova começar.
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
Em páginas longas de aplicação, rastrear a profundidade de scroll para entender a desistência dos usuários é um requisito comum. throttleTime mantém a taxa de eventos saudável sem atrasar atualizações até o scroll parar.
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);
}
}Tudo passa pelo document injetado e pelo defaultView dele, nunca pelos globais window ou document. Esse é justamente o motivo de injetar DOCUMENT: no servidor não existe window, defaultView é null, e o componente sai cedo em vez de lançar erro durante a renderização no servidor.
Erros Que Já Vi em Produção
- debounce em scroll: o header fixo ou barra de progresso só atualiza depois que o usuário para. Parece quebrado.
- throttle em busca: requisições continuam disparando no meio da digitação. Você reduz a quantidade mas não elimina o desperdício.
- debounceTime sem switchMap: você reduz requisições, mas respostas obsoletas ainda podem chegar fora de ordem e sobrescrever resultados mais recentes.
- debounceTime acima de 500ms: usuários percebem o atraso e assumem que a app está lenta. 300-400ms é o ponto ideal para busca.
- throttleTime acima de 300ms em scroll: gagueira visível nos elementos de UI que rastreiam a posição.
auditTime: O Operador Que Você Provavelmente Não Usou
auditTime(ms) é como throttleTime mas dispara no FINAL do intervalo com o valor mais recente, em vez do PRIMEIRO valor que chegou. Isso é útil quando você quer o comportamento do throttle mas precisa do valor mais atual na janela, não do primeiro. Eu uso para autosave de formulários em tempo real: dispara a cada 2 segundos, mas com o estado mais atual do formulário, não o estado de 2 segundos atrás.
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 é sobre a resposta final. Throttle é sobre progresso regular. Acerte isso e a escolha será sempre óbvia.
Referências
- 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/