Week 11: Advanced JavaScript

Deep Dive into Asynchronous Patterns

INFO 153A/253A - Front-End Web Architecture

UC Berkeley

Topics: Async/Await Mastery, Advanced Promises, Error Handling, Real-World Patterns

Part 1: Recap of Promises & Async Foundations

From Chapter 10 Mandatory Prep & Week 10

The Promise Journey from Prep Work

// From Chapter 10: Creating and consuming promises
const promise = new Promise((resolve, reject) => {
  // Async operation
  setTimeout(() => {
    const success = Math.random() > 0.5;
    if (success) {
      resolve({ data: "Operation complete" });
    } else {
      reject(new Error("Operation failed"));
    }
  }, 1000);
});

// Consuming with .then/.catch
promise
  .then(result => console.log(result))
  .catch(error => console.error(error))
  .finally(() => console.log("Cleanup"));
  • Promise constructor: Takes executor function with resolve/reject
  • Three states: Pending → Fulfilled or Rejected (settled)
  • Immutable once settled: Can't change from fulfilled to rejected
  • .finally() always runs: Perfect for cleanup operations
  • Non-blocking execution: Code after promise continues immediately

From Callbacks to Promises to Async/Await

// Evolution of async patterns

// 1. Callback pattern (the old way)
function fetchData(callback) {
  setTimeout(() => {
    callback({ id: 1, name: "Student" });
  }, 1000);
}

// 2. Promise pattern (better)
function fetchDataPromise() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: 1, name: "Student" });
    }, 1000);
  });
}

// 3. Async/await pattern (modern - introduced Week 10)
async function fetchDataAsync() {
  // Simulating API call
  await new Promise(resolve => setTimeout(resolve, 1000));
  return { id: 1, name: "Student" };
}
  • Callbacks led to "callback hell": Deeply nested, hard to read code
  • Promises improved chaining: Flatten nested callbacks with .then()
  • Async/await is syntactic sugar: Makes async code look synchronous
  • All three patterns coexist: You'll encounter all in real codebases
  • Modern preference: Async/await for readability and debugging

Promise.all() from Chapter 10

// From prep: Handling multiple promises simultaneously
const fetchCourses = fetch('/api/courses').then(r => r.json());
const fetchStudents = fetch('/api/students').then(r => r.json());
const fetchGrades = fetch('/api/grades').then(r => r.json());

// Wait for all to complete
Promise.all([fetchCourses, fetchStudents, fetchGrades])
  .then(([courses, students, grades]) => {
    console.log('All data loaded:', { courses, students, grades });
    // Process all data together
  })
  .catch(error => {
    console.error('One or more requests failed:', error);
    // If ANY promise rejects, Promise.all rejects
  });

// With async/await (Week 10 style)
async function loadAllData() {
  try {
    const [courses, students, grades] = await Promise.all([
      fetch('/api/courses').then(r => r.json()),
      fetch('/api/students').then(r => r.json()),
      fetch('/api/grades').then(r => r.json())
    ]);
    return { courses, students, grades };
  } catch (error) {
    throw new Error('Failed to load dashboard data');
  }
}
  • Parallel execution: All promises run simultaneously, not sequentially
  • Fail-fast behavior: If one fails, entire Promise.all fails immediately
  • Order preserved: Results array matches input promise order
  • Performance benefit: 3 requests in parallel faster than sequential
  • Common use case: Loading multiple resources for a page/component

Key Async Concepts from Week 10

Remember: JavaScript is single-threaded but has async capabilities through Web APIs

// From Week 10: Async in React components
function CourseList() {
  const [courses, setCourses] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Can't make useEffect async directly
    async function fetchCourses() {
      try {
        const response = await fetch('/api/courses');
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        setCourses(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchCourses();
  }, []);

  // Component renders with loading/error/data states
}
  • Event loop enables async: Callbacks, promises, async/await all use it
  • Microtask queue priority: Promises resolve before setTimeout callbacks
  • React components can't be async: But functions inside them can be
  • Always handle three states: Loading, success, and error
  • fetch doesn't throw on HTTP errors: Must check response.ok

Part 2: Understanding Async/Await

What It Is, Why It Exists, and Problems It Solves

What is Async/Await?

Definition: Async/await is syntactic sugar built on top of Promises that makes asynchronous code look and behave like synchronous code.

Promise Chains

function fetchData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(r => r.json())
    .then(user => {
      return fetch(`/api/courses/${user.courseId}`);
    })
    .then(r => r.json())
    .then(course => course)
    .catch(err => console.error(err));
}

Async/Await

async function fetchData(userId) {
  try {
    const userRes = await fetch(`/api/users/${userId}`);
    const user = await userRes.json();

    const courseRes = await fetch(`/api/courses/${user.courseId}`);
    const course = await courseRes.json();
    return course;
  } catch (err) {
    console.error(err);
  }
}
  • async keyword: Declares function returns a Promise automatically
  • await keyword: Pauses execution until Promise resolves
  • Syntactic sugar: Transforms to Promise chains behind the scenes
  • Readability boost: Code reads top-to-bottom like synchronous code

Problem 1: Callback Hell

Nested Callbacks

// Pyramid of doom (circa 2010)
getUser(userId, function(user) {
  getCourses(user.id, function(courses) {
    getGrades(courses[0].id, function(grades) {
      calculateAvg(grades, function(avg) {
        displayResult(avg, function() {
          console.log('Done!');
        });
      });
    });
  });
});

Async/Await Solution

// Flat, readable flow
async function display(userId) {
  const user = await getUser(userId);
  const courses = await getCourses(user.id);
  const grades = await getGrades(courses[0].id);
  const avg = await calculateAvg(grades);
  await displayResult(avg);
  console.log('Done!');
}
  • Pyramid of doom: Nested callbacks hard to follow
  • Linear mental model: Async/await reads step-by-step
  • Single error handler: One try/catch catches all errors
  • Debugging friendly: Stack traces and breakpoints work naturally

Problem 2: Error Handling Complexity

Promise Chains

// Which request failed?
fetch('/api/user')
  .then(r => {
    if (!r.ok) throw new Error('User failed');
    return r.json();
  })
  .then(user => fetch(`/api/courses/${user.id}`))
  .then(r => {
    if (!r.ok) throw new Error('Courses failed');
    return r.json();
  })
  .catch(error => {
    console.error('Something failed:', error);
  });

Async/Await

// Clear error context
async function loadData() {
  try {
    const userRes = await fetch('/api/user');
    if (!userRes.ok) throw new Error('User failed');
    const user = await userRes.json();

    const courseRes = await fetch(`/api/courses/${user.id}`);
    if (!courseRes.ok) throw new Error('Courses failed');
    const courses = await courseRes.json();

    return { user, courses };
  } catch (error) {
    console.error('Load failed:', error.message);
    throw error;
  }
}
  • Try/catch familiarity: Standard pattern from other languages
  • Clear stack traces: Line numbers point to exact failure
  • Granular handling: Multiple try/catch blocks for different operations
  • Better debugging: Breakpoints and step-through work intuitively

Problem 3: Sequential vs Parallel Confusion

BAD: Sequential (Slow)

// Waits for each one
async function loadDashboard() {
  const user = await fetch('/api/user')
    .then(r => r.json());    // 200ms
  const courses = await fetch('/api/courses')
    .then(r => r.json());    // 200ms
  const grades = await fetch('/api/grades')
    .then(r => r.json());    // 200ms

  return { user, courses, grades };
  // Total: ~600ms
}

GOOD: Parallel (Fast)

// Runs simultaneously
async function loadDashboard() {
  const [user, courses, grades] = await Promise.all([
    fetch('/api/user').then(r => r.json()),
    fetch('/api/courses').then(r => r.json()),
    fetch('/api/grades').then(r => r.json())
  ]);

  return { user, courses, grades };
  // Total: ~200ms (3x faster!)
}
  • Await = pause: Each await waits before continuing
  • Parallelize independent ops: Use Promise.all for simultaneous requests
  • Performance impact: Sequential vs parallel can triple load times

How Async/Await Actually Works

Under the Hood: Async functions are transformed into Promises by JavaScript

// What you write
async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  const user = await res.json();
  return user;
}

// Conceptually becomes
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then(res => res.json())
      .then(user => resolve(user))
      .catch(err => reject(err));
  });
}
// Key behaviors
const result = fetchUser(123);
console.log(result);
// Promise {  }

// Must use await or .then()
const user = await fetchUser(123);
console.log(user);
// { id: 123, name: "Student" }

// Even plain returns wrap in Promise
async function getNumber() {
  return 42;
}
getNumber() // Promise.resolve(42)
  • Always returns Promises: Even plain values get wrapped
  • Await only in async functions: Except top-level in modules
  • Non-blocking: Await yields control, doesn't freeze browser

When to Use Async/Await vs Promises

Use Async/Await:

  • Sequential operations with dependencies
  • Complex error handling needed
  • Code readability is priority
  • Multiple await calls in sequence
async function processOrder(orderId) {
  try {
    const order = await getOrder(orderId);
    const payment = await processPayment(order);
    const ship = await createShipment(payment);
    return ship;
  } catch (error) {
    await rollbackOrder(orderId);
    throw error;
  }
}

Use Promise Chains:

  • Simple one-off operations
  • Need Promise combinators (all, race)
  • Functional programming style
  • Data transformation pipelines
// Promise chains for transforms
fetch('/api/users')
  .then(r => r.json())
  .then(users => users.filter(u => u.active))
  .then(active => active.map(u => u.email))
  .then(emails => sendNotifications(emails))
  .catch(handleError);
  • Not either/or: Often use both in same codebase
  • Mix and match: await Promise.all() combines both approaches

Common Pitfall #1: Forgetting await

BUG

// Forgot await - returns Promise!
async function getUsername(id) {
  const user = fetchUser(id); // Missing await!
  return user.name;
  // Error: Cannot read property
  // 'name' of Promise
}

// What user actually is:
console.log(user);
// Promise {  }

FIX

// Add await keyword
async function getUsername(id) {
  const user = await fetchUser(id);
  return user.name;
  // Works! Returns the actual name
}

// What user actually is:
console.log(user);
// { id: 1, name: "Kay" }
  • Most common mistake: Forgetting await returns Promise instead of value
  • ESLint helps: Use @typescript-eslint/no-floating-promises rule
  • Debugging tip: Console.log suspicious values to check if they're Promises

Common Pitfall #2: Sequential Loops

BAD: Sequential (Slow)

// Waits for each one
async function processUsers(userIds) {
  const results = [];
  for (const id of userIds) {
    const user = await fetchUser(id);
    results.push(user);
  }
  return results;
}

// 100 users = ~20 seconds!

GOOD: Parallel (Fast)

// All at once
async function processUsers(userIds) {
  const promises = userIds.map(
    id => fetchUser(id)
  );
  return await Promise.all(promises);
}

// 100 users = ~200ms!
  • Sequential loops kill performance: await in loop waits for each iteration
  • Use Promise.all for independent operations: 100x faster for large arrays
  • Watch out for forEach: Doesn't work with await! Use for...of or map

Common Pitfall #3: Missing Error Handling

BAD: No try/catch

// Unhandled rejection!
async function loadData() {
  const data = await fetch('/api/data')
    .then(r => r.json());
  return data;
  // If fetch fails, app crashes!
}

GOOD: Handle errors

// Graceful fallback
async function loadData() {
  try {
    const data = await fetch('/api/data')
      .then(r => r.json());
    return data;
  } catch (error) {
    console.error('Load failed:', error);
    return getDefaultData();
  }
}
  • Always use try/catch: Unhandled rejections can crash your app
  • Provide fallbacks: Return default data instead of leaving users stranded
  • Log errors: Send to error tracking service (Sentry, Bugsnag, etc.)

Part 3: Advanced Promise Methods

Beyond Promise.all - Race, AllSettled, and Any

Promise.race() - First One Wins

// Promise.race settles with the first promise to settle
async function fetchWithTimeout(url, timeout = 5000) {
  const fetchPromise = fetch(url);

  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Request timeout')), timeout);
  });

  // Race between fetch and timeout
  return Promise.race([fetchPromise, timeoutPromise]);
}

// Usage example
async function getStudentData() {
  try {
    const response = await fetchWithTimeout('/api/students', 3000);
    const data = await response.json();
    console.log('Got data:', data);
  } catch (error) {
    if (error.message === 'Request timeout') {
      console.error('Request took too long!');
      // Show cached data or retry
    } else {
      console.error('Network error:', error);
    }
  }
}
  • First to settle wins: Whether it resolves or rejects
  • Perfect for timeouts: Race actual operation against timer
  • Cancellation pattern: Useful for implementing request cancellation
  • Performance testing: Race multiple servers, use fastest
  • Not about success: First to finish, not first to succeed
  • Other promises continue: They still run, just ignored

Promise.allSettled() - Wait for All, Ignore Failures

// Promise.allSettled waits for all, doesn't fail fast
async function loadDashboardData() {
  const results = await Promise.allSettled([
    fetch('/api/user-profile').then(r => r.json()),
    fetch('/api/notifications').then(r => r.json()),
    fetch('/api/recent-activity').then(r => r.json()),
    fetch('/api/recommendations').then(r => r.json())
  ]);

  // Process each result individually
  const dashboard = {
    profile: null,
    notifications: [],
    activity: [],
    recommendations: []
  };

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      switch(index) {
        case 0: dashboard.profile = result.value; break;
        case 1: dashboard.notifications = result.value; break;
        case 2: dashboard.activity = result.value; break;
        case 3: dashboard.recommendations = result.value; break;
      }
    } else {
      console.error(`Failed to load section ${index}:`, result.reason);
    }
  });

  return dashboard; // Partial data is better than no data
}
  • Never rejects: Always resolves with array of result objects
  • Each result has status: "fulfilled" or "rejected"
  • Graceful degradation: Show what loaded, hide what failed
  • Perfect for optional data: Non-critical API calls
  • Better UX: Partial success better than complete failure
  • Detailed error info: Know exactly which operations failed

Promise.any() - First Success Wins

// Promise.any resolves with first successful promise
async function fetchFromFastestCDN(resource) {
  try {
    const result = await Promise.any([
      fetch(`https://cdn1.example.com/${resource}`),
      fetch(`https://cdn2.example.com/${resource}`),
      fetch(`https://cdn3.example.com/${resource}`),
      fetch(`https://backup.example.com/${resource}`)
    ]);

    console.log('Got resource from fastest CDN');
    return result;
  } catch (error) {
    // AggregateError: ALL promises rejected
    console.error('All CDNs failed:', error.errors);
    throw new Error('Resource unavailable from all sources');
  }
}

// Practical example: Multi-region API fallback
async function getDataWithFallback(studentId) {
  try {
    const data = await Promise.any([
      fetchFromRegion('us-west', studentId),
      fetchFromRegion('us-east', studentId),
      fetchFromRegion('europe', studentId),
      fetchFromCache(studentId) // Local fallback
    ]);

    return data;
  } catch (aggregateError) {
    // Only fails if ALL attempts fail
    console.error('All attempts failed:', aggregateError.errors);
    return getDefaultStudentData();
  }
}
  • First successful promise: Ignores rejections until all fail
  • Resilience pattern: Multiple fallback options
  • AggregateError on failure: Contains all rejection reasons
  • CDN/API redundancy: Common pattern for high availability
  • Different from race: Race = first to finish, any = first to succeed
  • ES2021 feature: Very new, check browser support

Choosing the Right Promise Method

Promise Method Decision Tree

// Decision guide for Promise methods
const examples = {
  // Use Promise.all when you need everything
  all: "Loading user + preferences + permissions",

  // Use Promise.race for timeouts
  race: "API call with 5-second timeout",

  // Use Promise.allSettled for optional data
  allSettled: "Dashboard with independent widgets",

  // Use Promise.any for fallback/redundancy
  any: "Multi-region API with fallbacks"
};

// Practical comparison
async function demonstratePromiseMethods() {
  const promises = [
    delay(100, 'fast'),
    delay(200, 'medium'),
    delayReject(150, 'error'),
    delay(300, 'slow')
  ];

  // Different behaviors:
  const all = Promise.all(promises);           // Fails at 150ms
  const race = Promise.race(promises);         // Resolves at 100ms
  const allSettled = Promise.allSettled(promises); // Waits 300ms
  const any = Promise.any(promises);           // Resolves at 100ms
}
  • Promise.all: "I need everything or nothing"
  • Promise.race: "I need the fastest result"
  • Promise.allSettled: "I want everything, failures OK"
  • Promise.any: "I need at least one success"
  • Performance implications: All methods have different timing
  • Error handling varies: Each requires different catch logic

Part 4: Error Handling Mastery

Professional Async Error Patterns

The Complete Error Handling Pattern

// Professional error handling with custom error types
class APIError extends Error {
  constructor(message, status, endpoint) {
    super(message);
    this.name = 'APIError';
    this.status = status;
    this.endpoint = endpoint;
    this.timestamp = new Date().toISOString();
  }
}

async function robustFetch(url, options = {}) {
  const maxRetries = 3;
  const timeout = options.timeout || 10000;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);

      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new APIError(
          `HTTP ${response.status}: ${response.statusText}`,
          response.status,
          url
        );
      }

      return await response.json();

    } catch (error) {
      if (attempt === maxRetries) {
        // Log to error tracking service
        console.error('Final attempt failed:', error);
        throw error;
      }
      // Exponential backoff
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
    }
  }
}
  • Custom error classes: Provide context about what went wrong
  • Retry logic with backoff: Handle transient network failures
  • Timeout handling: Prevent hanging requests
  • AbortController: Modern way to cancel fetch requests
  • Structured logging: Include timestamp, endpoint, status
  • Progressive retry delay: Exponential backoff prevents server overload

Error Boundaries for Async Operations

// Async error boundary pattern
class AsyncBoundary {
  constructor() {
    this.errors = [];
    this.handlers = new Map();
  }

  async wrap(operation, context = 'unknown') {
    try {
      const result = await operation();
      return { success: true, data: result };
    } catch (error) {
      this.errors.push({ error, context, timestamp: Date.now() });

      // Check for specific error handlers
      for (const [ErrorType, handler] of this.handlers) {
        if (error instanceof ErrorType) {
          return { success: false, fallback: await handler(error) };
        }
      }

      // Default fallback
      return { success: false, error: error.message };
    }
  }

  onError(ErrorType, handler) {
    this.handlers.set(ErrorType, handler);
    return this;
  }
}

// Usage example
const boundary = new AsyncBoundary()
  .onError(APIError, async (err) => {
    if (err.status === 404) return { empty: true };
    if (err.status === 401) await refreshAuth();
    return null;
  })
  .onError(TypeError, () => ({ error: 'Invalid data format' }));

const result = await boundary.wrap(
  () => fetchUserData(userId),
  'user-data-fetch'
);
  • Centralized error handling: Single place to manage all async errors
  • Error type discrimination: Different handling for different errors
  • Fallback values: Graceful degradation instead of crashes
  • Error history tracking: Debug intermittent issues
  • Context preservation: Know where errors occurred
  • Chainable API: Fluent interface for configuration

Handling Specific Error Scenarios

// Common async error scenarios and solutions
const ErrorHandlers = {
  // Network errors
  async handleNetworkError(error) {
    if (!navigator.onLine) {
      return { offline: true, cached: await getCachedData() };
    }
    throw error; // Re-throw if not offline
  },

  // Auth errors with token refresh
  async handle401(refreshToken) {
    try {
      const newToken = await fetch('/auth/refresh', {
        method: 'POST',
        body: JSON.stringify({ refreshToken })
      }).then(r => r.json());

      localStorage.setItem('token', newToken);
      return { retry: true, token: newToken };
    } catch {
      // Refresh failed, need re-login
      window.location.href = '/login';
    }
  },

  // Rate limiting with retry-after
  async handle429(retryAfter = 60) {
    console.warn(`Rate limited, waiting ${retryAfter}s`);
    await new Promise(r => setTimeout(r, retryAfter * 1000));
    return { retry: true };
  },

  // Data validation errors
  validateResponse(data, schema) {
    const errors = [];
    for (const [key, type] of Object.entries(schema)) {
      if (typeof data[key] !== type) {
        errors.push(`${key} should be ${type}`);
      }
    }
    if (errors.length > 0) {
      throw new ValidationError(errors);
    }
    return data;
  }
};
  • Network detection: Check navigator.onLine for offline mode
  • Auth refresh pattern: Automatically refresh expired tokens
  • Rate limit handling: Respect retry-after headers
  • Response validation: Catch malformed API responses early
  • Graceful degradation: Fall back to cached data when possible
  • User-friendly errors: Convert technical errors to user messages

Error Recovery Strategies

// Circuit breaker pattern for failing services
class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.nextAttempt = Date.now();
  }

  async call(asyncFn) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is OPEN');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await asyncFn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failureCount++;
    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.timeout;
      console.error('Circuit breaker opened, too many failures');
    }
  }
}

// Usage
const apiBreaker = new CircuitBreaker(3, 30000);
try {
  const data = await apiBreaker.call(() => fetch('/flaky-api'));
} catch (error) {
  // Use fallback or cached data
}
  • Circuit breaker pattern: Prevents cascading failures
  • Three states: Closed (normal), Open (failing), Half-open (testing)
  • Automatic recovery: Periodically tests if service recovered
  • Fail fast: Don't waste time on known-bad services
  • Protects backend: Prevents overwhelming failing services
  • Netflix pattern: Used in microservices architectures

Part 5: Real-World Async Patterns

Production Patterns for Complex Async Scenarios

Retry Logic with Exponential Backoff

// Production-ready retry mechanism
async function retryWithBackoff(
  fn,
  maxRetries = 3,
  baseDelay = 1000,
  maxDelay = 30000,
  shouldRetry = (error) => true
) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;

      // Check if we should retry this type of error
      if (!shouldRetry(error)) {
        throw error;
      }

      if (attempt < maxRetries - 1) {
        // Calculate delay with jitter to prevent thundering herd
        const delay = Math.min(
          baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
          maxDelay
        );

        console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError;
}

// Usage with smart retry logic
const fetchWithSmartRetry = async (url) => {
  return retryWithBackoff(
    () => fetch(url).then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    }),
    5, // max retries
    1000, // base delay
    30000, // max delay
    (error) => {
      // Only retry on network errors or 5xx errors
      return !error.message.includes('HTTP 4');
    }
  );
};
  • Exponential backoff: 1s, 2s, 4s, 8s... prevents server overload
  • Jitter addition: Random delay prevents thundering herd
  • Selective retry: Don't retry 4xx errors (client errors)
  • Max delay cap: Prevents excessive wait times
  • Configurable predicate: Caller decides what's retryable
  • Last error preserved: Throw final error after all attempts

Request Deduplication & Caching

// Prevent duplicate requests for same data
class RequestCache {
  constructor(ttl = 60000) {
    this.cache = new Map();
    this.pending = new Map();
    this.ttl = ttl;
  }

  async fetch(key, fetchFn) {
    // Return cached if still fresh
    if (this.cache.has(key)) {
      const { data, timestamp } = this.cache.get(key);
      if (Date.now() - timestamp < this.ttl) {
        console.log(`Cache hit for ${key}`);
        return data;
      }
      this.cache.delete(key);
    }

    // Deduplicate concurrent requests
    if (this.pending.has(key)) {
      console.log(`Deduplicating request for ${key}`);
      return this.pending.get(key);
    }

    // Make the request
    const promise = fetchFn().then(data => {
      this.cache.set(key, { data, timestamp: Date.now() });
      this.pending.delete(key);
      return data;
    }).catch(error => {
      this.pending.delete(key);
      throw error;
    });

    this.pending.set(key, promise);
    return promise;
  }

  invalidate(pattern) {
    // Invalidate matching cache entries
    for (const key of this.cache.keys()) {
      if (key.includes(pattern)) {
        this.cache.delete(key);
      }
    }
  }
}

// Usage
const apiCache = new RequestCache(30000); // 30s TTL
const userData = await apiCache.fetch(
  `user-${userId}`,
  () => fetch(`/api/users/${userId}`).then(r => r.json())
);
  • Request deduplication: Multiple components requesting same data
  • Time-based cache: Fresh data within TTL window
  • Pending request tracking: Avoid duplicate in-flight requests
  • Cache invalidation: Clear stale data after mutations
  • Memory efficient: TTL prevents unbounded growth
  • React Query inspiration: Similar to popular caching libraries

Request Cancellation Patterns

// Modern request cancellation with AbortController
class CancellableRequest {
  constructor() {
    this.controllers = new Map();
  }

  async fetch(id, url, options = {}) {
    // Cancel previous request with same ID
    this.cancel(id);

    // Create new controller
    const controller = new AbortController();
    this.controllers.set(id, controller);

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();

    } catch (error) {
      if (error.name === 'AbortError') {
        console.log(`Request ${id} was cancelled`);
        return null;
      }
      throw error;
    } finally {
      this.controllers.delete(id);
    }
  }

  cancel(id) {
    const controller = this.controllers.get(id);
    if (controller) {
      controller.abort();
      this.controllers.delete(id);
    }
  }

  cancelAll() {
    for (const controller of this.controllers.values()) {
      controller.abort();
    }
    this.controllers.clear();
  }
}

// Usage: Search with automatic cancellation
const requests = new CancellableRequest();

async function search(query) {
  if (!query) return [];

  // Same ID cancels previous search
  return requests.fetch('search', `/api/search?q=${query}`);
}

// User types: "j" -> "ja" -> "jav" -> "java"
// Only final "java" request completes
  • AbortController API: Modern standard for cancellation
  • Automatic cancellation: New request cancels previous
  • Search optimization: Cancel outdated search requests
  • Component unmount: Cancel requests when component unmounts
  • Network efficiency: Don't waste bandwidth on unwanted data
  • Memory leak prevention: Cancelled requests can't update state

Polling and Long-Polling Patterns

// Smart polling with backoff and conditions
class SmartPoller {
  constructor(fn, options = {}) {
    this.fn = fn;
    this.interval = options.interval || 5000;
    this.maxInterval = options.maxInterval || 30000;
    this.shouldContinue = options.shouldContinue || (() => true);
    this.onError = options.onError || console.error;
    this.running = false;
    this.timer = null;
  }

  async start() {
    if (this.running) return;
    this.running = true;
    this.currentInterval = this.interval;

    const poll = async () => {
      if (!this.running) return;

      try {
        const result = await this.fn();

        // Check if we should continue polling
        if (!this.shouldContinue(result)) {
          this.stop();
          return;
        }

        // Reset interval on success
        this.currentInterval = this.interval;

      } catch (error) {
        this.onError(error);
        // Increase interval on error (backoff)
        this.currentInterval = Math.min(
          this.currentInterval * 1.5,
          this.maxInterval
        );
      }

      // Schedule next poll
      if (this.running) {
        this.timer = setTimeout(poll, this.currentInterval);
      }
    };

    // Start immediately
    poll();
  }

  stop() {
    this.running = false;
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }
}

// Usage: Poll for job completion
const jobPoller = new SmartPoller(
  () => fetch(`/api/jobs/${jobId}`).then(r => r.json()),
  {
    interval: 2000,
    maxInterval: 10000,
    shouldContinue: (job) => job.status === 'processing',
    onError: (err) => console.error('Poll failed:', err)
  }
);

jobPoller.start();
  • Smart polling: Adjusts frequency based on conditions
  • Error backoff: Reduces frequency when errors occur
  • Conditional stop: Stops when condition met (job complete)
  • Resource efficient: Increases interval to reduce server load
  • Graceful shutdown: Clean stop method for component unmount
  • Better than setInterval: Waits for response before next poll

Async Queue Management

// Control concurrent async operations
class AsyncQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
    this.results = new Map();
  }

  async add(id, asyncFn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ id, asyncFn, resolve, reject });
      this.process();
    });
  }

  async process() {
    if (this.running >= this.concurrency || this.queue.length === 0) {
      return;
    }

    this.running++;
    const { id, asyncFn, resolve, reject } = this.queue.shift();

    try {
      console.log(`Processing ${id}, queue size: ${this.queue.length}`);
      const result = await asyncFn();
      this.results.set(id, result);
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.running--;
      this.process(); // Process next item
    }
  }

  async drain() {
    while (this.queue.length > 0 || this.running > 0) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
  }
}

// Usage: Limit concurrent file uploads
const uploadQueue = new AsyncQueue(3); // Max 3 concurrent uploads

async function uploadFiles(files) {
  const uploads = files.map((file, index) =>
    uploadQueue.add(`file-${index}`, () => uploadFile(file))
  );

  const results = await Promise.all(uploads);
  console.log('All files uploaded:', results);
}
  • Concurrency control: Limit parallel operations
  • Queue management: Process items in order
  • Memory efficiency: Don't start all operations at once
  • Server protection: Avoid overwhelming backend
  • Progress tracking: Know queue size and completion
  • Use cases: File uploads, batch API calls, image processing

Summary: Advanced JavaScript Mastery

What You've Mastered Today

  • Async/await fundamentals: What it is, why it exists, and problems it solves
  • Common pitfalls: Forgetting await, sequential loops, missing error handling
  • Promise combinators: all, race, allSettled, any for complex scenarios
  • Error handling patterns: Retry logic, circuit breakers, custom errors
  • Production patterns: Request deduplication, cancellation, caching, polling, queues

Remember: These patterns prevent production failures and improve UX

Your Async Toolkit

  • Understand when to use async/await vs Promise chains
  • Choose the right Promise method for your use case
  • Always implement proper error handling with try/catch
  • Parallelize independent operations for performance
  • Consider retry logic with exponential backoff for network operations
  • Use request cancellation to prevent memory leaks
  • Implement caching to reduce server load