Tutorial de la API de geocodificación con Node.js: concurrencia, reintentos y streaming de CSV

Tutorial Node.js de geocodificación listo para producción: concurrencia acotada con p-limit, reintentos en 429 y pipeline de CSV en streaming.

| April 26, 2026
Tutorial de la API de geocodificación con Node.js: concurrencia, reintentos y streaming de CSV

Este es un tutorial funcional de Node.js para geocodificar direcciones a través de una API REST. El código de este post fue escrito, copiado a archivos y ejecutado antes de publicar. Cada snippet corre sobre una instalación limpia de Node 22 con dos dependencias (p-limit, csv-parse). Sin frameworks, sin SDK, sin magia.

Con lo que vas a terminar al final del post: un script que lee un CSV de direcciones, las geocodifica en paralelo con un límite de tasa configurable, reintenta en 429 con backoff exponencial y escribe un CSV de {lat, lng, error} sin cargar el archivo de entrada en memoria. Unas 80 líneas de código.

El endpoint que se usa a lo largo del post es csv2geo.com/api/v1. El tier gratis es de 1 000 requests al día, sin tarjeta de crédito. Inicia sesión y agarra una key en /api-keys si quieres seguir el tutorial; los endpoints de demostración usados en los primeros ejemplos no requieren key.

El endpoint

Dos endpoints cubren el 95% del trabajo real de geocodificación. Forward convierte una cadena de dirección en coordenadas. Reverse convierte coordenadas en una dirección. Ambos aceptan GET (individual) o POST (por lotes).

# Forward (single)
GET https://csv2geo.com/api/v1/geocode?q=ADDRESS&country=US

# Reverse (single)
GET https://csv2geo.com/api/v1/reverse?lat=LAT&lng=LNG

# Batch forward
POST https://csv2geo.com/api/v1/geocode
Body: { "addresses": ["addr1", "addr2", ...] }

# Auth: either ?api_key=KEY query string, or
# Authorization: Bearer KEY header

La forma de la respuesta para un request forward individual se ve así. Esto es salida real, no un ejemplo de la documentación.

{
  "query": "1600 Pennsylvania Avenue NW Washington DC",
  "results": [
    {
      "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500-0005, United States",
      "location": { "lat": 38.89768, "lng": -77.03655 },
      "accuracy": "houseNumber",
      "accuracy_score": 1,
      "components": {
        "house_number": "1600",
        "street": "Pennsylvania Ave NW",
        "city": "Washington",
        "state": "District of Columbia",
        "postal_code": "20500-0005",
        "country": "USA"
      }
    }
  ],
  "meta": { "response_time_ms": 673, "source": "here" }
}

Dos campos importan más que el resto cuando estás escribiendo el código del cliente. results[0].location es tu {lat, lng}. results[0].accuracy es el nivel de match — "houseNumber" es a nivel de techo, "street" es centroide de calle, "place" es match de POI, "postcode" es centroide de código postal. Úsalo para descartar resultados de baja confianza antes de que contaminen tu dataset.

Primer request

Node 18+ trae fetch incluido. No hace falta npm install para la primera llamada.

// geocode.mjs
const API_KEY = process.env.CSV2GEO_KEY;

async function geocode(address, country = 'US') {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', address);
  url.searchParams.set('country', country);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const data = await res.json();
  return data.results[0]?.location ?? null;
}

console.log(await geocode('1 Apple Park Way, Cupertino, CA'));
// -> { lat: 37.33177, lng: -122.03042 }

Córrelo: CSV2GEO_KEY=geo_live_xxx node geocode.mjs. La forma del header Authorization es la recomendada — así las keys nunca terminan en el historial de tu shell ni en tus logs de acceso.

Geocodificación reversa

Misma forma, distintos parámetros. Pasas lat y lng, recibes una dirección.

async function reverse(lat, lng) {
  const url = new URL('https://csv2geo.com/api/v1/reverse');
  url.searchParams.set('lat', String(lat));
  url.searchParams.set('lng', String(lng));

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.results[0] ?? null;
}

const r = await reverse(48.8584, 2.2945); // Eiffel Tower
console.log(r.formatted_address, r.distance_meters);

Las respuestas reversas incluyen un campo distance_meters — qué tan lejos está la dirección que hizo match de la coordenada de entrada. Si estás haciendo geocodificación reversa de pings de GPS de una app de delivery, cualquier valor por encima de ~50 m suele significar que la lectura del GPS estaba mala, no el geocodificador.

Hacerlo en paralelo sin que se derrita nada

La forma incorrecta de geocodificar 10 000 direcciones en Node es Promise.all sobre un map de fetches. Eso dispara 10 000 conexiones concurrentes contra la API, choca con el rate limit en el segundo lote y llena tu event loop de promesas rechazadas. No lo hagas.

La forma correcta es concurrencia acotada. La herramienta más limpia para el trabajo es p-limit — unas 50 líneas de código fuente, sin dependencias transitivas.

npm install p-limit
import pLimit from 'p-limit';

const limit = pLimit(8); // tune to your plan's per-minute rate limit

async function geocode(address) {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', address);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.results[0]?.location ?? null;
}

const addresses = [/* ...10,000 strings... */];
const results = await Promise.all(
  addresses.map(a => limit(() => geocode(a)))
);

Calibrar la concurrencia es empírico. El plan gratis tope a 100 requests por minuto, así que una concurrencia de 4 es segura. El plan Starter es 1 000/min — un rango bueno está entre 16 y 24. El plan Pro es 10 000/min — arranca en 64 y mira el header X-RateLimit-Remaining.

Reintentos, backoff y headers de rate limit

Tres cosas en un geocodificador de producción real: (1) tratar 429 y 5xx como reintentables, (2) respetar el header Retry-After cuando viene presente, (3) limitar el número de intentos para que una key permanentemente muerta no entre en bucle infinito.

async function geocodeWithRetry(address, { maxAttempts = 4 } = {}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const url = new URL('https://csv2geo.com/api/v1/geocode');
    url.searchParams.set('q', address);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
    });

    if (res.ok) return res.json();

    // 4xx other than 429 -> non-retriable (bad key, bad input, etc.)
    if (res.status !== 429 && res.status < 500) {
      throw new Error(`HTTP ${res.status}: ${await res.text()}`);
    }

    // 429 / 5xx -> back off and try again
    const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
    await sleep(retryAfter * 1000);
  }
  throw new Error(`Gave up after ${maxAttempts} attempts`);
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

La respuesta también expone tres headers que vale la pena revisar en cada llamada exitosa:

  • X-RateLimit-Limit — el techo por minuto de tu plan.
  • X-RateLimit-Remaining — lo que te queda en esta ventana.
  • X-RateLimit-Reset — segundos Unix hasta que se reinicia la ventana.

Si Remaining cae por debajo del 10% del Limit, baja el ritmo voluntariamente. Sale más barato que machacar contra 429s, y muchísimo más barato que un pico repentino que tira todo el pipeline.

El endpoint por lotes

Los tiers de suscripción desde Starter hacia arriba exponen un endpoint por lotes que recibe un arreglo de direcciones en un solo POST. Hasta 10 000 por request en Pro. Un round-trip en lugar de N. El tier gratis no permite batch — tienes que iterar individuales.

async function batchGeocode(addresses) {
  const res = await fetch('https://csv2geo.com/api/v1/geocode', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.CSV2GEO_KEY}`,
    },
    body: JSON.stringify({ addresses }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

const out = await batchGeocode([
  '1600 Pennsylvania Ave NW, Washington, DC',
  '1 Apple Park Way, Cupertino, CA',
  '233 S Wacker Dr, Chicago, IL',
]);
// out.results is an array aligned 1:1 with the input order

Las respuestas por lotes preservan el orden de entrada. La posición N de la respuesta siempre corresponde a la posición N de la entrada — no hace falta ir y volver con un campo id, aunque puedes hacerlo.

Stream de un CSV sin OOM

Si tu archivo de entrada tiene 100 000 filas, no quieres hacerle fs.readFileSync, parsearlo a un arreglo gigante y después correr map() sobre el resultado. Te vas a quedar sin memoria o vas a quemar tu rate limit. Hazlo en stream.

npm install p-limit csv-parse csv-stringify
// stream-geocode.mjs
import { createReadStream, createWriteStream } from 'node:fs';
import { parse } from 'csv-parse';
import { stringify } from 'csv-stringify';
import pLimit from 'p-limit';

const KEY = process.env.CSV2GEO_KEY;
const limit = pLimit(8);

async function geocode(q) {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', q);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  if (!res.ok) return { lat: '', lng: '', error: `http_${res.status}` };
  const data = await res.json();
  const r = data.results?.[0];
  if (!r) return { lat: '', lng: '', error: 'no_match' };
  if (r.accuracy_score < 0.7) return { lat: '', lng: '', error: 'low_confidence' };
  return { lat: r.location.lat, lng: r.location.lng, error: '' };
}

const out = createWriteStream('out.csv');
const stringer = stringify({ header: true, columns: ['id', 'address', 'lat', 'lng', 'error'] });
stringer.pipe(out);

const tasks = [];
const parser = createReadStream('in.csv').pipe(parse({ columns: true }));
for await (const row of parser) {
  tasks.push(limit(async () => {
    const g = await geocode(row.address);
    stringer.write({ ...row, ...g });
  }));
}
await Promise.all(tasks);
stringer.end();
await new Promise(r => out.on('finish', r));
console.log('done -> out.csv');

Tres cosas que hace este script y que el código de principiantes normalmente no hace:

  • Lee la entrada como stream (sin cargar el archivo completo) y escribe la salida como stream (sin bufferear todo y vaciar al final).
  • Limita el trabajo de geocodificación en vuelo a 8 requests concurrentes vía p-limit.
  • Registra los errores en una columna en vez de reventar al primer fallo. Esa es la diferencia entre un script que termina y un script que hay que volver a arrancar con la pregunta de "¿dónde se quedó?".

Un wrapper mínimo en TypeScript

Si tu proyecto está en TypeScript, tipa la forma de la respuesta una vez y reúsala en todos lados. Los tipos de abajo son lo bastante estrechos para ser útiles y lo bastante laxos para que futuras adiciones de la API no rompan el build.

// types.ts
export type Accuracy = 'houseNumber' | 'street' | 'place' | 'postcode';

export interface GeocodeResult {
  formatted_address: string;
  location: { lat: number; lng: number };
  accuracy: Accuracy;
  accuracy_score: number;
  components: {
    house_number?: string;
    street?: string;
    city?: string;
    state?: string;
    postal_code?: string;
    country?: string;
  };
}

export interface GeocodeResponse {
  query: string;
  results: GeocodeResult[];
  meta: { response_time_ms: number; source: string };
}

// client.ts
import type { GeocodeResponse, GeocodeResult } from './types';

export async function geocode(
  q: string,
  country = 'US',
): Promise<GeocodeResult | null> {
  const url = new URL('https://csv2geo.com/api/v1/geocode');
  url.searchParams.set('q', q);
  url.searchParams.set('country', country);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CSV2GEO_KEY}` },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = (await res.json()) as GeocodeResponse;
  return data.results[0] ?? null;
}

Dos decisiones de diseño que vale la pena resaltar. Primera, la función devuelve null cuando no hay match en lugar de lanzar excepción — un resultado vacío no es un error, es solo una respuesta. Segunda, accuracy está tipado como una unión, así que un switch sin rama default es un error de compilación si la API alguna vez agrega un nuevo valor. Ese tipo de código tipado es el que sobrevive las versiones menores de la API.

Lo que hace tropezar a la gente

El orden no se preserva cuando paralelizas individuales. Promise.all devuelve en orden de entrada, pero la red termina con el request que regrese primero. Si haces stream-write de los resultados en orden de finalización de red, las filas de salida te van a quedar barajadas. O bien haces stream-write con id como llave (el ejemplo de arriba hace esto — cada fila lleva su id original), o bien buffereas hasta el final.

fetch no lanza excepción en respuestas no-2xx. Un 404 o 429 es una promesa resuelta, no un rechazo. Siempre revisa res.ok antes de parsear el body. La cantidad de tickets de "¿por qué mis resultados son undefined?" que se rastrean hasta acá es enorme.

Los códigos de país importan. El parámetro country es una pista, no un filtro. Si pasas country=US para una dirección en Toronto, vas a recibir un resultado equivocado pero plausible en algún lugar del norte del estado de Nueva York. Define country por fila cuando tu entrada esté mezclada.

Las cadenas vacías son entrada válida que devuelve basura. Valida row.address antes de mandarlo. Dos minutos de saneamiento de entrada te ahorran una hora de investigación de "¿por qué la lat es 0,0?".

Preguntas frecuentes

¿Necesito un SDK?

No. La API es REST + JSON, y Node trae fetch incluido. El "SDK" que la mayoría de equipos termina escribiendo es la función geocodeWithRetry de arriba más un wrapper para el endpoint de batch. Unas 30 líneas.

¿Esto funciona con TypeScript?

Sí. Tipa la forma de la respuesta a partir del JSON de ejemplo de arriba. Los tipos mínimos útiles son { results: Array<{ location: { lat: number; lng: number }; accuracy: string; accuracy_score: number; components: Record<string, string> }> }. Si quieres algo más estricto, acota accuracy a una unión de strings "houseNumber" | "street" | "place" | "postcode".

¿Puedo usar esto desde un browser en vez de Node?

Puedes, pero no deberías meter una API key de larga duración en código del browser — cualquiera puede leerla. O proxeas a través de tu propio backend (el mismo código de este post, solo que dentro de una ruta de Express o Fastify), o emites keys de corta duración con scope para el browser.

¿Qué rate limit debo usar para concurrencia?

Toma el rate limit por minuto de tu plan, divídelo entre 60 y apunta a más o menos esa cantidad de requests concurrentes. 1 000/min ÷ 60 ≈ 16. Redondea hacia abajo. El header X-RateLimit-Remaining es la fuente de verdad — ajusta si lo ves bajando rápido.

¿Cómo detecto un no-match versus un match exitoso de baja confianza?

data.results es un arreglo vacío cuando no hay match. En un match de baja confianza, results[0] existe pero accuracy es "postcode" o "place" en lugar de "houseNumber" o "street", y accuracy_score está bastante por debajo de 1. Decide un umbral (0.7 es un default razonable) y trata cualquier cosa por debajo como no-match.

¿Cómo geocodifico direcciones fuera de EE. UU.?

Pasa el código de país correcto en el parámetro country — ISO alpha-2, así que DE para Alemania, GB para Reino Unido, BR para Brasil. La cobertura abarca 39 países hoy en día, incluyendo el top 10 completo por cantidad de direcciones. La página de la API lista los conteos por país.

¿Debo usar requests individuales o el endpoint por lotes?

Batch cuando tengas ≥100 direcciones para hacer de una sola vez y tu plan lo permita. Un solo POST sale más barato para ambos lados que 100 GETs, y el ahorro de latencia es aproximadamente tu tiempo promedio por request multiplicado por la cantidad de requests dividido entre la concurrencia. Los individuales son más simples para cargas en streaming donde las direcciones llegan a lo largo del tiempo.

Hacia dónde seguir

La referencia completa de la API está en csv2geo.com/api. Si prefieres trabajar en Python, el tutorial de Python sigue la misma estructura con requests y asyncio. Para cargas de trabajo donde quieres subir un archivo en lugar de escribir código cliente, el geocodificador por lotes es el mismo backend detrás de una UI web.

Si encuentras que los headers X-RateLimit subcuentan en algún caso límite, o si te topas con una forma de respuesta que este post no cubre, el formulario de contacto del sitio llega a una persona que lo lee. Los reportes de bugs con reproducciones en curl se arreglan rápido.

I.A. / Creador de CSV2GEO

Artículos relacionados

Cómo geocodificar direcciones en Python

Cómo convertir una dirección a lat long

Precios de APIs de geocodificación — costo real en 2026

API de geocodificación reversa

Geocodificación por lotes

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →