Advanced JavaScript Promise Patterns
Beyond basic .then() and async/await. Real-world patterns for production code.
1. Retry Pattern
async function retry(fn, options = {}) {
const {
retries = 3,
delay = 1000,
backoff = 2,
shouldRetry = (e) => true, // Custom condition to retry
} = options;
let lastError;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i === retries || !shouldRetry(error)) throw error;
const waitTime = delay * Math.pow(backoff, i);
console.warn(`Attempt ${i + 1} failed. Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
throw lastError;
}
// Usage:
const data = await retry(() => fetch('/api/data').then(r => r.json()), {
retries: 3,
delay: 1000,
backoff: 2, // 1s → 2s → 4s
shouldRetry: (e) => e.status >= 500, // Only retry server errors
});
2. Timeout Wrapper
function withTimeout(promise, ms, error = new Error('Timeout')) {
const timer = new Promise((_, reject) =>
setTimeout(() => reject(error), ms)
);
return Promise.race([promise, timer]);
}
// Usage:
try {
const data = await withTimeout(
fetch('/api/slow-endpoint'),
5000,
new Error('API request timed out after 5s')
);
} catch (err) {
if (err.message.includes('timeout')) {
// Handle timeout specifically
showFallbackData();
}
}
3. Request Caching (Deduplication)
class RequestCache {
constructor(ttl = 5000) {
this.cache = new Map();
this.ttl = ttl;
}
async get(key, fetcher) {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.promise; // Return same promise if in-flight!
}
const promise = fetcher().finally(() => {
// Don't delete — let it expire naturally
});
this.cache.set(key, { promise, timestamp: Date.now() });
return promise;
}
invalidate(key) {
this.cache.delete(key);
}
}
// Usage:
const apiCache = new RequestCache(10000); // 10 second cache
// These concurrent calls will only make ONE actual HTTP request:
const [data1, data2] = await Promise.all([
apiCache.get('user:123', () => fetchUser(123)),
apiCache.get('user:123', () => fetchUser(123)), // Cached! Returns same promise
]);
4. Concurrency Limiter
async function limitConcurrency(tasks, maxConcurrent = 5) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const promise = task().then(result => {
executing.delete(promise);
return result;
});
executing.add(promise);
results.push(promise);
if (executing.size >= maxConcurrent) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
// Process 100 URLs but only 5 at a time:
const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/data/${i}`);
const results = await limitConcurrency(
urls.map(url => () => fetch(url).then(r => r.json())),
5 // Max 5 concurrent requests
);
5. Queue Pattern (Process in Order)
class AsyncQueue {
constructor() {
this.queue = [];
this.processing = false;
}
async enqueue(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const { task, resolve, reject } = this.queue.shift();
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
}
}
this.processing = false;
}
}
// Ensure writes happen in order even when triggered rapidly:
const writeQueue = new AsyncQueue();
function saveToDatabase(data) {
return writeQueue.enqueue(() => db.save(data));
}
// Rapid calls are queued and processed sequentially:
saveToDatabase({ id: 1, value: 'a' });
saveToDatabase({ id: 2, value: 'b' });
saveToDatabase({ id: 3, value: 'c' });
// All processed in order, one at a time
6. Event Emitter Pattern
class EventEmitter {
#listeners = {};
on(event, callback) {
(this.#listeners[event] ??= []).push(callback);
return () => this.off(event, callback); // Return unsubscribe function
}
once(event, callback) {
const wrapper = (...args) => {
this.off(event, wrapper);
callback(...args);
};
this.on(event, wrapper);
}
off(event, callback) {
const listeners = this.#listeners[event];
if (!listeners) return;
this.#listeners[event] = listeners.filter(l => l !== callback);
}
emit(event, ...args) {
const listeners = this.#listeners[event];
if (!listeners) return;
listeners.forEach(callback => callback(...args));
}
}
// Practical usage: WebSocket reconnection
class ReconnectingWebSocket extends EventEmitter {
constructor(url, options = {}) {
super();
this.url = url;
this.options = { maxRetries: 5, reconnectDelay: 1000, ...options };
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.emit('connected');
this.retryCount = 0;
};
this.ws.onmessage = (event) => {
this.emit('message', JSON.parse(event.data));
};
this.ws.onclose = () => {
this.emit('disconnected');
this.reconnect();
};
this.ws.onerror = (error) => {
this.emit('error', error);
};
}
reconnect() {
if (this.retryCount >= this.options.maxRetries) {
this.emit('exhausted');
return;
}
this.retryCount++;
const delay = this.options.reconnectDelay * Math.pow(2, this.retryCount);
setTimeout(() => this.connect(), delay);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
7. Polling Pattern
async function poll(fetcher, options = {}) {
const {
interval = 5000, // Time between polls
maxAttempts = null, // Null = infinite
stopCondition = result => !!result, // Stop when truthy
onError = 'throw', // 'throw' | 'ignore' | 'return'
} = options;
let attempts = 0;
while (maxAttempts === null || attempts < maxAttempts) {
attempts++;
try {
const result = await fetcher();
if (stopCondition(result)) {
return { status: 'complete', result, attempts };
}
} catch (error) {
if (onError === 'throw') throw error;
if (onError === 'return') return { status: 'error', error, attempts };
// 'ignore': continue polling
}
await new Promise(resolve => setTimeout(resolve, interval));
}
return { status: 'exhausted', attempts };
}
// Wait for an async job to complete:
const jobResult = await poll(
() => fetch(`/api/jobs/${jobId}`).then(r => r.json()),
{ interval: 2000, stopCondition: (j) => j.status === 'completed' || j.status === 'failed' }
);
8. Promise Combinators Deep Dive
// Promise.all — All must succeed (fail fast)
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
// Promise.allSettled — Wait for ALL regardless of success/failure
const results = await Promise.allSettled([
fetchPrimaryService(), // Might fail
fetchBackupService(), // Might fail
fetchLocalCache(), // Usually works
]);
const successful = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
const failures = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
// Promise.any — First success wins (ignore failures)
const fastest = await Promise.any([
fetchFromCDN(),
fetchFromOrigin(),
fetchFromBackup(),
]);
// Returns the first successful result
// Promise.race — First to finish wins (success or failure)
const result = await Promise.race([
fetchData(),
timeoutAfter(3000), // Rejects after 3 seconds
]);
Which promise pattern do you use most? Any I missed?
Follow @armorbreak for more advanced JS content.
Top comments (0)