12 - Contrôle de concurrence
Ce que tu vas apprendre
- La différence entre throttle et debounce (et quand utiliser lequel)
- Implementer une async queue et un semaphore
- Traiter des tableaux en batches
- Gerer les rate limits d'APIs avec p-limit
Prerequisites
Avoir lu l'article 11 - Race conditions.
Un jour, j'ai lance un script qui devait mettre à jour 10 000 fiches sur une API tierce. Le script faisait un Promise.all(ids.map(id => updateFiche(id))). Dix mille requêtes HTTP simultanees. L'API a repondu 429 (Too Many Requests), mon IP a ete bannie pendant une heure, et le collegue qui gerait l'API m'a envoye un message pas tres amical. Depuis, je ne lance plus de requêtes sans contrôle de concurrence.
Throttle : max N appels par intervalle
Le throttle garantit qu'une fonction est appelee au maximum une fois toutes les N millisecondes. Les appels intermediaires sont ignores.
typescriptfunction throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
intervalMs: number
): T {
let lastCall = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
const now = Date.now();
const remaining = intervalMs - (now - lastCall);
if (remaining <= 0) {
lastCall = now;
return fn(...args);
}
if (!timer) {
timer = setTimeout(() => {
lastCall = Date.now();
timer = null;
fn(...args);
}, remaining);
}
}) as T;
}
// Usage : max 1 appel toutes les 200ms
const throttledScroll = throttle(() => {
updateScrollIndicator();
}, 200);
window.addEventListener("scroll", throttledScroll);
Utilise le throttle pour : les événements haute fréquence ou tu veux une mise à jour régulière (scroll, resize, mousemove). L'utilisateur voit des mises à jour fluides a intervalle fixe.
Debounce : attendre la pause
Le debounce attend que l'utilisateur arrêté d'agir pendant N millisecondes avant d'exécuter la fonction. Chaque nouvel appel reinitialise le timer.
typescriptfunction debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delayMs: number
): T {
let timer: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, delayMs);
}) as T;
}
// Usage : lance la recherche 300ms apres la derniere frappe
const debouncedSearch = debounce((query: string) => {
fetchSearchResults(query);
}, 300);
searchInput.addEventListener("input", (e) => {
debouncedSearch((e.target as HTMLInputElement).value);
});
Utilise le debounce pour : les actions ou seul le dernier état compte (recherche, validation de formulaire, sauvegarde automatique). Tu evites de lancer 15 requêtes quand l'utilisateur tape "restaurant paris".
Throttle vs debounce : la différence en une phrase
Throttle : "exécuté au maximum une fois toutes les 200ms" (régulier). Debounce : "exécuté 300ms apres le dernier appel" (attends la fin).
Si l'utilisateur tape pendant 2 secondes :
- Throttle (200ms) : 10 executions, espacees de 200ms
- Debounce (300ms) : 1 exécution, 300ms apres la dernière frappe
Semaphore : limiter les Promises concurrentes
Le pattern le plus utile pour les appels API. Un semaphore limite le nombre d'opérations async en cours simultanément :
typescriptfunction createSemaphore(maxConcurrency: number) {
let running = 0;
const queue: Array<() => void> = [];
function release() {
running--;
if (queue.length > 0) {
const next = queue.shift()!;
next();
}
}
return async function <T>(fn: () => Promise<T>): Promise<T> {
if (running >= maxConcurrency) {
await new Promise<void>((resolve) => queue.push(resolve));
}
running++;
try {
return await fn();
} finally {
release();
}
};
}
// Usage : max 5 requetes en parallele
const limit = createSemaphore(5);
const results = await Promise.all(
urls.map((url) => limit(() => fetch(url).then((r) => r.json())))
);
Meme avec 1000 URLs, il n'y aura jamais plus de 5 fetches en cours. Les autres attendent dans la queue.
p-limit : la lib qui fait ca en une ligne
En production, j'utilise p-limit plutot que mon implementation maison. C'est teste, maintenu, et ca fait exactement la meme chose :
typescriptimport pLimit from "p-limit";
const limit = pLimit(5); // max 5 en parallele
const results = await Promise.all(
ids.map((id) =>
limit(() => fetchUser(id))
)
);
Sur paltemps.fr, j'utilise p-limit(3) pour les appels a l'API de geocoding. Trois requêtes en parallèle, pas plus. L'API a une limite de 10 req/s, mais en gardant de la marge je n'ai jamais eu de 429.
Async queue : une chose a la fois
Parfois tu veux traiter les opérations une par une, dans l'ordre. C'est un semaphore avec maxConcurrency = 1, mais le concept merite son propre nom :
typescriptfunction createAsyncQueue() {
let chain = Promise.resolve();
return function <T>(fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
chain = chain
.then(() => fn())
.then(resolve, reject);
});
};
}
const queue = createAsyncQueue();
// Ces trois operations s'executent en sequence
queue(() => saveToDatabase(record1));
queue(() => saveToDatabase(record2));
queue(() => saveToDatabase(record3));
Utilise une async queue pour : les ecritures en base, les mutations d'état partagees, tout ce qui doit etre strictement sequentiel.
Batch processing : découper en morceaux
Pour traiter un grand tableau avec une concurrence limitee, le pattern batch est simple et lisible :
typescriptasync function processBatches<T, R>(
items: T[],
batchSize: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(fn));
results.push(...batchResults);
}
return results;
}
// Traite 10 000 items par lots de 50
const results = await processBatches(allUsers, 50, async (user) => {
return updateUser(user);
});
Chaque batch de 50 s'exécuté en parallèle, puis on passe au batch suivant. C'est moins optimal que le semaphore (qui remplit les slots des qu'ils se liberent), mais c'est plus simple a comprendre et a debugger.
La différence : avec le batch, tu attends que les 50 soient finis avant de lancer les 50 suivants. Avec le semaphore, des qu'un slot se libéré, le suivant démarré. Le semaphore a un meilleur throughput, le batch est plus previsible.
Gerer les rate limits d'APIs
Les APIs serieuses retournent des headers de rate limiting :
typescriptasync function fetchWithRateLimit(url: string): Promise<Response> {
const response = await fetch(url);
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : 5000;
console.warn(`Rate limited, retry in ${waitMs}ms`);
await delay(waitMs);
return fetchWithRateLimit(url); // retry
}
return response;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
En combinant ca avec un semaphore :
typescriptconst limit = pLimit(3);
async function fetchUser(id: string) {
return limit(() => fetchWithRateLimit(`/api/users/${id}`));
}
// 10 000 appels, 3 en parallele, avec retry automatique sur 429
const users = await Promise.all(ids.map(fetchUser));
Trois couches de protection : la concurrence est limitee par p-limit, les 429 sont geres par le retry, et le Retry-After respecte le delai demande par le serveur.
Combiner les patterns
Un scénario réel : tu dois synchroniser 5000 produits depuis une API externe qui autorise 10 req/s.
typescriptimport pLimit from "p-limit";
async function syncProducts(productIds: string[]) {
const limit = pLimit(8); // un peu sous la limite de 10
let successCount = 0;
let errorCount = 0;
const results = await Promise.allSettled(
productIds.map((id) =>
limit(async () => {
const product = await fetchWithRateLimit(`/api/products/${id}`);
const data = await product.json();
await saveProduct(data);
successCount++;
})
)
);
const errors = results.filter((r) => r.status === "rejected");
errorCount = errors.length;
console.log(`Sync termine : ${successCount} OK, ${errorCount} erreurs`);
return { successCount, errorCount };
}
Promise.allSettled au lieu de Promise.all pour ne pas s'arrêter a la première erreur. p-limit(8) pour rester sous les 10 req/s. Le retry dans fetchWithRateLimit pour gerer les 429 occasionnels.
Résumé
- Throttle : exécuté a intervalle régulier, pour les événements haute fréquence
- Debounce : exécuté apres une pause, pour les saisies utilisateur
- Semaphore : limite le nombre d'opérations async simultanées (utilise
p-limit) - Async queue : exécuté une opération a la fois, dans l'ordre
- Batch : decoupe un tableau en morceaux traites sequentiellement
- Respecte les rate limits :
p-limit+ retry sur 429 +Retry-After
Article précédent : 11 - Race conditions Article suivant : 13 - Generators et async generators