JavaScript Async/Await Patterns That Make Your Code Better
Learn practical async/await patterns in JavaScript. Real examples of error handling, parallel requests, sequential operations, and race conditions.
Async/await makes JavaScript async code look synchronous. Here's how to use it properly and avoid common pitfalls.
Basic Error Handling
Stop wrapping every async call in try/catch. Use this instead:
async function handleAsync(promise) {
try {
const data = await promise;
return [data, null];
} catch (error) {
return [null, error];
}
}
// Usage
const [data, error] = await handleAsync(fetch('/api/users'));
if (error) {
console.error('Failed:', error.message);
return;
}
Parallel Requests
Don't wait for one request to finish before starting another.
// Bad - Sequential requests
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
// Good - Parallel requests
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
Handle errors in parallel requests:
const promises = [fetchUsers(), fetchPosts(), fetchComments()];
const results = await Promise.allSettled(promises);
const data = results.map(result => {
if (result.status === 'fulfilled') {
return result.value;
}
console.error('Failed:', result.reason);
return null;
});
Timeouts
Don't let requests hang forever:
function timeout(promise, ms = 5000) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
)
]);
}
// Usage
try {
const data = await timeout(fetch('/api/slowEndpoint'));
} catch (error) {
if (error.message === 'Timeout') {
console.log('Request took too long');
}
}
Loading States
Handle loading states cleanly:
async function fetchWithLoading(fetchFn) {
let isLoading = true;
try {
const result = await fetchFn();
return result;
} finally {
isLoading = false;
}
}
// React example
function UserList() {
const [isLoading, setLoading] = useState(false);
const [users, setUsers] = useState([]);
async function loadUsers() {
setLoading(true);
try {
const data = await fetch('/api/users');
setUsers(await data.json());
} finally {
setLoading(false);
}
}
}
Retrying Failed Requests
When APIs are flaky, retry with backoff:
async function fetchWithRetry(url, options = {}) {
const { retries = 3, backoff = 300 } = options;
for (let i = 0; i < retries; i++) {
try {
return await fetch(url);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve =>
setTimeout(resolve, backoff * Math.pow(2, i))
);
}
}
}
// Usage
const data = await fetchWithRetry('/api/unstable-endpoint', {
retries: 3,
backoff: 500 // 500ms, 1000ms, 2000ms
});
Sequential Operations
When order matters:
async function processInOrder(items) {
const results = [];
for (const item of items) {
// Using for...of preserves async/await
const result = await processItem(item);
results.push(result);
}
return results;
}
// With array methods - harder to read but sometimes needed
const results = await items.reduce(async (promise, item) => {
const results = await promise;
const result = await processItem(item);
return [...results, result];
}, Promise.resolve([]));
Debouncing Async Calls
Perfect for search inputs:
function debounce(fn, delay = 300) {
let timeout;
return (...args) => {
clearTimeout(timeout);
return new Promise(resolve => {
timeout = setTimeout(() => resolve(fn(...args)), delay);
});
};
}
// Usage
const searchApi = async (term) => {
const response = await fetch(`/api/search?q=${term}`);
return response.json();
};
const debouncedSearch = debounce(searchApi, 500);
// In your input handler
input.addEventListener('input', async (e) => {
const results = await debouncedSearch(e.target.value);
displayResults(results);
});
Cache Results
Don't call the same API multiple times:
const cache = new Map();
async function fetchWithCache(url, options = {}) {
const { ttl = 60000 } = options; // Default 1 minute
const cached = cache.get(url);
if (cached?.timestamp > Date.now() - ttl) {
return cached.data;
}
const data = await fetch(url).then(r => r.json());
cache.set(url, {
timestamp: Date.now(),
data
});
return data;
}
// Usage
const data = await fetchWithCache('/api/frequently-accessed', {
ttl: 5 * 60000 // 5 minutes
});
Race Conditions
Handle out-of-order responses:
function createAsyncSequence() {
let currentRequest = 0;
return async function request(url) {
const thisRequest = ++currentRequest;
const response = await fetch(url);
// Ignore if newer request has started
if (thisRequest !== currentRequest) {
return null;
}
return response.json();
};
}
// Usage in search
const sequentialSearch = createAsyncSequence();
input.addEventListener('input', async (e) => {
const results = await sequentialSearch(
`/api/search?q=${e.target.value}`
);
if (results) {
displayResults(results);
}
});
These patterns work great together. Here's a real-world example combining several:
async function userApi() {
const cache = new Map();
const sequence = createAsyncSequence();
return {
async search(term) {
// Combine caching, sequence, timeout and error handling
const cached = cache.get(term);
if (cached?.timestamp > Date.now() - 30000) {
return cached.data;
}
const [data, error] = await handleAsync(
sequence(
timeout(
fetch(`/api/users/search?q=${term}`)
)
)
);
if (error) return [];
const results = await data.json();
cache.set(term, {
timestamp: Date.now(),
data: results
});
return results;
}
};
}