Tutorial Node.js de API de geocodificação: concorrência, retries e streaming de CSV

Tutorial Node.js de geocodificação pronto para produção: concorrência limitada com p-limit, retry/backoff em 429s e pipeline de CSV em streaming.

| April 26, 2026
Tutorial Node.js de API de geocodificação: concorrência, retries e streaming de CSV

Este é um tutorial Node.js funcional para geocodificar endereços através de uma API REST. O código deste post foi escrito, copiado para arquivos e executado antes da publicação. Cada trecho roda em uma instalação limpa do Node 22 com duas dependências (p-limit, csv-parse). Sem frameworks, sem SDK, sem mágica.

O que você terá no fim do post: um script que lê um CSV de endereços, geocodifica-os concorrentemente com um limite de taxa configurável, faz retry em 429s com backoff exponencial e grava um CSV de {lat, lng, error} sem carregar o arquivo de entrada na memória. Cerca de 80 linhas de código.

O endpoint usado ao longo do post é csv2geo.com/api/v1. O tier gratuito permite 1.000 requisições por dia, sem cartão de crédito. Faça login e pegue uma chave em /api-keys se quiser acompanhar; os endpoints de demonstração usados nos primeiros exemplos não exigem chave.

O endpoint

Dois endpoints cobrem 95% do trabalho real de geocodificação. Forward transforma uma string de endereço em coordenadas. Reverse transforma coordenadas em um endereço. Ambos aceitam GET (único) ou POST (lote).

# 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

O formato da resposta para uma única requisição forward é assim. Esta é uma saída real, não um exemplo de documentação.

{
  "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" }
}

Dois campos importam mais quando você está escrevendo código cliente. results[0].location é o seu {lat, lng}. results[0].accuracy é o nível de match — "houseNumber" é nível de telhado, "street" é centroide de rua, "place" é match de POI, "postcode" é centroide de CEP. Use-o para descartar resultados de baixa confiança antes que poluam seu dataset.

Primeira requisição

O Node 18+ tem fetch nativo. Nenhum npm install é necessário para a primeira chamada.

// 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 }

Execute: CSV2GEO_KEY=geo_live_xxx node geocode.mjs. A forma com header Authorization é a recomendada — as chaves nunca aparecem no histórico do shell ou nos logs de acesso.

Geocodificação reversa

Mesmo formato, parâmetros diferentes. Passe lat e lng e receba de volta um endereço.

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);

Respostas reverse incluem um campo distance_meters — a distância entre o endereço encontrado e a coordenada de entrada. Se você está geocodificando reverso pings de GPS de um app de entrega, qualquer coisa acima de ~50m geralmente significa que o GPS errou, não o geocodificador.

Fazendo em paralelo sem derreter nada

A forma errada de geocodificar 10.000 endereços em Node é Promise.all sobre um map de fetches. Isso dispara 10.000 conexões concorrentes contra a API, atinge o rate limit no segundo lote e enche seu event loop com promises rejeitadas. Não faça isso.

A forma certa é concorrência limitada. A ferramenta mais limpa para o trabalho é a p-limit — cerca de 50 linhas de código-fonte, sem dependências 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)))
);

Ajustar a concorrência é empírico. O plano gratuito tem teto de 100 requisições por minuto, então uma concorrência de 4 é segura. O plano Starter é 1.000/min — 16 a 24 é uma faixa boa. O plano Pro é 10.000/min — comece em 64 e fique de olho no header X-RateLimit-Remaining.

Retry, backoff e headers de rate limit

Três coisas em um geocodificador de produção real: (1) tratar 429 e 5xx como retryable, (2) respeitar o header Retry-After quando presente, (3) limitar o número de tentativas para que uma chave permanentemente morta não fique em loop eterno.

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));

A resposta também expõe três headers que vale verificar em toda chamada bem-sucedida:

  • X-RateLimit-Limit — o teto por minuto do seu plano.
  • X-RateLimit-Remaining — o que sobra nesta janela.
  • X-RateLimit-Reset — segundos Unix até a janela resetar.

Se Remaining cair abaixo de 10% de Limit, desacelere voluntariamente. Mais barato do que ficar batendo em 429s e muito mais barato do que uma rajada repentina que tira o pipeline inteiro do ar.

O endpoint de lote

Tiers de assinatura a partir do Starter expõem um endpoint de lote que aceita um array de endereços em um único POST. Até 10.000 por requisição no Pro. Uma viagem de ida e volta em vez de N. O tier gratuito não permite lote — você tem que iterar requisições únicas.

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

Respostas em lote preservam a ordem de entrada. A posição N da resposta sempre corresponde à posição N da entrada — não é preciso enviar e receber um campo id, embora você possa.

Streaming de um CSV sem estourar a memória

Se seu arquivo de entrada tem 100.000 linhas, você não vai querer fazer fs.readFileSync, parsear num array gigante e dar map() no resultado. Você vai ficar sem memória ou queimar seu rate limit. Faça 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');

Três coisas que esse script faz e que código de iniciante geralmente não faz:

  • Lê a entrada como stream (sem carregar o arquivo todo) e grava a saída como stream (sem bufferizar tudo e dar flush no fim).
  • Limita o trabalho de geocodificação em voo a 8 requisições concorrentes via p-limit.
  • Registra erros em uma coluna em vez de quebrar na primeira falha. Essa é a diferença entre um script que termina e um script que precisa ser reiniciado com a pergunta "onde foi que ele parou".

Um wrapper mínimo em TypeScript

Se seu projeto é TypeScript, tipe o formato da resposta uma vez e reutilize em todo lugar. Os tipos abaixo são estreitos o suficiente para serem úteis e largos o suficiente para que adições futuras na API não quebrem o 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;
}

Duas decisões de design que vale destacar. Primeiro, a função retorna null quando não há match em vez de lançar exceção — resultado vazio não é erro, é só uma resposta. Segundo, accuracy é tipado como union, então um switch sobre ele sem branch default vira erro de compilação se a API algum dia adicionar um valor novo. É esse tipo de código tipado que continua sobrevivendo a versões menores da API.

Coisas que mordem

A ordem não é preservada quando você paraleliza requisições únicas. Promise.all retorna na ordem de entrada, mas a rede termina primeiro a requisição que voltar primeiro. Se você grava em stream na ordem de finalização da rede, suas linhas de saída ficarão embaralhadas. Ou grave em stream chaveando por id (o exemplo acima faz isso — cada linha carrega seu id original), ou bufferize até o fim.

fetch não lança exceção em respostas não-2xx. Um 404 ou 429 é uma promise resolvida, não uma rejeição. Sempre verifique res.ok antes de parsear o body. O número de tickets "por que meus resultados estão undefined" que rastreiam até aqui é grande.

Códigos de país importam. O parâmetro country é uma dica, não um filtro. Se você passar country=US para um endereço em Toronto, vai receber de volta um resultado errado mas plausível em algum lugar do interior de Nova York. Defina country por linha quando sua entrada é mista.

Strings vazias são entradas válidas que retornam lixo. Valide row.address antes de enviar. Dois minutos de saneamento de entrada economizam uma hora de investigação "por que a lat está em 0,0?".

Perguntas frequentes

Preciso de um SDK?

Não. A API é REST + JSON, e o Node tem fetch nativo. O "SDK" que a maioria dos times acaba escrevendo é a função geocodeWithRetry acima mais um wrapper para o endpoint de lote. Cerca de 30 linhas.

Funciona com TypeScript?

Sim. Tipe o formato da resposta a partir do exemplo JSON acima. Os tipos mínimos úteis são { results: Array<{ location: { lat: number; lng: number }; accuracy: string; accuracy_score: number; components: Record<string, string> }> }. Se quiser mais estrito, restrinja accuracy a uma string union de "houseNumber" | "street" | "place" | "postcode".

Posso usar isso a partir de um navegador em vez do Node?

Pode, mas você não deveria colocar uma chave de API de longa duração em código de navegador — qualquer um pode ler. Ou faça proxy pelo seu próprio backend (o mesmo código deste post, só que dentro de uma rota Express ou Fastify), ou emita chaves de curta duração com escopo de navegador.

Que rate limit devo usar para concorrência?

Pegue o rate limit por minuto do seu plano, divida por 60 e mire mais ou menos esse número de requisições concorrentes. 1.000/min ÷ 60 ≈ 16. Arredonde para baixo. O header X-RateLimit-Remaining é a fonte da verdade — ajuste se vir caindo rápido.

Como detectar um no-match versus um match bem-sucedido de baixa confiança?

data.results é um array vazio em no-match. Em um match de baixa confiança, results[0] existe mas accuracy é "postcode" ou "place" em vez de "houseNumber" ou "street", e accuracy_score está bem abaixo de 1. Decida um limite (0,7 é um default razoável) e trate qualquer coisa abaixo como no-match.

Como geocodificar endereços fora dos EUA?

Passe o código de país certo no parâmetro country — ISO alpha-2, então DE para Alemanha, GB para Reino Unido, BR para Brasil. A cobertura abrange 39 países hoje, incluindo o top 10 completo por contagem de endereços. A página da API lista as contagens por país.

Devo usar requisições únicas ou o endpoint de lote?

Lote quando você tem ≥100 endereços para processar de uma vez e seu plano permite. Um POST é mais barato para os dois lados do que 100 GETs, e o ganho de latência é mais ou menos o seu tempo médio por requisição vezes a contagem de requisições dividida pela concorrência. Únicas são mais simples para cargas em streaming, onde os endereços chegam ao longo do tempo.

Para onde ir a partir daqui

A referência completa da API está em csv2geo.com/api. Se você prefere trabalhar em Python, o tutorial em Python segue a mesma estrutura com requests e asyncio. Para cargas em que você prefere subir um arquivo a escrever código cliente, o geocodificador em lote é o mesmo backend com uma UI web por cima.

Se você achar os headers X-RateLimit subcontando em algum caso de canto, ou bater em um formato de resposta que este post não cobre, o formulário de contato no site chega em uma pessoa que lê. Reports de bug com reproduções em curl são corrigidos rápido.

I.A. / Criador do CSV2GEO

Artigos relacionados

Como geocodificar endereços em Python

Como converter endereço em latitude e longitude

Preços de API de geocodificação — custo real em 2026

API de geocodificação reversa

Geocodificação em lote

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 →