The Quest Begins (The "Why")
Picture this: I’m deep in a late‑night debugging session, staring at a wall of .then() chains that look like a spilled bowl of alphabet soup. I’m trying to fetch user data, enrich it with preferences, then push the result to a caching layer—all while keeping the UI responsive. Every time I think I’ve got it, a rogue Promise slips through the cracks and throws an unhandled rejection that crashes the whole thing. I felt like Neo in the Matrix, dodging bullets but still getting hit by the ones I couldn’t see. Honestly, I was ready to throw my keyboard out the window and go back to callbacks—if only they didn’t make my code look like a nightmare.
That frustration sparked a quest: find the hidden async/await tricks that most tutorials gloss over, the kind that turn a tangled Promise jungle into a clean, readable trail. I dug into the spec, played with Node’s REPL, and eventually uncovered a few gems that saved me hours (and a lot of gray hairs). Let’s share those treasures.
The Revelation (The Insight)
1. Top‑level await – the “press start” button for modules
Most of us think await can only live inside an async function. That’s true for scripts, but ES2022 gave us top‑level await in ECMAScript modules. It lets you write asynchronous initialization code at the top level of a file, just like you would in a synchronous script—without wrapping everything in an IIFE.
Gotcha: It only works when the file is treated as a module (type="module" in browsers or .mjs/"type":"module" in package.json). If you try it in a regular .js script, you’ll get a SyntaxError that feels like a boss fight you didn’t see coming.
Why it matters: No more (() => { … })() wrappers just to fetch config or seed a database. Your module can start up cleanly, and any importer gets a fully resolved value automatically.
2. for await … of – looping over async iterators like a pro
We all know for … of for arrays, but few realize it works with async iterators—objects that implement [Symbol.asyncIterator](). When you prefix the loop with await, each iteration waits for the next promised value before moving on. It’s perfect for reading streams, paginated APIs, or any source that yields data over time.
Gotcha: Forgetting to make the iterator actually async (i.e., returning a normal iterator instead of a promise‑yielding one) leads to a silent loop that never yields. Also, remember that break or return inside the loop will exit early, leaving the iterator’s return() method untouched—potentially leaking resources if you don’t clean up.
Why it matters: Instead of chaining .then() or recursively calling a function to get the next page, you write a clean, synchronous‑looking loop that handles back‑pressure naturally.
3. Promise.allSettled and Promise.any – the safety net and the optimist
Promise.all is the go-to for running many async tasks in parallel, but it fails fast: one rejection tanks the whole thing. That’s often too harsh. Enter Promise.allSettled (waits for every promise to settle, regardless of outcome) and Promise.any (resolves as soon as any promise fulfills, ignoring rejections unless all reject).
Gotcha: With Promise.any, if every input promise rejects, you get an AggregateError containing all the rejection reasons—not a single error. Many devs expect a plain error and get surprised when they have to inspect the .errors property. Also, Promise.allSettled returns an array of objects with status ("fulfilled" or "rejected"), which requires a tiny bit more handling than the simple values array from Promise.all.
Why it matters: You can now build resilient aggregations—think dashboard widgets that should show whatever data they can get, or a file uploader that proceeds as soon as one chunk succeeds, while still logging the rest for later inspection.
Wielding the Power (Code & Examples)
Before: The “spaghetti” approach
// Fetch user, then preferences, then cache – classic .then() hell
fetchUser(userId)
.then(user => {
return fetchPreferences(user.id)
.then(prefs => ({ user, prefs }));
})
.then(({ user, prefs }) => {
return enrichUser(user, prefs);
})
.then(enriched => {
return cache.set(`user:${userId}`, enriched);
})
.catch(err => {
console.error('Something went wrong:', err);
});
After: Top‑level await + async/await (module)
// userService.mjs – treated as an ES module
import { fetchUser, fetchPreferences, enrichUser, cache } from './utils.js';
// Top‑level await runs as soon as the module is imported
const userId = getCurrentUserId(); // sync helper
const user = await fetchUser(userId);
const prefs = await fetchPreferences(user.id);
const enriched = await enrichUser(user, prefs);
await cache.set(`user:${userId}`, enriched);
// Export the enriched user for anyone who needs it
export default enriched;
No wrapping function, no extra indentation—just straight‑line code that reads like a recipe.
Before: Manual pagination loop
let page = 1;
let allItems = [];
function fetchPage() {
return fetch(`/api/items?page=${page}`).then(res => res.json());
}
fetchPage()
.then(data => {
allItems = [...allItems, ...data.items];
if (data.hasMore) {
page++;
return fetchPage();
}
return allItems;
})
.then(console.log)
.catch(console.err);
After: for await … of with an async generator
async function* paginatedFetch() {
let page = 1;
while (true) {
const res = await fetch(`/api/items?page=${page}`);
const data = await res.json();
yield data.items; // each yielded value is a promise‑resolved array
if (!data.hasMore) break;
page++;
}
}
// Usage – looks sync, but waits under the hood
(async () => {
const allItems = [];
for await (const chunk of paginatedFetch()) {
allItems.push(...chunk);
}
console.log(allItems);
})();
The loop feels natural, yet each iteration respects network latency without blocking the event loop.
Before: Promise.all that fails on the first error
Promise.all([
fetch('/api/user'),
fetch('/api/prefs'),
fetch('/api/settings')
])
.then(([userResp, prefsResp, settingsResp]) => {
// …
})
.catch(err => {
// One network hiccup kills the whole thing
console.error('Failed:', err);
});
After: Promise.allSettled for tolerant aggregation
const results = await Promise.allSettled([
fetch('/api/user'),
fetch('/api/prefs'),
fetch('/api/settings')
]);
const usable = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value); // extract Response objects
// Process only the successful calls, log the rest for diagnostics
results
.filter(r => r.status === 'rejected')
.forEach(r => console.warn('One request failed:', r.reason));
Now a single flaky endpoint doesn’t wipe out your whole dashboard.
After: Promise.any for optimistic shortcuts
try {
const first = await Promise.any([
fetch('/api/primary').then(r => r.json()),
fetch('/api/fallback1').then(r => r.json()),
fetch('/api/fallback2').then(r => r.json())
]);
console.log('Got data from the fastest source:', first);
} catch (err) {
// err is an AggregateError only if *all* three failed
if (err instanceof AggregateError) {
console.error('All sources failed:', err.errors);
}
}
You get the first successful response, and you still have a clean way to handle total failure.
Why This New Power Matters
Mastering these patterns does more than shave a few lines off your file—it changes how you think about asynchronous code. You start seeing async flow as a linear story rather than a tangled web of callbacks. Top‑level await lets you treat modules like scripts, making setup code obvious and testable. for await … of turns streams and paginated APIs into something you can read like a for loop, cutting down on recursion bugs. And the newer Promise.allSettled / Promise.any give you fine‑grained control over failure modes, so your apps stay resilient even when the network decides to be moody.
When you wield these tools, you spend less time debugging “why is this Promise hanging?” and more time building features that delight users. You become the developer who can glance at a file and instantly grasp the async narrative—just like Neo seeing the Matrix’s code.
Your Turn: The Challenge
Pick one part of your codebase that still relies on a chain of .then() or a manual pagination loop. Refactor it using one of the patterns above—maybe top‑level await for a config loader, or for await … of for fetching a list of blog posts from a paginated API. Share your before/after snippets in the comments (or on Twitter) and let’s see who can turn the most spaghetti into the cleanest async noodle soup!
Happy coding, and may your awaits always be resolved! 🚀
Top comments (0)