HTTP, datos y complejidad en el cliente
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
fetches el predeterminado, así que en Angular 22withFetch()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 aXMLHttpRequest(por progreso de descarga granular o compatibilidad con algún interceptor viejo), existewithXhr().
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:
authva primero para que el header ya esté puesto cuandoretryreintente (si no, el reintento sale sin token).errorva último en la lista para envolver a todos los demás: captura tanto los errores del backend como los que deja pasarretrytras agotar los intentos.- Un interceptor de caching (siguiente sección) suele ir antes que
authyretry: si respondés desde cache, cortocircuitás la cadena y ni siquiera generás el token ni contás un reintento.
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>
}
.value()— el dato parseado.hasValue()— guard para leer.value()sin romper en estado de error.isLoading()— booleano de carga.error()— el error si falló.status()— el estado del recurso
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:
- Paginación / filtrado en el servidor: mandá
page,pageSize,sortyfiltercomo query params y que el backend haga el trabajo pesado. El cliente recibe solo lo que muestra. - Virtualización: cuando sí tenés que mostrar una lista larga,
<cdk-virtual-scroll-viewport>del Angular CDK renderiza solo las filas visibles en el viewport, no las 10.000. El DOM se mantiene liviano sin importar el tamaño del dataset.
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
- HTTP en el componente:
this.http.get()dentro de un componente acopla la vista al transporte, imposibilita cachear/deduplicar y hace el componente intesteable sin mockear la red. La regla: el componente consume un service o unhttpResource; el service habla HTTP. - No cancelar requests obsoletos: en una búsqueda que dispara un request por tecla, si el del texto “a” responde después del de “ab”, pisás el resultado más nuevo con uno viejo (race condition).
httpResourcecancela solo; con RxJS, usáswitchMap(cancela el anterior) en vez demergeMap. - Procesar datasets enormes en el hilo principal: un
sorto un cruceO(n²)sobre decenas de miles de filas congela el frame. Indexá conMap/Set, memoizá concomputed, virtualizá el render o movelo a un Web Worker. - Over-fetching: traer campos o filas que no mostrás. Pedí solo lo que la vista necesita; empujá filtrado, orden y paginación al servidor.
shareReplaysinrefCount: cache que nunca se libera y retiene referencias → memory leak silencioso.retryindiscriminado: reintentar un4xxo una mutación no idempotente. Reintentá soloGETy solo errores transitorios.- Confiar en el genérico como validación:
get<T>no valida nada en runtime. Para datos de límites que no controlás, validá con un esquema.
Checklist
-
HttpClientprovisto conprovideHttpClient()(fetch por defecto en v22;withFetch()explícito si migrás). - Todas las llamadas HTTP viven en services o
httpResource, nunca en componentes. - Respuestas de límites no confiables validadas en runtime con un esquema (
parseenhttpResourceomapen HttpClient), no solo con el genérico. - Interceptores funcionales (
HttpInterceptorFn) para auth, logging, reintentos y errores; orden verificado (authantes deretry,errorenvolviendo todo). - Reintentos limitados a
GETy a errores transitorios (status 0o5xx), con backoff. - Cache y deduplicación donde haya requests repetidos;
shareReplaysiempre conrefCount: true. -
httpResource/rxResourcepara lecturas reactivas;HttpClientimperativo para mutaciones; TanStack Query reservado para casos complejos de estado servidor. - Requests obsoletos cancelados (
httpResourcenativo oswitchMap) para evitar race conditions. - Cruces de listas indexados con
Map/Set(O(1)de lookup), sinfind/includesanidados (O(n²)). - Derivaciones costosas memoizadas con
computed, no en getters de template. - Filtrado, orden y paginación en el servidor cuando el dataset es grande; virtualización con el CDK o Web Worker para lo que sí procesás en el cliente.
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.