Skip to main content

JavaScript Async Programming

This guide covers everything you need to know about asynchronous programming in JavaScript, from basic promises to advanced event loop concepts and Node.js specific timing functions.

Basic Async/Await Patterns

Description: The foundation of modern JavaScript async programming. Async/await provides a cleaner syntax for working with promises, making asynchronous code look and behave more like synchronous code.

When to use: Always prefer async/await over raw promises for better readability and error handling. Use when you need to perform operations that don't block the main thread.

Key benefits:

  • Cleaner, more readable code
  • Better error handling with try/catch
  • Easier debugging and stack traces
  • Sequential execution by default

Basic Syntax

// Basic async function
async function fetchUserData() {
try {
const response = await fetch('/api/user');
const user = await response.json();
return user;
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
}

// Async arrow function
const fetchUserData = async () => {
const response = await fetch('/api/user');
return response.json();
};

// Using async functions
async function main() {
const user = await fetchUserData();
console.log(user);
}

Error Handling

// Proper error handling with async/await
async function safeOperation() {
try {
const result = await riskyOperation();
return result;
} catch (error) {
// Handle specific error types
if (error.name === 'NetworkError') {
console.log('Network issue, retrying...');
return await retryOperation();
}
throw error; // Re-throw if we can't handle it
}
}

// Multiple async operations with error handling
async function processMultiple() {
try {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
]);
return { users, posts };
} catch (error) {
console.error('One or more operations failed:', error);
return { users: [], posts: [] };
}
}

Promise Fundamentals

Description: Promises represent the eventual completion (or failure) of an asynchronous operation. They provide a way to handle asynchronous results and chain operations together.

When to use: When working with legacy code, creating custom async operations, or when you need fine-grained control over async behavior.

Key benefits:

  • Standardized way to handle async operations
  • Built-in error handling
  • Chainable operations
  • Can be used with async/await

Creating Promises

// Creating a promise
const myPromise = new Promise((resolve, reject) => {
// Async operation
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve('Operation completed successfully');
} else {
reject(new Error('Operation failed'));
}
}, 1000);
});

// Promise constructor with async operation
const fetchData = (url) => {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => resolve(data))
.catch(error => reject(error));
});
};

// Converting callback-based code to promises
const readFile = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
};

Promise Methods

// Promise.all - wait for all promises to resolve
const loadAllData = async () => {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments };
};

// Promise.allSettled - wait for all promises to settle (resolve or reject)
const loadDataWithFallbacks = async () => {
const results = await Promise.allSettled([
fetchUsers(),
fetchPosts(),
fetchComments()
]);

return results.map(result =>
result.status === 'fulfilled' ? result.value : null
);
};

// Promise.race - return first promise to settle
const timeoutPromise = (promise, timeout) => {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
};

// Promise.any - return first promise to fulfill (not reject)
const loadFromMultipleSources = async () => {
const result = await Promise.any([
fetchFromSource1(),
fetchFromSource2(),
fetchFromSource3()
]);
return result;
};

Advanced Promise Patterns

Description: Advanced patterns for complex async scenarios including retry logic, caching, and sophisticated error handling.

When to use: When building robust applications that need to handle network failures, implement caching strategies, or manage complex async workflows.

Key benefits:

  • Improved reliability and user experience
  • Better resource management
  • Sophisticated error recovery
  • Performance optimization

Retry Logic

// Retry with exponential backoff
const retryWithBackoff = async (fn, maxRetries = 3, baseDelay = 1000) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;

const delay = baseDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
};

// Usage
const fetchWithRetry = () => retryWithBackoff(
() => fetch('/api/data').then(r => r.json()),
3,
1000
);

Promise Caching

// Simple promise cache
const cache = new Map();

const cachedFetch = async (url) => {
if (cache.has(url)) {
return cache.get(url);
}

const promise = fetch(url).then(r => r.json());
cache.set(url, promise);
return promise;
};

// Cache with expiration
class PromiseCache {
constructor(ttl = 60000) { // 1 minute default
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.value;
}

const value = await fetcher();
this.cache.set(key, { value, timestamp: Date.now() });
return value;
}
}

const userCache = new PromiseCache(300000); // 5 minutes
const getUser = (id) => userCache.get(`user-${id}`, () => fetchUser(id));

Sequential vs Parallel Execution

// Sequential execution (when order matters)
const processSequentially = async (items) => {
const results = [];
for (const item of items) {
const result = await processItem(item);
results.push(result);
}
return results;
};

// Parallel execution (when order doesn't matter)
const processInParallel = async (items) => {
const promises = items.map(item => processItem(item));
return await Promise.all(promises);
};

// Controlled concurrency (limit parallel operations)
const processWithConcurrency = async (items, concurrency = 3) => {
const results = [];
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
results.push(...batchResults);
}
return results;
};

Event Loop and Execution Order

Description: Understanding the JavaScript event loop is crucial for predicting execution order and avoiding common pitfalls in async code.

When to use: When debugging timing issues, optimizing performance, or understanding why certain code executes in unexpected order.

Key benefits:

  • Predictable code execution
  • Better performance optimization
  • Avoidance of common async bugs
  • Understanding of JavaScript runtime behavior

Event Loop Phases

// Understanding execution order
console.log('1. Start');

setTimeout(() => {
console.log('2. setTimeout callback');
}, 0);

Promise.resolve().then(() => {
console.log('3. Promise microtask');
});

console.log('4. End');

// Output order:
// 1. Start
// 4. End
// 3. Promise microtask
// 2. setTimeout callback

Microtasks vs Macrotasks

// Microtasks (Promise callbacks, queueMicrotask)
console.log('1. Script start');

setTimeout(() => {
console.log('2. setTimeout');
}, 0);

Promise.resolve().then(() => {
console.log('3. Promise 1');
}).then(() => {
console.log('4. Promise 2');
});

queueMicrotask(() => {
console.log('5. queueMicrotask');
});

console.log('6. Script end');

// Output order:
// 1. Script start
// 6. Script end
// 3. Promise 1
// 4. Promise 2
// 5. queueMicrotask
// 2. setTimeout

Common Event Loop Pitfalls

// ❌ Blocking the event loop
const blockingOperation = () => {
const start = Date.now();
while (Date.now() - start < 1000) {
// This blocks the main thread for 1 second
}
};

// ✅ Non-blocking alternative
const nonBlockingOperation = () => {
return new Promise(resolve => {
setTimeout(resolve, 1000);
});
};

// ❌ Infinite microtask loop
const infiniteMicrotask = () => {
Promise.resolve().then(infiniteMicrotask);
};

// ✅ Proper async recursion
const properAsyncRecursion = async () => {
await new Promise(resolve => setTimeout(resolve, 0));
return properAsyncRecursion();
};

Node.js Specific Timing Functions

Description: Node.js provides additional timing functions beyond the standard setTimeout and setInterval. Understanding these is crucial for Node.js development.

When to use: When building Node.js applications, especially when you need precise control over timing or need to defer operations to the next event loop iteration.

Key benefits:

  • Better performance in Node.js environments
  • More precise timing control
  • Proper integration with Node.js event loop
  • Avoiding timing-related bugs

process.nextTick()

Description: Schedules a callback to be executed in the next iteration of the event loop, before any I/O operations or timers.

When to use: When you need to ensure a callback runs after the current operation completes but before the event loop continues.

console.log('1. Start');

setTimeout(() => {
console.log('2. setTimeout');
}, 0);

process.nextTick(() => {
console.log('3. nextTick');
});

Promise.resolve().then(() => {
console.log('4. Promise');
});

console.log('5. End');

// Output order:
// 1. Start
// 5. End
// 3. nextTick
// 4. Promise
// 2. setTimeout

setImmediate()

Description: Schedules a callback to be executed in the next iteration of the event loop, after I/O operations but before timers.

When to use: When you want to defer execution to the next event loop iteration, similar to setTimeout with 0 delay but with different timing characteristics.

console.log('1. Start');

setTimeout(() => {
console.log('2. setTimeout');
}, 0);

setImmediate(() => {
console.log('3. setImmediate');
});

process.nextTick(() => {
console.log('4. nextTick');
});

console.log('5. End');

// Output order:
// 1. Start
// 5. End
// 4. nextTick
// 3. setImmediate
// 2. setTimeout

Timing Function Comparison

// Understanding the differences
const compareTiming = () => {
console.log('=== Timing Function Comparison ===');

// 1. Synchronous code
console.log('1. Synchronous code');

// 2. process.nextTick - highest priority
process.nextTick(() => {
console.log('2. process.nextTick');
});

// 3. Promise microtasks
Promise.resolve().then(() => {
console.log('3. Promise microtask');
});

// 4. queueMicrotask
queueMicrotask(() => {
console.log('4. queueMicrotask');
});

// 5. setImmediate - check phase
setImmediate(() => {
console.log('5. setImmediate');
});

// 6. setTimeout - timer phase
setTimeout(() => {
console.log('6. setTimeout');
}, 0);

console.log('7. End of synchronous code');
};

compareTiming();

Practical Node.js Examples

// Using process.nextTick for error handling
const readFileAsync = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, 'utf8', (err, data) => {
if (err) {
// Use nextTick to ensure error is thrown in the next iteration
process.nextTick(() => reject(err));
} else {
process.nextTick(() => resolve(data));
}
});
});
};

// Using setImmediate for non-blocking operations
const processLargeArray = (array) => {
return new Promise((resolve) => {
const results = [];
let index = 0;

const processChunk = () => {
const chunk = array.slice(index, index + 1000);
results.push(...chunk.map(item => item * 2));
index += 1000;

if (index < array.length) {
setImmediate(processChunk);
} else {
resolve(results);
}
};

setImmediate(processChunk);
});
};

// Combining timing functions for optimal performance
const optimizedAsyncOperation = async () => {
// Use nextTick for immediate cleanup
process.nextTick(() => {
console.log('Cleanup completed');
});

// Use setImmediate for non-critical operations
setImmediate(() => {
console.log('Non-critical operation completed');
});

// Use setTimeout for operations that can wait
setTimeout(() => {
console.log('Deferred operation completed');
}, 100);
};

Common Async Patterns and Best Practices

Description: Proven patterns and best practices for writing robust, maintainable async code in JavaScript.

When to use: When building production applications, refactoring existing code, or learning industry-standard async patterns.

Key benefits:

  • More reliable applications
  • Better error handling
  • Improved performance
  • Easier maintenance and debugging

Async Function Patterns

// Async function with proper error boundaries
const safeAsyncOperation = async (operation) => {
try {
const result = await operation();
return { success: true, data: result };
} catch (error) {
console.error('Operation failed:', error);
return { success: false, error: error.message };
}
};

// Async function with timeout
const withTimeout = async (promise, timeoutMs) => {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Operation timed out')), timeoutMs);
});

return Promise.race([promise, timeoutPromise]);
};

// Async function with retry logic
const withRetry = async (operation, maxRetries = 3) => {
let lastError;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === maxRetries) break;

// Wait before retrying (exponential backoff)
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}

throw lastError;
};

Promise Utility Functions

// Delay utility
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

// Retry utility with custom logic
const retry = async (fn, { maxAttempts = 3, delayMs = 1000, backoff = true } = {}) => {
let lastError;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) break;

const waitTime = backoff ? delayMs * Math.pow(2, attempt - 1) : delayMs;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}

throw lastError;
};

// Promise pool for limiting concurrency
class PromisePool {
constructor(maxConcurrency = 5) {
this.maxConcurrency = maxConcurrency;
this.running = 0;
this.queue = [];
}

async add(fn) {
if (this.running >= this.maxConcurrency) {
await new Promise(resolve => this.queue.push(resolve));
}

this.running++;
try {
return await fn();
} finally {
this.running--;
if (this.queue.length > 0) {
this.queue.shift()();
}
}
}
}

// Usage
const pool = new PromisePool(3);
const results = await Promise.all([
pool.add(() => fetch('/api/1')),
pool.add(() => fetch('/api/2')),
pool.add(() => fetch('/api/3')),
pool.add(() => fetch('/api/4')),
pool.add(() => fetch('/api/5'))
]);

Error Handling Patterns

// Centralized error handling
const handleAsyncError = (error, context = '') => {
console.error(`Error in ${context}:`, error);

// Log to external service
if (process.env.NODE_ENV === 'production') {
// sendToErrorService(error, context);
}

// Return user-friendly error
return {
error: true,
message: 'Something went wrong. Please try again.',
code: error.code || 'UNKNOWN_ERROR'
};
};

// Async wrapper with error handling
const withErrorHandling = (asyncFn) => {
return async (...args) => {
try {
return await asyncFn(...args);
} catch (error) {
return handleAsyncError(error, asyncFn.name);
}
};
};

// Usage
const safeFetchUser = withErrorHandling(async (userId) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
});

Performance Optimization

Description: Techniques for optimizing async code performance, including batching, caching, and efficient resource management.

When to use: When building high-performance applications, optimizing existing async code, or dealing with large datasets.

Key benefits:

  • Faster application response times
  • Reduced resource usage
  • Better user experience
  • Scalable architecture

Batching and Debouncing

// Debounce utility
const debounce = (fn, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};

// Throttle utility
const throttle = (fn, delay) => {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
fn(...args);
}
};
};

// Batch processing
const batchProcess = async (items, batchSize = 10, processor) => {
const results = [];

for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(item => processor(item))
);
results.push(...batchResults);
}

return results;
};

Memory Management

// WeakMap for caching with automatic cleanup
const cache = new WeakMap();

const cachedOperation = async (key, operation) => {
if (cache.has(key)) {
return cache.get(key);
}

const result = await operation();
cache.set(key, result);
return result;
};

// Async generator for memory-efficient processing
async function* asyncGenerator(items) {
for (const item of items) {
const processed = await processItem(item);
yield processed;
}
}

// Usage
const processLargeDataset = async (items) => {
const results = [];
for await (const result of asyncGenerator(items)) {
results.push(result);
}
return results;
};

This comprehensive guide covers all essential aspects of JavaScript async programming, from basic patterns to advanced concepts and Node.js-specific features. Use these patterns and examples to write more robust, performant, and maintainable async code.