HTTP, datos y complejidad en el cliente

Por: Artiko
angularbuenas-practicashttpclientinterceptoreshttp-resourcerendimientocomplejidad-algoritmica

HTTP, datos y complejidad en el cliente

La capa de datos es donde una app deja de ser una demo. Acá se cruzan tres problemas de calidad distintos que se suelen tratar como uno solo: cómo pedís los datos (HTTP tipado, errores, cancelación), cómo evitás pedirlos de más (caching, deduplicación, over-fetching) y qué hacés con ellos una vez que llegan (transformar, filtrar y ordenar sin colgar el hilo principal). Un O(n²) escondido en un filter anidado no aparece con 50 filas y te tira la app con 5.000; un request duplicado por cada tecla no rompe nada hasta que el backend te limita. Este capítulo ataca las tres dimensiones con las herramientas de Angular 22: HttpClient, interceptores funcionales, httpResource/rxResource y las estructuras de datos correctas para procesar en el cliente.

Configurar HttpClient: provideHttpClient y fetch

En una app standalone no hay HttpClientModule. HttpClient se provee con una función en app.config.ts:

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
  provideHttpClient,
  withFetch,
  withInterceptors,
} from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withFetch(),
      withInterceptors([authInterceptor, retryInterceptor, errorInterceptor]),
    ),
  ],
};

withFetch() hace que HttpClient use la Fetch API del navegador en lugar de XMLHttpRequest. Importa porque el backend basado en fetch es el que soporta streaming de respuestas y es el requerido para que la hidratación con SSR transfiera el estado correctamente.

Nota de versión: desde Angular 21 el backend fetch es el predeterminado, así que en Angular 22 withFetch() es redundante en apps nuevas. Se sigue mostrando porque hace explícita la intención y porque muchos proyectos vienen migrando desde versiones donde había que activarlo a mano. Si necesitás volver a XMLHttpRequest (por progreso de descarga granular o compatibilidad con algún interceptor viejo), existe withXhr().

Tipar las respuestas (y saber qué garantiza el tipo)

Los métodos aceptan un genérico que asevera la forma de la respuesta:

interface Usuario {
  id: string;
  nombre: string;
  email: string;
}

this.http.get<Usuario>(`/api/usuarios/${id}`);

El punto que se olvida: get<Usuario> es una aserción de tipos, no una validación en runtime. HttpClient no verifica que el servidor haya devuelto realmente un Usuario. Si el backend cambia un campo, TypeScript sigue creyendo que todo está bien y el error explota tres capas más adelante, lejos de la causa. Para datos que cruzan un límite de confianza (una API externa, un contrato que no controlás), validá en runtime con un esquema. Más abajo se ve cómo hacerlo con la opción parse de httpResource; con HttpClient imperativo, hacelo en el map:

import { z } from 'zod';

const usuarioSchema = z.object({
  id: z.string(),
  nombre: z.string(),
  email: z.string().email(),
});

this.http.get<unknown>(`/api/usuarios/${id}`).pipe(
  map((raw) => usuarioSchema.parse(raw)), // valida y tipa en runtime
);

Manejo de errores

Los errores HTTP llegan por el canal de error del observable como HttpErrorResponse. Un detalle diagnóstico útil: status: 0 indica un fallo de red o CORS (el request nunca llegó al servidor); cualquier otro código es una respuesta real del backend.

import { HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';

this.http.get<Usuario>(`/api/usuarios/${id}`).pipe(
  catchError((err: HttpErrorResponse) => {
    const mensaje =
      err.status === 0
        ? 'Sin conexión con el servidor'
        : `El servidor respondió ${err.status}`;
    return throwError(() => new Error(mensaje));
  }),
);

En la práctica, el manejo de errores repetitivo (loguear, mostrar un toast, redirigir en un 401) no va en cada llamada: va en un interceptor centralizado, como se ve a continuación.

Interceptores funcionales

Un interceptor es una función que recibe el request y una función next para pasarlo al siguiente eslabón de la cadena. Los interceptores basados en clase (HttpInterceptor + withInterceptorsFromDi()) siguen existiendo, pero los funcionales (HttpInterceptorFn) son la forma actual: menos boilerplate, inject() disponible directamente y registro por orden explícito.

La firma:

import { HttpInterceptorFn } from '@angular/common/http';

export const miInterceptor: HttpInterceptorFn = (req, next) => {
  // ... transformar req si hace falta
  return next(req);
};

HttpRequest es inmutable: nunca mutes req directamente, cloná con los cambios.

Auth: inyectar el token

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).getToken();
  if (!token) return next(req);

  const autenticado = req.clone({
    headers: req.headers.set('Authorization', `Bearer ${token}`),
  });
  return next(autenticado);
};

Como el interceptor corre en el contexto de inyección del injector que lo registró, inject() funciona sin constructor.

Logging con timing

import { HttpEventType } from '@angular/common/http';
import { tap } from 'rxjs';

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const inicio = performance.now();
  return next(req).pipe(
    tap((evento) => {
      if (evento.type === HttpEventType.Response) {
        const ms = Math.round(performance.now() - inicio);
        console.debug(`${req.method} ${req.urlWithParams} → ${evento.status} (${ms}ms)`);
      }
    }),
  );
};

Reintentos con backoff

retry a secas reintenta cualquier error, incluido un 400 que nunca va a mejorar. Reintentá solo errores transitorios (red o 5xx) y con un retardo creciente para no martillar al servidor:

import { retry, timer, throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  // No reintentes mutaciones no idempotentes
  if (req.method !== 'GET') return next(req);

  return next(req).pipe(
    retry({
      count: 3,
      delay: (error: HttpErrorResponse, intento) => {
        const transitorio = error.status === 0 || error.status >= 500;
        if (!transitorio) return throwError(() => error);
        return timer(Math.pow(2, intento) * 300); // 300ms, 600ms, 1200ms
      },
    }),
  );
};

Manejo de errores centralizado

import { catchError, throwError } from 'rxjs';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  const notificador = inject(NotificacionService);

  return next(req).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status === 401) router.navigate(['/login']);
      else if (err.status >= 500) notificador.error('Error del servidor, intentá más tarde');
      return throwError(() => err);
    }),
  );
};

El orden importa

Los interceptores se encadenan en el orden en que los listás en withInterceptors([...]). Ese orden determina la semántica, no es cosmético:

flowchart LR
  C[Componente/Service] --> A[auth]
  A --> R[retry]
  R --> E[error]
  E --> B[(Backend)]
  B -.respuesta.-> E
  E -.-> R
  R -.-> A
  A -.-> C

En la ida, el request atraviesa auth → retry → error → backend. En la vuelta, la respuesta atraviesa la cadena al revés: backend → error → retry → auth. Consecuencias concretas:

Caching y deduplicación de requests

Dos requests idénticos disparados con 50ms de diferencia (un @for que pide el avatar de cada fila, un computed que se recalcula) son dos viajes de red para el mismo dato. Hay tres niveles de solución, de menos a más control.

Deduplicación con shareReplay (con cuidado)

shareReplay multicasta una única suscripción a todos los consumidores. Sirve para que N componentes que piden lo mismo compartan un solo request:

private readonly config$ = this.http.get<Config>('/api/config').pipe(
  shareReplay({ bufferSize: 1, refCount: true }),
);

El detalle que causa memory leaks: shareReplay({ bufferSize: 1 }) sin refCount: true mantiene la suscripción viva para siempre y retiene el último valor aunque no quede nadie escuchando. Con refCount: true, la fuente se desconecta cuando el último suscriptor se va y se vuelve a pedir en la próxima suscripción. Usá refCount: true salvo que quieras deliberadamente un cache que persista sin consumidores.

Cache manual con signals

Para un cache con TTL y control explícito, un Map con signals es más predecible que encadenar operadores de RxJS. La clave del Map es la URL; el lookup es O(1):

import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, tap } from 'rxjs';

interface Entrada<T> {
  dato: T;
  expira: number;
}

@Injectable({ providedIn: 'root' })
export class UsuarioCache {
  private readonly http = inject(HttpClient);
  private readonly cache = new Map<string, Entrada<Usuario>>();
  private readonly ttl = 60_000; // 1 min

  obtener(id: string): Observable<Usuario> {
    const clave = `/api/usuarios/${id}`;
    const cacheado = this.cache.get(clave); // O(1)
    if (cacheado && cacheado.expira > Date.now()) {
      return of(cacheado.dato);
    }
    return this.http.get<Usuario>(clave).pipe(
      tap((dato) => this.cache.set(clave, { dato, expira: Date.now() + this.ttl })),
    );
  }

  invalidar(id: string): void {
    this.cache.delete(`/api/usuarios/${id}`);
  }
}

Deduplicación de requests en vuelo

El cache por TTL no cubre el caso de dos requests simultáneos al mismo recurso antes de que ninguno responda: ambos ven el cache vacío y salen. La solución es guardar el Observable en vuelo (no el resultado) y devolver el mismo a todos:

private readonly enVuelo = new Map<string, Observable<Usuario>>();

obtener(id: string): Observable<Usuario> {
  const clave = `/api/usuarios/${id}`;
  const existente = this.enVuelo.get(clave);
  if (existente) return existente; // reusa el request en curso

  const request$ = this.http.get<Usuario>(clave).pipe(
    finalize(() => this.enVuelo.delete(clave)),
    shareReplay({ bufferSize: 1, refCount: true }),
  );
  this.enVuelo.set(clave, request$);
  return request$;
}

Data fetching moderno: httpResource

httpResource es un envoltorio reactivo sobre HttpClient que expone el estado y la respuesta como signals. Se re-ejecuta solo cuando cambian los signals de los que depende, y cancela el request anterior si todavía estaba en vuelo cuando llega uno nuevo. Eso último resuelve de forma nativa las race conditions que con HttpClient imperativo tenías que manejar a mano con switchMap.

import { httpResource } from '@angular/common/http';
import { signal } from '@angular/core';

usuarioId = signal('1');

// Se re-fetchea automáticamente cuando usuarioId() cambia
usuario = httpResource<Usuario>(() => `/api/usuarios/${this.usuarioId()}`);

La función que pasás es reactiva: Angular rastrea qué signals leés dentro y vuelve a pedir cuando cambian. La diferencia clave con HttpClient es que httpResource dispara el request de forma eager (apenas se crea y ante cada cambio), mientras que el Observable de HttpClient no hace nada hasta que te suscribís.

Estado como signals, directo al template con el nuevo control flow:

@if (usuario.hasValue()) {
  <app-detalle [usuario]="usuario.value()" />
} @else if (usuario.error()) {
  <p>No se pudo cargar el usuario</p>
} @else if (usuario.isLoading()) {
  <p>Cargando…</p>
}

Validación en runtime con parse

httpResource acepta una opción parse que transforma y valida la respuesta. El tipo del recurso lo determina el valor de retorno de parse, no un genérico ficticio:

import { z } from 'zod';

const usuarioSchema = z.object({
  id: z.string(),
  nombre: z.string(),
  email: z.string().email(),
});

usuario = httpResource(() => `/api/usuarios/${this.usuarioId()}`, {
  parse: usuarioSchema.parse, // valida en runtime; el tipo sale del schema
});

httpResource vs HttpClient imperativo

flowchart TD
  Q{¿Qué necesitás?}
  Q -->|Leer datos que dependen de signals| HR[httpResource]
  Q -->|Mutación POST/PUT/DELETE| HC[HttpClient imperativo]
  Q -->|Composición compleja de streams| RX[HttpClient + RxJS]
  HR --> HRd[Cancelación y refetch automáticos<br/>Estado como signals<br/>Ideal para vistas reactivas]
  HC --> HCd[Control imperativo<br/>Efectos secundarios explícitos]
  RX --> RXd[switchMap, combineLatest,<br/>debounce, retry a medida]

Usá httpResource para lecturas reactivas (una vista que depende de un id, un filtro o un signal de estado). Usá HttpClient imperativo para mutaciones (POST/PUT/DELETE): la doc oficial recomienda no usar httpResource para mutaciones. Y usá HttpClient + RxJS cuando necesites composición fina de streams (combinar varias fuentes, debounceTime en una búsqueda, orquestar dependencias).

Para casos complejos de servidor —cache compartido entre rutas, invalidación por mutación, reintentos con estado, optimistic updates, paginación infinita— httpResource se queda corto y conviene una librería dedicada. Eso se cubre en el capítulo 13: Librerías del ecosistema con TanStack Query. rxResource es la variante para cuando tu fuente es un Observable en lugar de una URL: mismo modelo de signals, pero el loader devuelve un stream.

Complejidad algorítmica en el cliente

Traer los datos es la mitad; procesarlos es donde una app se cuelga con datasets grandes. El costo acá no es de red, es de CPU en el hilo principal, el mismo que pinta la UI. Un algoritmo O(n²) sobre 5.000 registros son 25 millones de operaciones que congelan el frame.

El antipatrón O(n²): búsqueda anidada

Cruzar dos listas con un find adentro de un map (o de otro filter) es cuadrático:

// ANTIPATRÓN: O(n × m). Con 5.000 pedidos y 2.000 clientes ≈ 10M iteraciones
const enriquecidos = pedidos.map((pedido) => ({
  ...pedido,
  cliente: clientes.find((c) => c.id === pedido.clienteId), // O(m) por cada pedido
}));

El find recorre clientes entero por cada pedido. La solución es indexar una vez con un Map (O(m) para construirlo) y después hacer lookups O(1):

// MEJOR: O(n + m). Un solo recorrido para indexar, luego lookups O(1)
const indice = new Map(clientes.map((c) => [c.id, c]));
const enriquecidos = pedidos.map((pedido) => ({
  ...pedido,
  cliente: indice.get(pedido.clienteId), // O(1)
}));

Pasás de ~10.000.000 de operaciones a ~7.000. La misma idea con Set para chequeos de pertenencia:

// ANTIPATRÓN: array.includes() dentro de un filter → O(n × k)
const seleccionadosArr: string[] = [/* ... */];
const filtrados = items.filter((i) => seleccionadosArr.includes(i.id)); // includes es O(k)

// MEJOR: Set con lookup O(1) → O(n)
const seleccionados = new Set(seleccionadosArr);
const filtrados = items.filter((i) => seleccionados.has(i.id));

Memoizar derivaciones con computed

Si transformás una lista en el getter de un template o en una propiedad que se recalcula en cada detección de cambios, pagás el costo en cada frame. computed memoiza: recalcula solo cuando cambian sus dependencias.

// ANTIPATRÓN: método llamado desde el template → corre en cada CD
// <div *ngFor... no; con @for igual: {{ ordenarYFiltrar() }} recomputa siempre

// MEJOR: computed memoizado, recomputa solo si datos o filtro cambian
readonly datos = signal<Registro[]>([]);
readonly filtro = signal('');

readonly visibles = computed(() => {
  const q = this.filtro().toLowerCase();
  return this.datos()
    .filter((r) => r.nombre.toLowerCase().includes(q))
    .sort((a, b) => a.nombre.localeCompare(b.nombre));
});

No traigas todo: paginación y virtualización

El error de raíz suele ser over-fetching: pedir 10.000 filas para mostrar 20. Antes de optimizar el procesamiento, preguntate si necesitás el dataset entero en el cliente. Dos estrategias complementarias:

Datasets enormes: sacar el trabajo del hilo principal

Si tenés que procesar de verdad un dataset grande (parsear un CSV de 50MB, agregar cientos de miles de puntos), hacerlo en el hilo principal congela la UI aunque el algoritmo sea O(n). Movelo a un Web Worker: corre en otro hilo y devuelve el resultado por mensajes, sin bloquear el render.

ng generate web-worker procesador
// procesador.worker.ts
addEventListener('message', ({ data }) => {
  const resultado = agregarPesado(data); // trabajo intensivo fuera del hilo de UI
  postMessage(resultado);
});

Cómo encaja todo: el flujo completo

flowchart TD
  subgraph Cliente
    Comp[Componente]
    Svc[Data Service / httpResource]
    Cache[(Cache: Map + signals)]
    Comp -->|nunca HTTP directo| Svc
    Svc -->|1. consulta| Cache
    Cache -->|hit válido| Svc
  end
  Svc -->|2. miss| I1[auth]
  I1 --> I2[retry]
  I2 --> I3[error]
  I3 --> BE[(Backend)]
  BE -.respuesta.-> I3
  I3 -.-> I2
  I2 -.-> I1
  I1 -->|3. guarda| Cache
  Svc -->|4. transforma con Map/Set + computed| Comp

El request nace en un service (nunca en el componente), consulta el cache, y solo ante un miss atraviesa la cadena de interceptores hasta el backend. La respuesta vuelve por la cadena inversa, se guarda en cache y el service la transforma con las estructuras correctas antes de exponerla como signal al componente.

Antipatrones recurrentes

Checklist

Con los datos entrando de forma tipada, cacheada y eficiente, el próximo frente es que esos datos —y el token que los pide— no se conviertan en un agujero de seguridad. Seguimos con Seguridad →: XSS y sanitización, Trusted Types, XSRF y por qué no hay secretos en el bundle.