Dispatch
Technology·3 min read

JavaScript Async Patterns: Promises, Async/Await, and Beyond

Understanding asynchronous JavaScript is crucial for modern web development.

R

By Rahadul Islam

Share

JavaScript Async Patterns

Asynchronous programming is at the heart of JavaScript. Understanding async patterns is essential for building modern, responsive applications.

The Evolution of Async JavaScript

JavaScript's approach to async programming has evolved significantly:

  1. Callbacks - The original pattern
  2. Promises - A cleaner approach
  3. Async/Await - Syntactic sugar for promises
  4. Observables - For complex async streams

Callbacks

The original async pattern in JavaScript:

function fetchData(callback) {
  setTimeout(() => {
    const data = { name: 'John', age: 30 }
    callback(null, data)
  }, 1000)
}

fetchData((error, data) => {
  if (error) {
    console.error('Error:', error)
    return
  }
  console.log('Data:', data)
})

Callback Hell

Nested callbacks lead to the infamous "callback hell":

getData((err, a) => {
  if (err) return handleError(err)
  getMoreData(a, (err, b) => {
    if (err) return handleError(err)
    getEvenMoreData(b, (err, c) => {
      if (err) return handleError(err)
      // This gets hard to read quickly...
    })
  })
})

Promises

Promises provide a cleaner way to handle async operations:

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true
      if (success) {
        resolve({ name: 'John', age: 30 })
      } else {
        reject(new Error('Failed to fetch data'))
      }
    }, 1000)
  })
}

fetchData()
  .then(data => console.log('Data:', data))
  .catch(error => console.error('Error:', error))

Promise Chaining

Chain multiple async operations elegantly:

fetch('/api/user')
  .then(response => response.json())
  .then(user => fetch(`/api/posts/${user.id}`))
  .then(response => response.json())
  .then(posts => console.log('Posts:', posts))
  .catch(error => console.error('Error:', error))

Promise.all

Execute multiple promises in parallel:

const promises = [
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
]

Promise.all(promises)
  .then(responses => Promise.all(responses.map(r => r.json())))
  .then(([users, posts, comments]) => {
    console.log({ users, posts, comments })
  })

Async/Await

The modern way to write async code:

async function fetchUserData() {
  try {
    const response = await fetch('/api/user')
    const user = await response.json()

    const postsResponse = await fetch(`/api/posts/${user.id}`)
    const posts = await postsResponse.json()

    return { user, posts }
  } catch (error) {
    console.error('Error fetching data:', error)
    throw error
  }
}

Parallel Execution with Async/Await

Don't await sequentially when you can parallelize:

// ❌ Sequential - Slow
async function fetchSequential() {
  const users = await fetch('/api/users').then(r => r.json())
  const posts = await fetch('/api/posts').then(r => r.json())
  return { users, posts }
}

// ✅ Parallel - Fast
async function fetchParallel() {
  const [users, posts] = await Promise.all([
    fetch('/api/users').then(r => r.json()),
    fetch('/api/posts').then(r => r.json())
  ])
  return { users, posts }
}

Error Handling

Pattern Pros Cons try/catch Clean, familiar Can't catch errors outside async scope .catch() Chainable Can get verbose Error boundaries Great for React Component-level only

Best Practice: Always Handle Errors

async function safeAsync(asyncFn) {
  try {
    const result = await asyncFn()
    return [result, null]
  } catch (error) {
    return [null, error]
  }
}

// Usage
const [data, error] = await safeAsync(() => fetchData())
if (error) {
  console.error('Failed:', error)
} else {
  console.log('Success:', data)
}

Advanced Patterns

Debouncing Async Functions

function debounceAsync(fn, delay) {
  let timeoutId = null

  return function(...args) {
    return new Promise((resolve) => {
      if (timeoutId) clearTimeout(timeoutId)

      timeoutId = setTimeout(async () => {
        const result = await fn.apply(this, args)
        resolve(result)
      }, delay)
    })
  }
}

const debouncedSearch = debounceAsync(searchAPI, 300)

Retry with Exponential Backoff

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      if (attempt === maxRetries - 1) throw error

      const delay = baseDelay * Math.pow(2, attempt)
      console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`)
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
}

Conclusion

Mastering async patterns is essential for JavaScript developers. Remember:

  • Use async/await for cleaner, more readable code
  • Use Promise.all for parallel operations
  • Always handle errors appropriately
  • Consider retry logic for unreliable operations
  • Debounce user-triggered async operations

The key to async JavaScript is understanding that your code doesn't execute linearly. Embrace the event loop!

Share

More from Technology