Async/Await Best Practices and Common Pitfalls
Three years ago, I shipped a feature that brought down our production API. The cause? A forgotten await in a critical database query. The query returned a Promise, I treated it like data, and chaos ensued.
That bug taught me async/await isn't just syntax sugar—it's a fundamental shift in how JavaScript handles concurrency. Here are the lessons I wish I'd learned before that incident.
The Mental Model That Changed Everything
Here's what finally made async/await click for me: async functions always return Promises, and await pauses execution until a Promise resolves.
That's it. But the implications are huge:
// These are identical
async function getUser() {
return { id: 1, name: "John" };
}
function getUser() {
return Promise.resolve({ id: 1, name: "John" });
}
When you write async, you're opting into Promise-land. Even if you return a plain value, it gets wrapped in a Promise. This is why forgotten await keywords are so dangerous—you get a Promise where you expected data.
AI debugging tip: When I hit a weird async bug, I paste the function into Claude/GPT and ask: "Why is this async function not working as expected?" 90% of the time it's a missing await or incorrect error handling.
Error Handling: The #1 Source of Production Bugs
Poor error handling in async code has caused more outages than I care to admit. Here's what I do now:
Always Use Try/Catch
This seems obvious, but I see it violated constantly:
// ✗ Unhandled promise rejection waiting to happen
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// ✓ Handle errors explicitly
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
} catch (error) {
console.error("Failed to fetch user:", error);
throw error; // Re-throw or handle appropriately
}
}
Real talk: In 2025, tools like TypeScript with strict null checks and ESLint's no-floating-promises rule catch many of these issues. But you still need to think about error boundaries.
Centralized Error Handling
For complex operations, handle errors at the appropriate level:
async function loadUserDashboard(userId) {
try {
const [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
return { user, posts, notifications };
} catch (error) {
logError(error);
showUserMessage("Couldn't load dashboard. Please try again.");
return null;
}
}
This pattern prevents error handling boilerplate in every sub-function. The top-level function owns error recovery.
AI workflow: When I'm unsure where to handle errors, I ask: "Where should I add try/catch blocks in this async function chain?" The AI suggests a strategy, I evaluate it against my app's error recovery needs.
Performance: Sequential vs Parallel Execution
This is the mistake that costs the most performance. I see it in code reviews constantly.
The Problem: Accidental Sequential Execution
// ✗ SLOW - waits 600ms total
async function loadData() {
const users = await fetchUsers(); // 200ms
const posts = await fetchPosts(); // 200ms
const comments = await fetchComments(); // 200ms
return { users, posts, comments };
}
These three fetches have no dependencies. They should run in parallel.
The Solution: Promise.all()
// ✓ FAST - waits 200ms total
async function loadData() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
return { users, posts, comments };
}
Real-world impact: I optimized a dashboard that loaded in 3.2 seconds down to 0.8 seconds just by converting sequential fetches to Promise.all(). Same data, 4x faster.
When Sequential Is Correct
Sometimes you need sequential execution:
async function createUserAndProfile(userData) {
const user = await createUser(userData); // Must finish first
const profile = await createProfile(user.id, userData.profile); // Needs user.id
return { user, profile };
}
The second call depends on the first. Sequential is correct here.
Rule of thumb: If operations don't depend on each other's results, use Promise.all().
Modern Patterns for 2025
Promise.allSettled() for Partial Failures
Sometimes you want all results, even if some fail:
async function loadDashboard() {
const results = await Promise.all Settled([
fetchUsers(),
fetchPosts(),
fetchAnalytics(),
]);
const users = results[0].status === "fulfilled" ? results[0].value : [];
const posts = results[1].status === "fulfilled" ? results[1].value : [];
const analytics = results[2].status === "fulfilled" ? results[2].value : null;
return { users, posts, analytics };
}
If analytics fail, you still get users and posts. Much better UX than showing an error for everything.
Top-Level Await (ES2022)
In modules, you can now await at the top level:
// config.js
const config = await fetch('/api/config').then(r => r.json());
export default config;
This is huge for initialization code. No more IIFE wrappers or deferred loading patterns.
Async Iterators
For streaming data:
async function* fetchPages(url) {
let page = 1;
while (true) {
const response = await fetch(`${url}?page=${page}`);
const data = await response.json();
if (data.length === 0) break;
yield data;
page++;
}
}
// Usage
for await (const page of fetchPages('/api/items')) {
console.log('Got page:', page);
}
I use this for paginated APIs, log streaming, and any "process data as it arrives" scenario.
Common Pitfalls (And How I Debug Them)
Pitfall 1: Forgetting await
// ✗ Returns Promise<User>, not User
async function getUser(id) {
const user = fetchUser(id); // Missing await!
return user.name; // TypeError: Cannot read property 'name' of Promise
}
How I catch this: ESLint rule @typescript-eslint/no-floating-promises with TypeScript. It yells at you immediately.
AI debugging: Paste the error and ask: "Why am I getting 'Cannot read property of Promise'?" The AI spots the missing await instantly.
Pitfall 2: Async Functions in Array Methods
// ✗ Doesn't work as expected
async function processUsers(users) {
users.forEach(async (user) => {
await updateUser(user); // forEach doesn't wait for promises
});
console.log("Done!"); // Logs before updates finish
}
// ✓ Use for...of
async function processUsers(users) {
for (const user of users) {
await updateUser(user);
}
console.log("Done!"); // Logs after all updates
}
// ✓ Or Promise.all for parallel
async function processUsers(users) {
await Promise.all(users.map(user => updateUser(user)));
console.log("Done!");
}
forEach, map, filter don't understand async. Use for...of or Promise.all().
Pitfall 3: Mixing Promises and Async/Await
Pick one style and stick with it:
// ✗ Confusing mix
async function getData() {
return fetch("/api/data")
.then(response => response.json())
.then(data => processData(data));
}
// ✓ Consistent async/await
async function getData() {
const response = await fetch("/api/data");
const data = await response.json();
return processData(data);
}
Mixing styles makes error handling unpredictable and confuses readers.
Using AI for Async Code
Here's my workflow in 2025:
- Write the logic: Get it working, even if inefficient
- Profile: Check if sequential fetches are slowing things down
- Ask AI for optimization: "Can I parallelize these async operations?"
- Review suggestions: Make sure dependencies are preserved
- Test error cases: Ensure failures are handled gracefully
Prompts I use regularly:
- "Convert this sequential async code to use Promise.all where possible"
- "Add proper error handling to this async function"
- "Why is this async function returning a Promise instead of the value?"
- "Help me debug this race condition in my async code"
The AI catches patterns I miss (especially forgotten await), but I own the error handling strategy.
Advanced Patterns
Retry with Exponential Backoff
async function retryWithBackoff(fn, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
console.log(`Retry attempt ${attempt} in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const data = await retryWithBackoff(() => fetchData());
Essential for dealing with flaky APIs or rate limits.
Debounced Async Functions
function debounceAsync(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
return new Promise((resolve, reject) => {
timeoutId = setTimeout(async () => {
try {
resolve(await fn.apply(this, args));
} catch (error) {
reject(error);
}
}, delay);
});
};
}
// Usage
const debouncedSearch = debounceAsync(searchAPI, 300);
const results = await debouncedSearch(query);
Perfect for search inputs, auto-save, any user-triggered async action.
Testing Async Code
In 2025, testing tools handle async natively:
// Jest/Vitest
test("fetches user data", async () => {
const user = await fetchUser(1);
expect(user.name).toBe("John");
});
// With mocks
test("handles errors", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
await expect(fetchUser(1)).rejects.toThrow("Network error");
});
AI-assisted testing: I ask: "Write tests for this async function including error cases" and the AI generates comprehensive test cases. I review them, add edge cases it missed, done.
Wrapping Up
Async/await transformed JavaScript, but it's easy to shoot yourself in the foot. The patterns that matter:
- Always handle errors with try/catch
- Parallelize independent operations with Promise.all()
- Use for...of for async operations in loops
- Let AI catch missing awaits but own your error strategy
- Profile before optimizing - measure actual performance impact
The biggest mindset shift? Async functions always return Promises. Internalize that, and half the bugs disappear.
Related posts: Check out my guides on JavaScript array methods for handling async in map/filter, and TypeScript patterns for typing async functions.
Building React apps with async data? Read my post on type safety in React for patterns that catch async bugs at compile time.