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

Diagrama comparando Observable cold, onde cada consumidor dispara sua própria chamada HTTP, com Observable hot usando shareReplay, onde os três consumidores compartilham a mesma chamada HTTP

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:

Aplicação rodando mostrando duas seções lado a lado: sem shareReplay disparando 3 chamadas HTTP, com shareReplay disparando só 1

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: shareReplay num HttpClient.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? shareReplay sozinho não resolve isso: precisa de outra peça por cima (um timer derrubando 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 }),
  );
}

Diagrama comparando refCount true, onde a origem desliga e o contador de ticks congela assim que o último inscrito cancela, com refCount false, onde a origem continua rodando e o contador sobe pra sempre

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.

Henrique Guedes FormigaHenrique Guedes FormigaSênior Angular Developer