The Quest Begins (The "Why")
I still remember the first time I tried to fetch a user’s profile, then their posts, and finally the comments on each post. I ended up nesting .then callbacks like a set of Russian dolls, and after the third level I felt like I was lost in the Inception dream‑within‑a‑dream scene—except the only thing spinning was my sanity. Debugging a missed await was a nightmare; an unhandled promise rejection would pop up in the console like a jump‑scare in a horror movie, and I’d spend hours tracing why the UI never updated.
Honestly, I thought async/await was just syntactic sugar for promises—nice to look at, but nothing revolutionary. I was wrong, and that’s awesome.
The Revelation (The Insight)
When I dug deeper, I uncovered three async/await quirks that most tutorials gloss over. They’re not just “nice to know”; they can shave hours off your debugging time and turn clunky code into something that reads like a story.
- Top‑level await – you can actually wait for a promise at the very top of an ES module, no IIFE needed.
-
Async iterators &
for await…of– lets you consume asynchronous streams (think lazy‑loaded pages or WebSocket messages) with the same clean syntax you use for arrays. - Auto‑wrapping of return values – an async function always returns a promise, even if you just return a plain value.
Each of these comes with a gotcha that can bite you if you’re not aware, but once you tame them they feel like discovering a hidden shortcut in a video game.
Wielding the Power (Code & Examples)
1. Top‑level await – The Config Loader
The struggle
Before ES modules got top‑level await, I’d wrap my config load in an immediately‑invoked async function:
// config.js (old way)
(async () => {
const response = await fetch('/config.json');
const config = await response.json();
// expose config to the rest of the app
window.APP_CONFIG = config;
})();
I had to remember to call the IIFE, and if I accidentally exported the wrapper instead of the resolved value, other modules got a promise instead of the actual config. Annoying, right?
The victory
With top‑level await, the same file becomes:
// config.js (new way) – works only in ES modules ("type":"module")
const response = await fetch('/config.json');
const config = await response.json();
export default config; // <-- plain object, no promise wrapping needed
Gotcha
Top‑level await only works in modules, not in classic <script> tags. If you try it in a script you’ll see a SyntaxError: await is only valid in async functions and the top level of modules. Also, because the module’s execution pauses until the await settles, any sibling imports that also use top‑level await will wait in sequence—so keep heavy work out of the top level if you care about startup time.
2. Async Iterators – Lazy Page Fetching
The struggle
I once built a paginated API viewer. My first attempt used a regular for loop with await inside:
// pages.js – the slow way
async function fetchAllPages() {
const pages = [];
for (let i = 0; i < 5; i++) {
const resp = await fetch(`https://api.example.com/page/${i}`);
pages.push(await resp.json()); // each request waits for the previous one
}
return pages;
}
Five requests, each one starting only after the previous finished. It felt like watching a boss fight in Dark Souls where you can only swing your sword after the enemy’s attack animation ends—painfully slow.
The victory
An async generator lets us define a lazy stream, and for await…of consumes it without changing the sequential nature (but now the intent is crystal clear, and we can easily swap in parallelism later):
// pages.js – async iterator way
async function* pageGenerator() {
for (let i = 0; i < 5; i++) {
const resp = await fetch(`https://api.example.com/page/${i}`);
yield await resp.json(); // each yield is a promise that resolves to the page data
}
}
// usage
(async () => {
const pages = [];
for await (const page of pageGenerator()) {
pages.push(page);
}
console.log(pages);
})();
Gotcha
The generator yields promises, not raw values. If you forget the inner await (yield resp.json();) you’ll end up yielding a promise object, and your for await…of will resolve that promise to another promise—leading to weird nested promises. Keep the await inside the yield, or yield the raw value and let the caller await it separately.
3. Auto‑wrapping – The “Just Return It” Trick
The struggle
I used to write helpers like this:
// old helper
function getUser(id) {
return db.findUserById(id).then(user => user); // explicit .then just to return a promise
}
It worked, but the extra .then felt like wearing a belt and suspenders when you already had suspenders.
The victory
Make the function async and simply return the value:
// new helper
async function getUser(id) {
const user = await db.findUserById(id);
return user; // <-- automatically wrapped in Promise.resolve(user)
}
Now await getUser(42) gives you the user object, and if you forget the await you still get a promise you can chain with .then.
Gotcha
Because the return value is always promise‑wrapped, returning a thenable (an object with a .then method) can cause double‑resolution surprises. For example:
async function tricky() {
return { then: (resolve, reject) => resolve('surprise') };
}
tricky().then(console.log); // logs 'surprise' – the extra thenable was unwrapped once
If you actually meant to return that object as‑is, you’d need to avoid making the function async. So, only use async when you genuinely want promise semantics.
Why This New Power Matters
Mastering these patterns changes how you write JavaScript.
- Top‑level await lets you ditch boilerplate IIFEs and write module initialization that looks synchronous, making config loading, feature flag checks, or even database connections feel natural.
- Async iterators give you a readable way to handle streams of data—whether it’s reading lines from a file, processing WebSocket messages, or paging through an API—without nesting callbacks or managing manual state.
- Auto‑wrapping reminds you that async functions are first‑class promise factories; you can focus on the logic and let the runtime handle the promise plumbing.
Together, they turn asynchronous code from a chore into a narrative. You spend less time chasing “why isn’t this waiting?” and more time building features that delight users.
Your Turn – The Challenge
Pick a piece of code you’ve written recently that uses .then chains or a for loop with await inside. Refactor it using one of the patterns above:
- Move a config fetch to the top level of an ES module.
- Turn a paginated fetch loop into an async generator +
for await…of. - Convert a helper that returns a promise manually into an plain
asyncfunction that just returns a value.
Drop your before/after snippets in the comments—I’d love to see how you’ve leveled up your async game!
Happy coding, and may your promises always resolve. 🚀
Top comments (0)