JavaScript Promises 101 — A Practical, No‑Nonsense Guide
A concise, practical reference to understand Promises, async/await, timing, and common patterns you’ll use daily.

1) What is a Promise?
A Promise represents the eventual result of an asynchronous operation.
States
pending → operation in progress
fulfilled → operation completed successfully (has a value)
rejected → operation failed (has a reason/error)
Lifecycle
Pending → Fulfilled (value) → Rejected (error)
Key idea: A Promise is a container for a future value. It does not execute work by itself—work starts when you call the async function or API that returns it (e.g., fetch()).
2) Where does the work actually happen?
In the browser: network stack, timers, Web APIs
In Node.js: libuv (thread pool), OS syscalls, timers
The JavaScript thread doesn’t perform the I/O. It waits to be notified when the work completes and then processes the completion via the microtask queue.
3) async functions and await
async function example() {
// Synchronous until the first await
const data = await fetch('/api'); // pauses here until the promise settles
return data; // returned value becomes the resolution value of the async function
}
An
asyncfunction always returns a Promise.Execution runs synchronously until it hits the first
await.At
await, the function pauses, returning a pending Promise to the caller.When the awaited promise settles, the function resumes after the
await.
awaitdoesn’t make the work happen—it waits for and retrieves the result of work already in progress.
4) Await inside vs awaiting the outer function
async function doSomething() {
await fetch('/api/data'); // awaits inside
console.log('done'); // runs later when fetch resolves
}
doSomething(); // caller is NOT awaiting
console.log('other work');
// output: "other work" then "done"
awaitinside controls sequencing inside the function.Awaiting the outer function controls whether the caller waits for completion / gets the returned value.
Inside awaits still run even if the caller never awaits the outer function (but beware unhandled rejections; see §10).
5) When should you await?
Await when
You need the value:
const user = await fetchUser(); render(user);You need ordering/atomicity (e.g., show a loader, then hide it only after completion):
setLoading(true); await save(); setLoading(false);You must handle errors with
try/catchat this call site.
Don’t await when
Fire-and-forget side effects (analytics, logging):
logEvent().catch(console.error); // at least catch errorsYou want to run tasks in parallel:
const p1 = fetchA(); const p2 = fetchB(); const [a, b] = await Promise.all([p1, p2]);
6) Parallel vs Sequential
Sequential (slower)
await task1();
await task2();
Parallel (faster)
const p1 = task1(); // start both
const p2 = task2();
await Promise.all([p1, p2]);
Handling partial failures
const results = await Promise.allSettled([task1(), task2()]);
for (const r of results) {
if (r.status === 'fulfilled') console.log(r.value);
else console.error(r.reason);
}
7) Request coalescing (de-dup) pattern
Avoid duplicate in-flight requests for the same key/id.
class Loader<T> {
private pending = new Map<string, Promise<T>>();
private cache = new Map<string, T>();
async get(id: string): Promise<T> {
if (this.cache.has(id)) return this.cache.get(id)!;
const p = this.pending.get(id);
if (p) return p; // reuse in-flight
const request = (async () => {
try {
const value = await fetchThing(id);
this.cache.set(id, value);
return value;
} finally {
this.pending.delete(id); // runs on success or failure
}
})();
this.pending.set(id, request);
return request;
}
}
Notes
Async IIFE returns a promise immediately; actual work continues in background.
finallyclears the in-flight marker regardless of outcome.
8) Error handling
try/catch with await
try {
const data = await fetchJSON(url);
} catch (err) {
// handle / show toast / retry
}
.then/.catch
fetchJSON(url)
.then(data => use(data))
.catch(err => handle(err));
Top-level (fire-and-forget)
doSideEffect().catch(err => console.error('background failed', err));
9) Unhandled promise rejections
If a promise rejects and nothing awaits or .catch()es it:
Browsers: console warning
Node.js: unhandled rejection warning; newer Node can terminate the process based on
unhandledRejectionbehavior
Always attach a catch for fire-and-forget operations:
void doSomethingAsync().catch(reportError);
10) Cancellation & timeouts
Promises can’t be canceled intrinsically, but many APIs support AbortController.
const c = new AbortController();
const p = fetch(url, { signal: c.signal });
setTimeout(() => c.abort(), 3000); // cancel after 3s
try {
const res = await p;
} catch (e) {
if (e.name === 'AbortError') console.log('aborted');
}
Timeout utility
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout')), ms))
]);
}
11) Microtasks vs Macrotasks (event loop essentials)
Microtasks: promise reactions (
.then,awaitcontinuations)Macrotasks:
setTimeout,setInterval, DOM events
Order: After each macrotask tick, the engine drains all microtasks before the next macrotask.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
// output: microtask, then timeout
12) Common pitfalls & fixes
Forgetting to handle rejections
- Fix: always add
.catch(...)on fire-and-forget, or wrap intry/catch.
- Fix: always add
Accidentally running sequentially when you intended parallelism
- Fix: start tasks first,
awaitwithPromise.all.
- Fix: start tasks first,
Mutating shared results from cache
- Fix: return a copy or
Object.freezeimmutable results.
- Fix: return a copy or
Shallow vs deep clone
- Use
structuredClonewhen needed; spreading is shallow and strips prototypes.
- Use
Returning before cleanup
- Use
finallyto ensure cleanup always runs.
- Use
13) Patterns cheat‑sheet
Start now, await later
const work = doWork();
// ... do other things
const result = await work;
Fire and forget (with error logging)
void doSideEffect().catch(console.error);
Serialize with a mutex-like queue
let last = Promise.resolve();
function enqueue(task) {
const run = last.then(task, task);
last = run.catch(() => {});
return run;
}
Rate limit calls (token bucket / debounce + promises) — out of scope for brevity but good to know.
14) FAQ
Q: If I don’t await a returned promise, does the work still happen?
A: Yes. The work continues in the background. But handle rejections to avoid unhandled warnings.
Q: Why do I need to await later if the promise already resolved?
A: To retrieve the resolved value and to ensure your code runs after completion. Without await, you only have the Promise, not its value.
Q: Does await block the thread?
A: No. It pauses that async function and yields control to the event loop.
Q: Is await the same as .then?
A: Semantically similar. await is syntax sugar for chaining .then and handling errors via try/catch.
Q: Will finally run if a promise rejects?
A: Yes—finally always runs.
15) Minimal mental model
Start async work → you get a Promise immediately.
Work proceeds outside the JS thread.
When done, JS schedules a microtask to resume your code.
Use
await/.thento: (a) get the value, (b) sequence dependent code, (c) handle errors.
16) Mini-lab: log the timeline
async function demo() {
console.log('A: start');
const p = fetch('/api/slow');
console.log('B: after starting fetch');
const res = await p; // pauses here
console.log('C: after await');
const json = await res.json();
console.log('D:', json);
}
demo();
console.log('E: outside');
// Possible order: A, B, E, C, D
Use this to verify your understanding of ordering.
17) Production tips
Prefer
Promise.allfor independent async work.Always catch background promises.
Centralize error reporting for async failures.
Consider timeouts and cancellation for network requests.
Use
finallyfor cleanup (spinners, locks, in-flight maps).
You’ve got this. Promises are just: start work → get a token (promise) → resume when it’s done. Master the timing, and the rest follows naturally.






