shareReplay: como fazer cache de requisição HTTP e evitar chamada duplicada no Angular
shareReplay: como fazer cache de requisição HTTP e evitar chamada duplicada no Angular
Quem nunca viu um componente disparar 3 requisições HTTP idênticas assim que abriu a tela? Normalmente nesses casos três serviços diferentes se inscrevem no mesmo Observable: cada um recebe sua própria cópia da resposta, e o backend responde a mesma pergunta três vezes seguidas. E eu já vi isso em produção mais vezes do que gostaria xD.
A solução é usar o shareReplay, que resolve fácil o problema. Mas pera: colar o operador sem entender por que ele
resolve é apostar no tigrinho. Então a gente precisa mesmo é entender como funciona.
Observables cold e hot
Um Observable cold só produz valor quando alguém se inscreve, e cada
subscribe() novo dispara uma execução própria, do zero. Por exemplo,
this.http.get(url) é cold: três inscrições disparam três chamadas HTTP
separadas, mesmo que peçam a mesma URL ao mesmo tempo.
Um Observable hot já está produzindo (ou compartilha uma única origem), mas não importa quem está ouvindo. E quem se inscreve depois só entra na festa que já está rolando desde sexta (imagine que hoje é domingo).

O papel do shareReplay é pegar um Observable cold e transformar numa versão hot. A
primeira inscrição dispara a execução real. Então todo mundo que se inscrever
depois, mesmo que em alguns milissegundos depois, recebe o valor cacheado em vez
de disparar uma execução nova.
shareReplay({ bufferSize: 1, refCount: true })
Assim, na prática, temos um serviço com duas formas de buscar o mesmo dado:
const URL = 'https://jsonplaceholder.typicode.com/todos/1';
@Injectable({ providedIn: 'root' })
export class DataService {
callsWithoutCache = 0;
callsWithCache = 0;
private readonly cachedData$ = this.http.get(URL).pipe(
tap(() => this.callsWithCache++),
shareReplay({ bufferSize: 1, refCount: true }),
);
constructor(private readonly http: HttpClient) {}
fetchWithoutCache(): Observable<unknown> {
return this.http.get(URL).pipe(tap(() => this.callsWithoutCache++));
}
fetchWithCache(): Observable<unknown> {
return this.cachedData$;
}
}
O detalhe que faz isso funcionar é o cachedData$, que é criado uma vez, como
propriedade da classe. Se você colocar esse .pipe(shareReplay(...)) dentro de um
método, você perde tudo: cada chamada cria uma instância nova do
operador, e o cache nunca é compartilhado com ninguém. Ou seja, cada
chamador ganha seu próprio shareReplay particular. Isso é decoração, não cache.
Já o bufferSize: 1 Guarda só o valor mais recente pra quem chegar depois.
Isso faz sentido pra um cache de estado atual, porque a resposta mais nova é o que
importa. Já bufferSize: Infinity guardaria o histórico inteiro de
emissões: útil pra replay de eventos, mas exagero pra cache de requisição.
Fiz a simulação disso com 3 "consumidores" se inscrevendo em cada versão, ao mesmo tempo:

3 chamadas de um lado. 1 do outro.
Uma pegadinha que eu cai: refCount não reinicia o cache sozinho
Minha expectativa era que com refCount: true,
quando o último inscrito for embora, o cache resetaria, e a próxima
inscrição dispararia uma chamada HTTP nova. Então cliquei duas vezes seguidas em
"buscar de novo", bem depois da primeira resposta já ter chegado e todo
mundo já ter cancelado a inscrição. Resultado: o contador ficou travado em 1 pra
sempre.
Por quê? Por baixo dos panos, shareReplay sempre roda com
resetOnComplete: false, não importa o valor de refCount. E quando a
origem completa sozinha (uma chamada HTTP completa assim que a
resposta chega), o valor guardado no buffer nunca é descartado: ele fica
em memória pelo tempo de vida do serviço inteiro. Já refCount não controla
isso: ele controla só se a inscrição é encerrada quando ninguém mais está
ouvindo, e isso só importa numa origem que nunca completa por conta
própria.
Ou seja:
shareReplaynumHttpClient.get()cacheia pra sempre, até o serviço ser destruído. Sem expiração automática nenhuma. Precisa de um cache que expira e refaz a chamada depois de um tempo?shareReplaysozinho não resolve isso: precisa de outra peça por cima (umtimerderrubando o cache, por exemplo).
O jeito mais simples de montar essa peça extra: guardar
o pipe num campo que pode virar null, e um timer derruba esse campo
depois do TTL (o "time to live", o tempo que o valor cacheado continua
valendo antes de expirar).
export const CACHE_TTL_MS = 30_000;
@Injectable({ providedIn: 'root' })
export class ExpiringCacheService {
calls = 0;
private cached$: Observable<unknown> | null = null;
constructor(private readonly http: HttpClient) {}
fetch(): Observable<unknown> {
if (!this.cached$) {
this.cached$ = this.http.get(URL).pipe(
tap(() => this.calls++),
shareReplay({ bufferSize: 1, refCount: true }),
);
timer(CACHE_TTL_MS).subscribe(() => (this.cached$ = null));
}
return this.cached$;
}
}
O timer não cancela nada que já esteja rodando, ele só joga fora a
referência guardada em cached$. Na próxima chamada, cached$ está null,
então o if recria o .pipe(shareReplay(...)) do zero, com sua própria
instância nova do operador, e isso dispara uma chamada HTTP de verdade. Ou
seja, é o mesmo mecanismo do início do post (criar o pipe dentro de um
método em vez de um campo fixo), só que dessa vez de propósito.
Fiz o teste com fakeAsync e HttpTestingController:
it('shares one HTTP call within the TTL, then fires a fresh one after it expires', fakeAsync(() => {
service.fetch().subscribe();
httpMock.expectOne(URL).flush({});
expect(service.calls).toBe(1);
service.fetch().subscribe();
httpMock.expectNone(URL);
expect(service.calls).toBe(1);
tick(CACHE_TTL_MS);
service.fetch().subscribe();
httpMock.expectOne(URL).flush({});
expect(service.calls).toBe(2);
}));
Uma chamada, cache segurando por 30 segundos, e só depois disso a próxima chamada busca de novo.
Onde o refCount realmente importa: vazamento numa origem que nunca termina
A diferença entre refCount: true e refCount: false só aparece numa
origem que nunca completa sozinha: um WebSocket, uma leitura por
polling. Simulei com interval():
@Injectable({ providedIn: 'root' })
export class LiveSourceService {
ticksWithRefCount = 0;
ticksWithoutRefCount = 0;
readonly withRefCount$ = interval(1000).pipe(
tap(() => this.ticksWithRefCount++),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly withoutRefCount$ = interval(1000).pipe(
tap(() => this.ticksWithoutRefCount++),
shareReplay({ bufferSize: 1, refCount: false }),
);
}

Teste com fakeAsync:
it('keeps ticking in the background after unsubscribing (refCount: false)', fakeAsync(() => {
const sub = service.withoutRefCount$.subscribe();
tick(3000);
expect(service.ticksWithoutRefCount).toBe(3);
sub.unsubscribe();
tick(3000);
expect(service.ticksWithoutRefCount).toBe(6); // kept climbing: this is the leak
discardPeriodicTasks();
}));
Com refCount: true, o teste trava em 3 depois do unsubscribe(). Porque a
origem desliga quando o último inscrito vai embora. Com
refCount: false, o interval() continua rodando escondido pra sempre,
mesmo que ninguém esteja ouvindo, e gasta memória e CPU à toa. E numa
aplicação real isso é uma conexão de WebSocket que nunca fecha, ou um
polling que nunca para, só porque um componente que passou por ali um dia
esqueceu de configurar refCount: true.
E não importa o que o componente faça do seu lado. Mesmo com um
ngOnDestroy chamando unsubscribe() (é o que o LeakComponent deste
post faz), isso só desconecta aquele componente específico. Enquanto isso,
a origem compartilhada continua rodando por baixo. Afinal, refCount: false
nunca desliga ela, não importa quantos inscritos sobraram.
O vazamento mora na configuração do
shareReplay. Não em quem se inscreve nele depois.
O repositório
Todo o código deste post (as versões do fetch, o cache com expiração, a
demo do vazamento, e os testes que provam esses comportamentos) está em
formigadev-sharereplay-example,
em português/inglês. Eu escolhi o Angular 15
de propósito, porque o shareReplay é RxJS puro, então não depende de nada
específico de uma versão mais nova (como o signals).
Um ultimo ponto
refCounté a parte que todo tutorial cita, com diagramazinho e tudo. Mas pra uma chamada HTTP comum, quase nunca importa: o cache já fica pra sempre de qualquer jeito. Já o que importa de verdade é onde o operador mora (se é na propriedade ou no metodo) e se a origem que você está compartilhando algum dia termina sozinha.
Eu testei bastante antes de escrever esse post. Testa antes de mandar pra produção, pelo amor do Signals.
