Tutoriel API de géocodage Node.js : concurrence, retries et streaming CSV

Tutoriel Node.js de géocodage prêt pour la prod : concurrence bornée avec p-limit, retry/backoff sur 429, et un pipeline CSV en streaming. Code testé.

| April 26, 2026
Tutoriel API de géocodage Node.js : concurrence, retries et streaming CSV

Voici un tutoriel Node.js fonctionnel pour géocoder des adresses via une API REST. Le code de cet article a été écrit, copié-collé dans des fichiers et exécuté avant publication. Chaque snippet tourne sur une installation propre de Node 22 avec deux dépendances (p-limit, csv-parse). Pas de framework, pas de SDK, pas de magie.

Ce que vous obtiendrez à la fin de l'article : un script qui lit un CSV d'adresses, les géocode en parallèle avec un rate limit configurable, retry sur les 429 avec exponential backoff, et écrit un CSV de {lat, lng, error} sans charger le fichier d'entrée en mémoire. Environ 80 lignes de code.

L'endpoint utilisé tout au long est csv2geo.com/api/v1. Le tier gratuit, c'est 1 000 requêtes par jour, sans carte bancaire. Connectez-vous et récupérez une clé sur /api-keys si vous voulez suivre ; les endpoints de démo utilisés dans les premiers exemples ne nécessitent pas de clé.

L'endpoint

Deux endpoints couvrent 95 % du travail de géocodage en conditions réelles. Le forward transforme une chaîne d'adresse en coordonnées. Le reverse transforme des coordonnées en adresse. Les deux acceptent GET (single) ou POST (batch).

# 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 forme de la réponse pour une requête forward unique ressemble à ceci. C'est la sortie réelle, pas un exemple de documentation.

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

Deux champs comptent vraiment quand vous écrivez du code client. results[0].location, c'est votre {lat, lng}. results[0].accuracy, c'est le niveau de match — "houseNumber", c'est rooftop, "street" centroïde de rue, "place" un match POI, "postcode" un centroïde de code postal. Servez-vous-en pour écarter les résultats à faible confiance avant qu'ils ne polluent votre dataset.

Première requête

Node 18+ embarque fetch nativement. Aucun npm install requis pour le premier appel.

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

Lancez-le : CSV2GEO_KEY=geo_live_xxx node geocode.mjs. La forme avec header Authorization est la recommandée — les clés ne se retrouvent ni dans l'historique du shell ni dans les logs d'accès comme ça.

Reverse geocoding

Même forme, paramètres différents. Vous passez lat et lng, vous récupérez une adresse.

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

Les réponses reverse incluent un champ distance_meters — la distance entre l'adresse matchée et la coordonnée d'entrée. Si vous reverse-geocodez des pings GPS d'une appli de livraison, tout ce qui dépasse ~50 m signifie en général que le fix GPS était mauvais, pas le geocoder.

Le faire en parallèle sans tout faire fondre

La mauvaise façon de géocoder 10 000 adresses en Node, c'est Promise.all sur un map de fetch. Ça déclenche 10 000 connexions concurrentes vers l'API, tape le rate limit dès le second batch, et remplit l'event loop de promesses rejetées. À éviter.

La bonne façon, c'est la concurrence bornée. L'outil le plus propre pour ça, c'est p-limit — environ 50 lignes de source, sans dépendances transitives.

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

Le tuning de la concurrence est empirique. Le plan gratuit plafonne à 100 requêtes par minute, donc une concurrence de 4 est sûre. Le plan Starter, c'est 1 000/min — entre 16 et 24 est une bonne fourchette. Le plan Pro, c'est 10 000/min — démarrez à 64 et surveillez le header X-RateLimit-Remaining.

Retry, backoff et headers de rate limit

Trois choses dans un vrai geocoder de production : (1) traiter les 429 et 5xx comme retryables, (2) honorer le header Retry-After quand il est présent, (3) plafonner le nombre de tentatives pour qu'une clé définitivement morte ne boucle pas indéfiniment.

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 réponse expose aussi trois headers qui valent le coup d'œil sur chaque appel réussi :

  • X-RateLimit-Limit — le plafond par minute de votre plan.
  • X-RateLimit-Remaining — ce qu'il vous reste dans la fenêtre courante.
  • X-RateLimit-Reset — secondes Unix avant le reset de la fenêtre.

Si Remaining tombe sous 10 % de Limit, ralentissez volontairement. Moins cher que de patiner sur des 429, et bien moins cher qu'un burst soudain qui met tout le pipeline hors-ligne.

L'endpoint batch

Les tiers d'abonnement à partir de Starter exposent un endpoint batch qui accepte un tableau d'adresses dans un seul POST. Jusqu'à 10 000 par requête sur Pro. Un round-trip au lieu de N. Le tier gratuit n'autorise pas le batch — il faut boucler en singles.

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

Les réponses batch préservent l'ordre d'entrée. La position N de la réponse correspond toujours à la position N de l'entrée — pas besoin de faire transiter un champ id, même si vous pouvez.

Streamer un CSV sans OOM

Si votre fichier d'entrée fait 100 000 lignes, vous ne voulez pas le fs.readFileSync, le parser dans un tableau géant, puis faire un map() sur le résultat. Vous allez manquer de mémoire ou cramer votre rate limit. Streamez-le.

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

Trois choses que ce script fait et que le code des débutants ne fait en général pas :

  • Lit l'entrée en stream (pas de chargement complet du fichier) et écrit la sortie en stream (pas de tout-bufferiser-puis-flusher).
  • Plafonne le travail de géocodage in-flight à 8 requêtes concurrentes via p-limit.
  • Enregistre les erreurs dans une colonne au lieu de planter à la première défaillance. C'est la différence entre un script qui finit et un script qu'il faut redémarrer en se demandant "où s'est-il arrêté ?".

Un wrapper TypeScript minimal

Si votre projet est en TypeScript, typez la forme de la réponse une fois et réutilisez-la partout. Les types ci-dessous sont assez étroits pour être utiles et assez lâches pour que les futures additions de l'API ne cassent pas le 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;
}

Deux choix de design qui valent d'être soulignés. D'abord, la fonction retourne null en cas de no-match au lieu de throw — un résultat vide n'est pas une erreur, juste une réponse. Ensuite, accuracy est typé en union, donc un switch dessus sans branche default devient une erreur de compilation si l'API ajoute une nouvelle valeur. C'est le genre de code typé qui survit aux versions mineures de l'API.

Les pièges qui mordent

L'ordre n'est pas préservé quand vous parallélisez des singles. Promise.all retourne dans l'ordre d'entrée, mais le réseau termine la requête qui revient en premier. Si vous écrivez vos résultats en stream dans l'ordre de complétion réseau, vos lignes de sortie seront mélangées. Soit vous écrivez en stream avec une clé id (l'exemple ci-dessus le fait — chaque ligne porte son id d'origine), soit vous bufferisez jusqu'à la fin.

fetch ne throw pas sur les non-2xx. Un 404 ou un 429, c'est une promesse résolue, pas une rejection. Vérifiez toujours res.ok avant de parser le body. Le nombre de tickets "pourquoi mes résultats sont undefined" qui remontent à ça est énorme.

Les codes pays comptent. Le paramètre country est un hint, pas un filtre. Si vous passez country=US pour une adresse à Toronto, vous allez recevoir un résultat erroné mais plausible quelque part dans l'État de New York. Définissez country par ligne quand votre entrée est mixte.

Les chaînes vides sont des entrées valides qui retournent du n'importe quoi. Validez row.address avant d'envoyer. Deux minutes d'assainissement d'entrée économisent une heure d'enquête sur "pourquoi la lat est 0,0 ?".

Questions fréquentes

Ai-je besoin d'un SDK ?

Non. L'API c'est REST + JSON, et Node embarque fetch nativement. Le "SDK" que la plupart des équipes finissent par écrire, c'est la fonction geocodeWithRetry ci-dessus plus un wrapper pour l'endpoint batch. Une trentaine de lignes.

Est-ce que ça marche avec TypeScript ?

Oui. Typez la forme de la réponse à partir du JSON exemple ci-dessus. Les types minimum utiles sont { results: Array<{ location: { lat: number; lng: number }; accuracy: string; accuracy_score: number; components: Record<string, string> }> }. Pour plus strict, restreignez accuracy à une union de chaînes "houseNumber" | "street" | "place" | "postcode".

Puis-je utiliser ça depuis un navigateur au lieu de Node ?

Vous pouvez, mais vous ne devriez pas mettre une clé API à longue durée de vie dans du code navigateur — n'importe qui peut la lire. Soit vous proxifiez via votre propre backend (le même code que dans cet article, juste à l'intérieur d'une route Express ou Fastify), soit vous émettez des clés à courte durée de vie scopées au navigateur.

Quel rate limit utiliser pour la concurrence ?

Prenez le rate limit par minute de votre plan, divisez par 60, et visez à peu près ce nombre de requêtes concurrentes. 1 000/min ÷ 60 ≈ 16. Arrondissez vers le bas. Le header X-RateLimit-Remaining est la source de vérité — ajustez si vous le voyez chuter vite.

Comment distinguer un no-match d'un match réussi à faible confiance ?

data.results est un tableau vide en cas de no-match. Sur un match à faible confiance, results[0] existe mais accuracy vaut "postcode" ou "place" plutôt que "houseNumber" ou "street", et accuracy_score est nettement inférieur à 1. Choisissez un seuil (0,7 est un défaut raisonnable) et traitez tout ce qui est en dessous comme un no-match.

Comment géocoder des adresses hors États-Unis ?

Passez le bon code pays dans le paramètre country — ISO alpha-2, donc DE pour l'Allemagne, GB pour le Royaume-Uni, BR pour le Brésil. La couverture s'étend aujourd'hui à 39 pays incluant le top 10 complet par nombre d'adresses. La page API liste les comptes par pays.

Dois-je utiliser des requêtes singles ou l'endpoint batch ?

Batch quand vous avez ≥ 100 adresses à faire d'un coup et que votre plan le permet. Un POST est moins coûteux des deux côtés que 100 GET, et le gain de latence vaut à peu près votre temps moyen par requête multiplié par le nombre de requêtes divisé par la concurrence. Les singles sont plus simples pour des charges en streaming où les adresses arrivent au fil du temps.

Pour aller plus loin

La référence complète de l'API est sur csv2geo.com/api. Si vous préférez bosser en Python, le tutoriel Python suit la même structure avec requests et asyncio. Pour les charges où vous voulez uploader un fichier plutôt que d'écrire du code client, le batch geocoder est le même backend derrière une UI web.

Si vous trouvez les headers X-RateLimit qui sous-comptent dans un edge case, ou si vous tombez sur une forme de réponse non couverte par cet article, le formulaire de contact du site arrive chez une vraie personne qui le lit. Les bug reports avec reproductions curl sont fixés rapidement.

I.A. / Créateur CSV2GEO

Articles liés

Comment géocoder des adresses en Python

Comment convertir une adresse en lat/long

Tarifs des API de géocodage — coût réel en 2026

API de reverse geocoding

Géocodage en lot

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 →