DEV Community

Alex Chen
Alex Chen

Posted on

Advanced JavaScript Promise Patterns

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
});
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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
]);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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' }
);
Enter fullscreen mode Exit fullscreen mode

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
]);
Enter fullscreen mode Exit fullscreen mode

Which promise pattern do you use most? Any I missed?

Follow @armorbreak for more advanced JS content.

Top comments (0)