Destino Dev

Blog

Prefetch inteligente de rotas com Signals e RxJS em Standalone Components

Por John Pires · 11 de março de 2026

Cenario pratico do dia a dia

Problema observado em apps reais

Em aplicações Angular com rotas lazy e componentes standalone é comum perceber latência ao navegar para uma rota pela primeira vez: o bundle da rota é baixado e avaliado, causando um atraso perceptível. Usuários esperam transições instantâneas e interfaces responsivas; o prefetch de rotas é uma técnica direta para reduzir essa latência. Porém não é suficiente disparar tudo ao carregar a página — isso desperdiça banda e aumenta TTI.

O objetivo aqui é construir um mecanismo de prefetch inteligente: pré-carregar apenas rotas com alta probabilidade de uso, cancelar prefetchs redundantes, expor estado reativo para a UI e operar bem com standalone components e DI moderno do Angular.

Vamos mostrar um pattern que combina Signals (para estado síncrono e autoatualizável na tree), RxJS (para gestão de intenções, debouncing e cancelamento), e diretivas/serviços injetáveis para integrar com links e menus.

Exemplos de cenário prático

Casos típicos onde o prefetch inteligente traz ganhos reais:

  • Usuário passa o mouse sobre um link em um dashboard: alta probabilidade de navegação.
  • Links próximos à viewport em uma lista longa: prefetch quando o link entra na viewport.
  • Navegação por teclado (focus): prefetch imediato no foco do elemento.

Esses pontos de sinalização (hover, focus, intersection) serão a base para nossas fontes de intenção. Usaremos RxJS para combinar e regular essas fontes, e Signals para exposição simples do estado.

Arquitetura da solucao

Componentes e responsabilidades

Arquitetura mínima necessária:

  • PrefetchService: núcleo. Recebe intents, gerencia cache, inicia import() das rotas, expõe sinais com status (idle/pending/success/error).
  • PrefetchIntentDirective: aplicada em anchors ou botões. Escuta hover/focus/click e emite intents para o serviço.
  • Route config: loadComponent com dynamic import; o prefetch service grava que a rota foi carregada para evitar double fetch.
  • Componente consumidor: pode ler signals para mostrar indicador de prefetch (ex: shimmer, hint).

Fluxo: diretiva detecta intenção -> envia um evento Rx para o serviço -> serviço combina/intensifica com debounce -> inicia prefetch (import) -> atualiza signal de estado -> rota efetua lazy load instantaneamente quando o usuário navegar.

Decisões técnicas

Decidi usar import() direto para pré-carregar o bundle da rota porque é compatível com o padrão de lazy loading mantido pelo Angular e evita dependências externas. Import não é abortável nativamente; para cancelar work flows usaremos RxJS switchMap para ignorar resultados de import anteriores e manter um cache booleano para evitar re-import redundante. A signal expõe o estado para o template sem precisar de subscriptions manuais.

Vamos também usar inject() e providers no estilo standalone para manter a solução compatível com componentes standalone e possibilitar tree-shaking.

Codigo completo com explicacoes

PrefetchService (o motor)

Contexto: serviço injetável que recebe uma chave de rota (string) e tenta pré-carregar o bundle correspondente com lógica de debounce, timeout e cache.

import { Injectable, computed, signal } from '@angular/core';
import { Subject, from, of, timer } from 'rxjs';
import { switchMap, takeUntil, catchError, tap, filter } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class PrefetchService {
  private intent$ = new Subject<{ key: string }>();
  private abort$ = new Subject<string>();

  // cache simples: key -> 'idle'|'pending'|'done'|'error'
  private statuses = new Map<string, string>();

  // signal exposta para templates
  public statusSignal = signal(this.statuses);

  constructor() {
    this.intent$
      .pipe(
        // switchMap cancela fetches anteriores para a mesma chave
        switchMap(intent => this.createPrefetchObservable(intent.key))
      )
      .subscribe();
  }

  trigger(key: string) {
    // não reenfileira se já done
    const s = this.statuses.get(key);
    if (s === 'done' || s === 'pending') return;
    this.intent$.next({ key });
  }

  private createPrefetchObservable(key: string) {
    this.setStatus(key, 'pending');

    // timeout de segurança
    return from(this.dynamicImportByKey(key)).pipe(
      tap(() => this.setStatus(key, 'done')),
      catchError((err) => {
        this.setStatus(key, 'error');
        return of(null);
      })
    );
  }

  private async dynamicImportByKey(key: string) {
    // mapear key -> import dependendo do seu app
    // exemplo: '/profile' => import('../routes/profile/profile.component')
    if (key === '/profile') {
      return await import('../routes/profile/profile.component');
    }
    if (key === '/settings') {
      return await import('../routes/settings/settings.component');
    }
    throw new Error('no-mapping');
  }

  private setStatus(key: string, status: string) {
    this.statuses.set(key, status);
    // atualizar signal com uma cópia para disparar reatividade
    this.statusSignal.set(new Map(this.statuses));
  }
}

O que acontece: o serviço mantém uma Subject de intents. Ao receber uma intent, marca como pending e chama a função de import dinâmica. Quando o bundle resolve, marca como done; em erro marca error. O signal é um Map que é substituído por cópia para notificar assinantes.

Diretiva de intenção (hover, focus, intersection)

Contexto: anexar a diretiva a elementos que representam navegação. Ela emite intent para o serviço quando o usuário demonstra intenção forte (hover + 150ms, focus imediato, ou quando o elemento entra na viewport).

import { Directive, ElementRef, HostListener, Input, inject, OnDestroy } from '@angular/core';
import { PrefetchService } from './prefetch.service';
import { fromEvent, Subscription, timer } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';

@Directive({ selector: '[prefetchIntent]' })
export class PrefetchIntentDirective implements OnDestroy {
  @Input('prefetchIntent') prefetchKey!: string;
  private sub = new Subscription();
  private svc = inject(PrefetchService);

  constructor(private el: ElementRef<HTMLElement>) {
    // intersection observer para prefetch quando entrou na viewport
    const io = new IntersectionObserver(entries => {
      for (const e of entries) {
        if (e.isIntersecting) this.svc.trigger(this.prefetchKey);
      }
    }, { threshold: 0.5 });
    io.observe(this.el.nativeElement);
    // limpa observer no destroy
    this.sub.add({ unsubscribe: () => io.disconnect() });
  }

  @HostListener('focus') onFocus() {
    this.svc.trigger(this.prefetchKey);
  }

  @HostListener('mouseenter') onHover() {
    // debounce simples: só dispare se estiver >=150ms sobre o item
    const hover$ = timer(150).subscribe(() => this.svc.trigger(this.prefetchKey));
    this.sub.add(hover$);
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }
}

O que acontece: a diretiva combina triggers padrão (focus, hover com debounce) e IntersectionObserver. Sempre que a intenção é detectada, chama PrefetchService.trigger, delegando política e cache ao serviço.

Configuração de rota com loadComponent (standalone)

Contexto: exemplo de rota standalone que será pré-carregada. O key usado pelo serviço deve corresponder ao path/identificador que o diretiva/passará.

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'profile',
    loadComponent: () => import('./routes/profile/profile.component').then(m => m.ProfileComponent)
  },
  {
    path: 'settings',
    loadComponent: () => import('./routes/settings/settings.component').then(m => m.SettingsComponent)
  }
];

O que acontece: as rotas estão definidas como standalone components via loadComponent. O PrefetchService usa as mesmas import paths internamente (mapeamento) para pré-carregar os bundles correspondentes.

Tratamento de erros e edge cases

Erros comuns e armadilhas

Erros que aparecem frequentemente ao implementar prefetch:

  • Import duplicado: chamar import() duas vezes para o mesmo bundle não quebra, mas desperdiça CPU e banda. Solução: cache no serviço (status 'done').
  • Cancelamento inadequado: import() não é abortável. Usar switchMap apenas evita side-effects lógicos, mas não interrompe bytes sendo baixados. Se banda for crítica, prefetch via fetch do arquivo chunk + cache pode ser considerado (mais complexo).
  • Mapeamento inconsistente: se o mapeamento key->import mudar, o prefetch pode carregar o bundle errado. Centralize mappings num único lugar.

Outra armadilha: confiar apenas em hover para dispositivos touch. Sempre combine com focus/touchstart e intersection para capturar a maioria dos casos.

Retries, timeouts e fallback

Implementar retry simples e timeout protege contra redes instáveis. Exemplo prático:

// dentro do PrefetchService.createPrefetchObservable
return from(this.dynamicImportByKey(key)).pipe(
  // se falhar, tente 1 vez depois de 500ms
  catchError(err => timer(500).pipe(switchMap(() => from(this.dynamicImportByKey(key))))),
  timeout(5000),
  catchError(err => { this.setStatus(key, 'error'); return of(null); })
);

O que acontece: uma tentativa extra é feita em erro; um timeout garante que não ficamos pendurados indefinidamente. Ajuste tempo conforme o ramo de deploy e tamanho dos chunks.

Dica: mantenha o mapeamento import em ambiente de build (por exemplo, gerar um arquivo map.json em build time) se você tem muitos pontos de rota. Isso evita divergências manuais entre rotas e prefetch.

Performance e otimizacao

Estratégias para reduzir custo de prefetch

Boas práticas para controlar o impacto do prefetch:

  1. Limitador global: nunca permita mais que N prefetches simultâneos. Use um semáforo no serviço.
  2. Heurísticas de rede: respeite navigator.connection.effectiveType e desative prefetch em conexões 2g ou quando save-data está ativo.
  3. Cache persistente: use IndexedDB para armazenar assets críticos quando aplicável (avançado).

Implementação de checagem básica de rede:

private shouldPrefetch(): boolean {
  const nav = (navigator as any);
  if (nav.connection) {
    const type = nav.connection.effectiveType;
    if (type === '2g' || nav.connection.saveData) return false;
  }
  return true;
}

O que acontece: o serviço verifica a condição de rede antes de iniciar o import; é um gate simples que reduz tráfego em redes lentas.

Semaforo para limitar prefetches simultaneos

Exemplo: limitar a 3 prefetches concorrentes. Mantemos um contador e uma fila simples.

Implementação: crie uma fila FIFO, incremente contador em start, decremento em complete e processe fila.

Conclusao practica

Resumo direto: usar Signals para expor estado e RxJS para orquestração permite construir um prefetch inteligente e controlável que funciona bem com standalone components. A diretiva de intenção mantém a UX simples e desacoplada do serviço. O serviço centraliza cache, políticas (retries, timeout, heurísticas de rede) e evita duplicação de imports.

Checklist prático para implementação:

  • Centralize mapeamento key -> import()
  • Use signals para status e evitar subscriptions no template
  • Combine hover/focus/intersection como fontes de intenção
  • Adicione semáforo, heurísticas de rede e timeout para robustez

Com esses padrões você reduz latência de navegação sem sacrificar banda ou estabilidade. Comece pequeno: implemente prefetch apenas para 2-3 rotas de alta prioridade e observe métricas reais antes de ampliar.

Exemplos completos estão prontos para adaptar ao seu app: copie o PrefetchService, registre a diretiva em módulos/standalone components e mapeie rotas. Ajuste timers e políticas conforme telemetria.

Se quiser, posso gerar uma versão do PrefetchService com semáforo e heurística de rede já implementada, pronta para drop-in. Também posso mostrar como gerar um mapa de imports automaticamente no pipeline de build para evitar divergências manuais.

Assuntos: Angular, signals, rxjs, standalone-components, routing, dependency-injection, prefetch

Leia também