Construyendo una app de radios LATAM (2): cómo me comunico con el backend sin volverme loco

Publicado el 25/05/2026

El problema

Una app de radios necesita un catálogo. El estándar de facto del mundo libre para esto es Radio Browser: una base de datos comunitaria con decenas de miles de emisoras y una API REST pública. Suena ideal.

Hasta que la enchufas.

Lo que me encontré al primer intento

  • Los servidores son varios y rotativos. Tienes que hacer una resolución DNS especial (SRV records) para descubrir un mirror disponible, y cualquier mirror puede caer en cualquier momento.
  • Los datos son... comunitarios. Encuentras emisoras con url en http:// plano, country_code en minúsculas, otras veces en mayúsculas, otras vacío, bitrate como string en vez de número, tags repetidos, uuid en formatos raros.
  • Sin paginación uniforme. Algunos endpoints aceptan limit/offset, otros devuelven todo de golpe.
  • Sin CORS amistoso para web. Si quiero servir la app como PWA, el navegador me bloquea peticiones cross-origin a la mayoría de mirrors.
  • Sin contrato. Es un servicio gratis mantenido por voluntarios. No me puedo permitir que un cambio sutil tumbe mi app.

Conclusión rápida: si dependo directamente de Radio Browser desde el cliente, dependo del peor día de su infraestructura.

La decisión: un proxy propio

Monté un pequeño backend en otro repo (latam_radios_back) que actúa como proxy curado entre Radio Browser y mi app. Vive en https://latamradio.rexvalentia.es/api/v1 y la app lo consume sin saber lo que hay detrás.

Lo que el backend hace por mí:

  1. Filtra a Latam. Me ahorra mandar al cliente 30.000 emisoras de Europa que no me interesan.
  2. Cachea respuestas. Si Radio Browser está caído cinco minutos, mis usuarios no se enteran.
  3. Normaliza datos básicos. Estandariza algunos campos antes de devolverlos.
  4. Expone solo lo que necesito. /stations/, /stations/by-uuid/, /countries/, /tags/. Ni un endpoint más.
  5. Tiene CORS abierto para que el cliente web funcione sin trampas.

Esto no es "sobreingeniería para una app personal". Es comprar fiabilidad con un servidor de 5 €/mes. Si Radio Browser un día cambia su esquema, yo absorbo el cambio en el backend y la app no se entera.

El cliente HTTP: pequeño pero firme

En el cliente no quería ni Axios ni RTK Query. Para esta app, fetch con dos helpers basta. La complejidad real no está en las librerías, está en gestionar timeouts, abortos y errores tipados.

Así quedó services/api.ts:

const DEFAULT_API_URL = 'https://latamradio.rexvalentia.es/api/v1';
const DEFAULT_TIMEOUT_MS = 12_000;

function resolveBaseUrl(): string {
  const fromEnv =
    process.env.EXPO_PUBLIC_API_URL ??
    (Constants.expoConfig?.extra as { apiUrl?: string } | undefined)?.apiUrl;
  const raw = (fromEnv ?? DEFAULT_API_URL).replace(/\/+$/, '');
  if (!/^https?:\/\//i.test(raw)) {
    throw new Error('EXPO_PUBLIC_API_URL debe ser una URL absoluta http(s).');
  }
  return raw;
}

export const BASE_URL = resolveBaseUrl();

Tres decisiones explícitas:

  • Origen configurable en tres niveles: EXPO_PUBLIC_API_URL > app.json > fallback. Esto es lo que me deja apuntar a localhost:8000 en desarrollo, a staging en builds de preview, y a producción en builds de release, sin tocar código.
  • Validación temprana. Si la URL no es absoluta http(s), lanzo en arranque. Antes prefería logs silenciosos; ahora prefiero fallar ruidosamente.
  • Trailing slash normalizado. Pequeño detalle, evita bugs odiosos del tipo /v1//stations/.

El request interno

La parte que de verdad pelea con la realidad:

async function request<T>(method, endpoint, options) {
  const { params, body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  const onUserAbort = () => controller.abort();
  signal?.addEventListener('abort', onUserAbort);

  try {
    const response = await fetch(buildUrl(endpoint, params), {
      method,
      headers: { Accept: 'application/json', ... },
      body: body ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });

    if (!response.ok) throw new ApiError(`API ${method} ${endpoint} → ${response.status}`, response.status);
    if (!response.headers.get('content-type')?.includes('application/json')) {
      throw new ApiError('Respuesta no es JSON', response.status);
    }
    return (await response.json()) as T;
  } finally {
    clearTimeout(timeout);
    signal?.removeEventListener('abort', onUserAbort);
  }
}

Lo que aprendí escribiendo esto:

  • Hay que combinar dos AbortControllers. Uno para el timeout interno, otro para el signal que el llamante pueda pasarme (porque desmontó un componente, por ejemplo). Si solo respetas uno, fugas peticiones o peor: no respetas la intención del usuario.
  • Limpiar el listener en finally no es opcional. Si no lo haces, cada petición fuga un listener en el AbortSignal del llamante.
  • Comprobar content-type. Más de una vez Radio Browser (o su proxy) ha devuelto un HTML de error 200. Si haces .json() ciegamente, peta con un mensaje incomprensible. Mejor lanzar un ApiError claro.
  • Tipar el error. ApiError con status permite a las capas superiores decidir si reintentar, si mostrar "sin red" o si mostrar "error del servidor".

Normalizar antes de tocar UI

Aunque mi backend ya normaliza, el cliente no se fía. Esta paranoia me ha salvado el cuello más de una vez. En services/stationService.ts:

function normalizeStation(raw: Raw): Station | null {
  const rawUuid = pickFirst<string>(raw.uuid, '').toLowerCase();
  if (!UUID_RE.test(rawUuid)) return null;
  const url = sanitizeUrl(raw.url);
  const url_resolved = sanitizeUrl(raw.url_resolved) || url;
  return {
    uuid: rawUuid,
    name: pickFirst<string>(raw.name, 'Sin nombre'),
    country_code: pickFirst<string>(raw.country_code, '').toUpperCase(),
    ...
    bitrate: toInt(raw.bitrate),
    favicon: sanitizeUrl(raw.favicon) || null,
    ...
  };
}

Reglas que me he autoimpuesto:

  • Si no tiene UUID válido, no existe. Filtrarlo en cliente cuesta nada y evita explotar más adelante en el reproductor o en favoritos.
  • country_code siempre en mayúsculas. Lo uso de clave en agrupaciones y en el cálculo de la bandera (String.fromCodePoint(127397 + code.charCodeAt(0))). Una inconsistencia mayúsculas/minúsculas rompe ambas cosas.
  • url solo si es http(s)://. Cualquier otra cosa (rtmp://, vacío, javascript:) la tiro. Esto me protege de XSS si en algún momento renderizo la URL.
  • bitrate como número o nada. No quiero hacer bitrate.toString() en UI si la API a veces devuelve string. Conviertes en el borde, no en la vista.

Filtrar y normalizar en la capa de servicio, no en el componente. La vista debe poder asumir que el Station que recibe está limpio.

Paginación: ofset + ofset = dolor

La API expone limit/offset clásico. El reto: combinar paginación con filtros que cambian (país, género, búsqueda por nombre) y con cancelación.

export async function searchStations(query: StationsQuery): Promise<PaginatedStations> {
  const page = Math.max(1, query.page ?? 1);
  const limit = query.limit ?? DEFAULT_LIMIT;
  const offset = (page - 1) * limit;
  const raw = await get<{ results: Raw[]; count: number }>(
    'stations/',
    { country_code: query.countryCode, name: query.name, tag: query.tag, limit, offset },
    { signal: query.signal },
  );
  return buildPaginated(raw, page, limit);
}

Tres errores que cometí antes de llegar aquí:

  1. Olvidé el Math.max(1, ...). Un día puse page: 0 por accidente y el offset salió negativo. La API devolvía error. Aprendí a no fiarme ni de mí mismo.
  2. Pasaba null/undefined/"" como query params. Quedaba ?name=&tag= en la URL y la API devolvía cero resultados porque interpretaba "nombre vacío exacto". Ahora buildUrl los descarta:

ts for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null || value === '') continue; url.searchParams.set(key, String(value)); } 3. No abortaba. Si el usuario tecleaba en el buscador y disparaba 5 búsquedas, la respuesta más lenta (la del prefijo más corto) llegaba última y pisaba la buena. Solución: AbortController por request y abortar en cleanup del efecto.

Endpoints bulk: el caso favoritos

Los favoritos los guardo localmente como lista de UUIDs, no como copia completa de las emisoras. ¿Por qué? Porque las URLs cambian y los datos se actualizan. Si guardo el objeto entero, mis favoritos envejecen mal.

Pero al cargar la pantalla de favoritos necesito 50 emisoras de golpe. Si pidiera una a una serían 50 round-trips. Hay endpoint bulk:

export async function getStationsByUUIDs(uuids, signal): Promise<Station[]> {
  const cleaned = Array.from(new Set(uuids.map(u => u.toLowerCase()).filter(u => UUID_RE.test(u))));
  if (cleaned.length === 0) return [];

  const out: Station[] = [];
  for (let i = 0; i < cleaned.length; i += BY_UUID_CHUNK) {
    const chunk = cleaned.slice(i, i + BY_UUID_CHUNK);
    const raw = await post<unknown>('stations/by-uuid/', { uuids: chunk }, { signal });
    out.push(...unwrapList(raw).map(normalizeStation).filter(Boolean));
  }
  return out;
}

Lo importante:

  • Chunks de 100. Si tienes 500 favoritos, mandar todos los UUIDs en un POST puede pasar de límite. Trocear.
  • Dedup en cliente. El usuario puede haber metido el mismo UUID dos veces por bugs míos. No me fío.
  • Si quedan 0 UUIDs válidos, ni siquiera llamo a la API.

Lo que cambiaría

  • Reintentos con backoff para errores 5xx transitorios. Hoy no los hago; en producción real lo añadiría.
  • Caché in-memory por sesión. Si el usuario va a "Argentina", vuelve atrás y entra otra vez, hoy reconsulto. Una pequeña caché de TTL 1 minuto sería un win obvio.
  • React Query / TanStack Query. Lo descarté por simplicidad. Para v2, si la cosa crece, lo metería sin dudarlo: gestiona caché, refetch, dedup y estados de carga mejor que cualquier cosa que yo escriba a mano.

La conclusión que me llevo

Las APIs públicas se rompen, y los datos sucios se cuelan siempre. Las dos defensas reales son un proxy en el medio que te dé control y una capa de normalización en el cliente que filtre lo inválido. Si confías a ciegas en lo uno o en lo otro, antes o después te explota.

En el próximo capítulo cuento por qué tiré a la basura todos mis StyleSheet.create y migré a NativeWind (Tailwind para React Native), y qué problemas resolvió que no esperaba.