Promises and asynchronous JavaScript
Overview
Promises and asynchronous JavaScript are fundamental to modern web development. JavaScript runs application code on a single main thread in the browser, but real applications constantly need to do work that takes time: calling APIs, reading files, waiting for timers, loading images, handling user events, or performing background operations. Asynchronous programming allows JavaScript to start these operations without blocking the whole page.
A Promise is a JavaScript object that represents the eventual completion or failure of an asynchronous operation and its resulting value. It lets code attach success and failure handlers instead of deeply nesting callbacks. async and await are syntax built on top of Promises that make asynchronous code easier to read and reason about.
This topic is especially important for React developers because React applications frequently fetch data, submit forms, debounce user input, run multiple API calls, handle loading/error states, cancel stale requests, and coordinate UI updates after asynchronous work. Many production bugs in React applications come from misunderstanding Promises, stale async results, race conditions, missing error handling, or incorrect useEffect cleanup.
For interviews, this topic matters because it tests whether a developer understands more than basic syntax. A strong candidate should be able to explain:
- What asynchronous JavaScript means.
- How Promises work.
- The difference between pending, fulfilled, and rejected Promise states.
- How
.then,.catch, and.finallywork. - How
asyncandawaitrelate to Promises. - How error handling works with asynchronous code.
- The difference between sequential and parallel async execution.
- How
Promise.all,Promise.allSettled,Promise.race, andPromise.anydiffer. - How the event loop, tasks, and microtasks affect execution order.
- How to avoid common bugs such as unhandled rejections, accidental sequential calls, and stale React state updates.
The practical goal is to write asynchronous code that is readable, reliable, cancellable where needed, and safe for real user interfaces.
Core Concepts
Synchronous vs asynchronous JavaScript
Synchronous code runs line by line. Each statement must finish before the next statement runs.
console.log("A");
console.log("B");
console.log("C");
Output:
A
B
C
Asynchronous code starts an operation that will complete later. JavaScript can continue running other code while waiting.
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
console.log("C");
Output:
A
C
B
The timer callback runs later. JavaScript does not block the main thread for one second.
In real applications, asynchronous behavior appears in:
fetchAPI calls.- Timers such as
setTimeoutandsetInterval. - DOM events.
- File operations in Node.js.
- WebSocket messages.
- IndexedDB operations.
- Image and script loading.
- React data fetching.
- Form submissions.
- Authentication flows.
- Background jobs and worker communication.
Why asynchronous programming matters
JavaScript often runs on the browser's main thread, which also handles user interaction and rendering. If long-running work blocks the main thread, the UI can freeze.
Bad blocking example:
function blockForTooLong() {
const start = Date.now();
while (Date.now() - start < 5000) {
// Blocks the main thread for 5 seconds
}
}
blockForTooLong();
console.log("Done");
During that loop, the browser cannot respond smoothly to clicks, input, or rendering.
Asynchronous programming helps keep applications responsive by allowing slow operations to complete later while JavaScript continues running other work.
Callback-based asynchronous code
Before Promises became common, asynchronous JavaScript often used callbacks.
function loadUser(userId, onSuccess, onError) {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(user => onSuccess(user))
.catch(error => onError(error));
}
loadUser(
42,
user => {
console.log(user);
},
error => {
console.error(error);
}
);
Callbacks are still common in event handlers and some older APIs, but deeply nested callbacks can become hard to read.
Example callback nesting:
getUser(userId, user => {
getOrders(user.id, orders => {
getOrderItems(orders[0].id, items => {
console.log(items);
});
});
});
This is often called "callback hell" or the "pyramid of doom." Promises and async/await make this easier to structure.
What is a Promise?
A Promise is an object representing an asynchronous result that may be available now, later, or never if the operation fails or hangs.
A Promise can be in one of three states:
A Promise is settled when it is either fulfilled or rejected.
Example:
const promise = fetch("/api/products");
console.log(promise); // Promise object
The fetch call starts an HTTP request and immediately returns a Promise. The Promise will eventually fulfill with a Response or reject if the request fails at the network level.
Creating a Promise
You can create a Promise with the Promise constructor.
function delay(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
delay(1000).then(() => {
console.log("One second passed");
});
The constructor receives an executor function with two callbacks:
resolve(value)fulfills the Promise.reject(error)rejects the Promise.
Example:
function loadSettings() {
return new Promise((resolve, reject) => {
const settings = localStorage.getItem("settings");
if (!settings) {
reject(new Error("Settings not found"));
return;
}
resolve(JSON.parse(settings));
});
}
In most application code, you do not need to manually create Promises often. Many modern APIs already return Promises.
Promise resolution and rejection
A Promise should represent either success or failure.
function getNumber() {
return new Promise((resolve, reject) => {
const value = Math.random();
if (value > 0.5) {
resolve(value);
} else {
reject(new Error("Value was too small"));
}
});
}
Usage:
getNumber()
.then(value => {
console.log("Success:", value);
})
.catch(error => {
console.error("Failed:", error.message);
});
Important behavior:
- A Promise can settle only once.
- Calling
resolveorrejectafter settlement has no effect. - Throwing an error inside the Promise executor rejects the Promise.
- Returning a Promise from
.thenchains the asynchronous operation.
.then, .catch, and .finally
Promises expose methods for handling completion.
.then
.then handles successful fulfillment.
fetch("/api/users/1")
.then(response => response.json())
.then(user => {
console.log(user.name);
});
Each .then returns a new Promise, which enables chaining.
getUser()
.then(user => getOrders(user.id))
.then(orders => getOrderItems(orders[0].id))
.then(items => console.log(items));
.catch
.catch handles rejection.
fetch("/api/users/1")
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(user => {
console.log(user);
})
.catch(error => {
console.error("Failed to load user:", error);
});
A .catch can handle errors thrown in previous .then callbacks.
.finally
.finally runs whether the Promise is fulfilled or rejected.
setLoading(true);
fetch("/api/users/1")
.then(response => response.json())
.then(user => {
setUser(user);
})
.catch(error => {
setError(error);
})
.finally(() => {
setLoading(false);
});
finally is useful for cleanup:
- Stop loading indicator.
- Release a lock.
- Close a resource.
- Reset temporary state.
Promise chaining
Promise chaining lets each step depend on the previous step.
fetch("/api/users/1")
.then(response => response.json())
.then(user => {
return fetch(`/api/orders?userId=${user.id}`);
})
.then(response => response.json())
.then(orders => {
console.log(orders);
})
.catch(error => {
console.error(error);
});
Important rule:
Return the next Promise from .then if the next step is asynchronous.
Bad example:
fetch("/api/users/1")
.then(response => {
response.json(); // Missing return
})
.then(user => {
console.log(user); // undefined
});
Correct:
fetch("/api/users/1")
.then(response => {
return response.json();
})
.then(user => {
console.log(user);
});
Or shorter:
fetch("/api/users/1")
.then(response => response.json())
.then(user => console.log(user));
async functions
An async function is a function that always returns a Promise.
async function getUser() {
return { id: 1, name: "Minh" };
}
const result = getUser();
console.log(result); // Promise
Even though the function returns a plain object, JavaScript wraps it in a fulfilled Promise.
Equivalent idea:
async function getUser() {
return { id: 1 };
}
// Similar to:
function getUser() {
return Promise.resolve({ id: 1 });
}
If an async function throws an error, the returned Promise is rejected.
async function fail() {
throw new Error("Something went wrong");
}
fail().catch(error => {
console.error(error.message);
});
await
await pauses the execution of an async function until the awaited Promise settles.
async function loadUser() {
const response = await fetch("/api/users/1");
const user = await response.json();
console.log(user);
}
This looks synchronous, but it is still asynchronous. The JavaScript thread is not blocked while waiting for the Promise. Instead, the async function pauses and resumes later.
await returns the fulfillment value:
const value = await Promise.resolve(123);
console.log(value); // 123
If the Promise rejects, await throws the rejection reason:
try {
await Promise.reject(new Error("Failed"));
} catch (error) {
console.error(error.message);
}
async/await vs Promise chains
These two examples are equivalent in behavior.
Promise chain:
function loadUser() {
return fetch("/api/users/1")
.then(response => response.json())
.then(user => {
console.log(user);
return user;
});
}
async/await:
async function loadUser() {
const response = await fetch("/api/users/1");
const user = await response.json();
console.log(user);
return user;
}
async/await is often easier to read for sequential logic. Promise chains can still be useful for concise transformations or when working directly with combinators.
Error handling with async/await
Use try/catch around awaited operations.
async function loadUser(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to load user:", error);
throw error;
}
}
Important: fetch does not reject just because the server returns 404 or 500. It rejects for network-level failures. You must check response.ok for HTTP error statuses.
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
Sequential vs parallel async execution
A common performance mistake is accidentally running independent operations sequentially.
Sequential:
const user = await fetchUser();
const settings = await fetchSettings();
const notifications = await fetchNotifications();
This waits for each operation to finish before starting the next. If each takes one second, the total time may be around three seconds.
Parallel:
const userPromise = fetchUser();
const settingsPromise = fetchSettings();
const notificationsPromise = fetchNotifications();
const user = await userPromise;
const settings = await settingsPromise;
const notifications = await notificationsPromise;
Or with Promise.all:
const [user, settings, notifications] = await Promise.all([
fetchUser(),
fetchSettings(),
fetchNotifications()
]);
If the operations are independent, Promise.all is usually cleaner and faster.
Use sequential execution when:
- Step 2 depends on the result of step 1.
- Order matters.
- You need to stop early after a failed step.
- You need rate limiting or controlled load.
Use parallel execution when:
- Operations are independent.
- You need all results.
- You want lower total waiting time.
Promise combinators
Promise combinators help coordinate multiple asynchronous operations.
Promise.all
Waits for all Promises to fulfill. Rejects when any input Promise rejects.
const [user, orders] = await Promise.all([
fetchUser(userId),
fetchOrders(userId)
]);
Use when:
- All operations are required.
- Failure of any operation should fail the whole operation.
- You want parallel execution.
Risk:
One rejection rejects the entire Promise.all result.
Promise.allSettled
Waits for all Promises to settle, whether fulfilled or rejected.
const results = await Promise.allSettled([
fetchUser(userId),
fetchOrders(userId),
fetchRecommendations(userId)
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Value:", result.value);
} else {
console.error("Reason:", result.reason);
}
}
Use when:
- You need every result.
- Some failures are acceptable.
- You want to show partial data.
Example React use case:
Load profile, recommendations, and notifications.
If recommendations fail, still show profile and notifications.
Promise.race
Settles as soon as the first input Promise settles, whether fulfilled or rejected.
const result = await Promise.race([
fetchData(),
delay(5000).then(() => {
throw new Error("Timeout");
})
]);
Use when:
- You care about the first settled operation.
- You implement timeout-like behavior.
- You race multiple sources.
Important: Promise.race does not automatically cancel the other Promises.
Promise.any
Fulfills as soon as the first input Promise fulfills. Rejects only if all input Promises reject.
const data = await Promise.any([
fetchFromPrimaryRegion(),
fetchFromSecondaryRegion(),
fetchFromCacheService()
]);
Use when:
- Any successful result is acceptable.
- You want the fastest successful response.
- Failures are acceptable as long as one succeeds.
If all reject, Promise.any rejects with an AggregateError.
Event loop, tasks, and microtasks
JavaScript uses an event loop to coordinate synchronous code, asynchronous callbacks, rendering, and queued work.
Key terms:
Promise callbacks are microtasks.
Example:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
Output:
A
D
C
B
Why:
Alogs synchronously.setTimeoutschedules a task.- Promise
.thenschedules a microtask. Dlogs synchronously.- Microtasks run before the next task.
- Timer task runs later.
This matters in interviews because it shows that "0 ms timeout" does not mean "run immediately." It means "run in a later task."
Microtask starvation
Because the microtask queue is drained before the browser moves to the next task or rendering opportunity, too many recursive microtasks can delay rendering and user input.
Bad example:
function loop() {
Promise.resolve().then(loop);
}
loop();
This can keep the microtask queue busy and make the UI unresponsive.
Most application code does not create this intentionally, but it is useful to understand why Promise-heavy loops can affect responsiveness.
Async functions and execution order
async functions start running synchronously until the first await.
async function run() {
console.log("B");
await Promise.resolve();
console.log("C");
}
console.log("A");
run();
console.log("D");
Output:
A
B
D
C
Explanation:
run()starts immediately.- It logs
B. awaitpauses the function.- The outer code continues and logs
D. - The async function resumes later and logs
C.
This is a common interview question because many developers incorrectly assume everything inside an async function runs later.
Returning vs awaiting
Inside an async function, returning a Promise and awaiting a Promise are often similar, but not always identical for error handling and stack traces.
async function getUser() {
return fetchUser();
}
This returns a Promise that resolves or rejects with fetchUser.
async function getUser() {
return await fetchUser();
}
This awaits the result and then returns it.
In many cases, return await is unnecessary. However, it is useful inside try/catch when you want the function to catch the rejection.
Bad:
async function loadUser() {
try {
return fetchUser();
} catch (error) {
// This will not catch an async rejection from fetchUser()
console.error(error);
}
}
Correct:
async function loadUser() {
try {
return await fetchUser();
} catch (error) {
console.error("Failed to load user:", error);
throw error;
}
}
Unhandled Promise rejections
An unhandled rejection happens when a Promise rejects and no code handles the error.
Bad:
async function saveUser() {
throw new Error("Save failed");
}
saveUser(); // No await, no catch
Better:
try {
await saveUser();
} catch (error) {
console.error(error);
}
Or:
saveUser().catch(error => {
console.error(error);
});
In React event handlers, unhandled rejections can happen if you call an async function without handling errors.
function SaveButton() {
async function handleClick() {
await saveUser();
}
return <button onClick={handleClick}>Save</button>;
}
Better:
function SaveButton() {
async function handleClick() {
try {
await saveUser();
showSuccess("Saved");
} catch (error) {
showError("Save failed");
}
}
return <button onClick={handleClick}>Save</button>;
}
Async array methods
A common mistake is using forEach with async and expecting it to await each operation.
Bad:
const users = [1, 2, 3];
users.forEach(async userId => {
await sendEmail(userId);
});
console.log("Done");
Done logs before the emails complete because forEach does not await the async callbacks.
Use Promise.all for parallel execution:
await Promise.all(
users.map(userId => sendEmail(userId))
);
console.log("Done");
Use for...of for sequential execution:
for (const userId of users) {
await sendEmail(userId);
}
console.log("Done");
Use sequential execution when order or rate limiting matters. Use parallel execution when operations are independent and the system can handle the load.
Concurrency control
Running everything in parallel can overload the browser, API, database, or third-party service.
Risky:
await Promise.all(
thousandsOfIds.map(id => fetch(`/api/items/${id}`))
);
This can create too many concurrent requests.
A simple concurrency limiter:
async function runWithConcurrency(items, limit, worker) {
const results = [];
const executing = new Set();
for (const item of items) {
const promise = Promise.resolve()
.then(() => worker(item))
.then(result => {
results.push(result);
})
.finally(() => {
executing.delete(promise);
});
executing.add(promise);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
Usage:
const results = await runWithConcurrency(ids, 5, id =>
fetch(`/api/items/${id}`).then(response => response.json())
);
In real projects, teams may use a small utility or library for concurrency limiting, but interviewers often want to see that you understand why unlimited Promise.all can be dangerous.
Cancellation with AbortController
Promises do not have built-in cancellation. For APIs that support cancellation, such as fetch, use AbortController.
const controller = new AbortController();
try {
const response = await fetch("/api/products", {
signal: controller.signal
});
const products = await response.json();
console.log(products);
} catch (error) {
if (error.name === "AbortError") {
console.log("Request was cancelled");
} else {
throw error;
}
}
// Later:
controller.abort();
Cancellation is important when:
- A user navigates away.
- A React component unmounts.
- A search query changes before the previous request finishes.
- A timeout is reached.
- A newer request replaces an older request.
Timeouts
fetch does not automatically use a custom timeout in the same way some HTTP clients do. You can combine AbortController with setTimeout.
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
Usage:
try {
const response = await fetchWithTimeout("/api/products", 3000);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const products = await response.json();
} catch (error) {
if (error.name === "AbortError") {
console.error("Request timed out or was cancelled");
} else {
console.error("Request failed:", error);
}
}
Async JavaScript in React
React applications commonly use asynchronous JavaScript for data fetching, form submission, authentication, and background updates.
Example with useEffect:
import { useEffect, useState } from "react";
function ProductList() {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadProducts() {
try {
setStatus("loading");
const response = await fetch("/api/products", {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setProducts(data);
setStatus("success");
} catch (error) {
if (error.name === "AbortError") {
return;
}
setError(error);
setStatus("error");
}
}
loadProducts();
return () => {
controller.abort();
};
}, []);
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p>{error.message}</p>;
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Important React habits:
- Do not make the
useEffectcallback itselfasync. - Define an inner async function and call it.
- Use cleanup to cancel or ignore stale requests.
- Track loading and error states.
- Avoid setting state after a component unmounts.
- Handle stale responses when dependencies change.
- Prefer a data-fetching library for complex caching and synchronization.
Why useEffect callback should not be async
This is incorrect:
useEffect(async () => {
const response = await fetch("/api/products");
const data = await response.json();
setProducts(data);
}, []);
The effect callback should return either nothing or a cleanup function. An async function returns a Promise, which is not the cleanup function React expects.
Correct:
useEffect(() => {
async function loadProducts() {
const response = await fetch("/api/products");
const data = await response.json();
setProducts(data);
}
loadProducts();
}, []);
With cleanup:
useEffect(() => {
const controller = new AbortController();
async function loadProducts() {
try {
const response = await fetch("/api/products", {
signal: controller.signal
});
const data = await response.json();
setProducts(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
}
loadProducts();
return () => {
controller.abort();
};
}, []);
Race conditions in React data fetching
A race condition happens when multiple async operations finish in a different order than expected.
Example problem:
useEffect(() => {
async function loadUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
}
loadUser();
}, [userId]);
If userId changes quickly, the old request may finish after the new request and overwrite the state with stale data.
Better with cancellation:
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
}
loadUser();
return () => {
controller.abort();
};
}, [userId]);
Alternative with request ID:
let requestId = 0;
async function search(query) {
const currentRequestId = ++requestId;
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (currentRequestId === requestId) {
setResults(data);
}
}
This ensures only the latest response updates the UI.
Loading, success, and error state
Asynchronous UI should usually model request state explicitly.
const [status, setStatus] = useState("idle");
// idle | loading | success | error
Example:
async function handleSubmit() {
try {
setStatus("loading");
setError(null);
await saveProfile(formData);
setStatus("success");
} catch (error) {
setError(error);
setStatus("error");
}
}
Good UI states:
- Idle.
- Loading.
- Success.
- Error.
- Empty result.
- Retrying.
- Cancelled if useful.
Avoid relying only on data === null to represent all states. That can make the UI ambiguous.
Promise-based API helpers
A common real-world pattern is to centralize fetch logic.
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options.headers
},
...options
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `HTTP ${response.status}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
Usage:
const products = await requestJson("/api/products");
Benefits:
- Consistent error handling.
- Consistent JSON parsing.
- Centralized headers.
- Easier authentication handling.
- Easier logging and retry behavior.
Retry and backoff
Some failures are transient, such as network issues or temporary server overload. Retrying can help, but it must be used carefully.
Simple retry:
async function retry(operation, maxAttempts = 3) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) {
throw lastError;
}
}
}
}
Retry with delay:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function retryWithBackoff(operation, maxAttempts = 3) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === maxAttempts) {
throw lastError;
}
await delay(250 * attempt);
}
}
}
Use retries only for safe operations or operations designed to be idempotent. Retrying a payment or order submission without idempotency can create duplicate side effects.
Idempotency and async operations
An operation is idempotent if repeating it has the same effect as doing it once.
Examples:
GET /productsis typically idempotent.PUT /profileis usually designed to be idempotent.POST /ordersmay not be idempotent unless it uses an idempotency key.
Async operations often fail after the server already processed the request but before the client received the response. Retrying blindly can duplicate work.
Example safer request:
await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID()
},
body: JSON.stringify(order)
});
This is more of an API design concern, but frontend developers should understand it when implementing retries.
Top-level await
In JavaScript modules, await can be used at the top level.
const configResponse = await fetch("/config.json");
export const config = await configResponse.json();
This can be useful for module initialization, but it should be used carefully because it can delay module loading and affect dependent modules.
For React apps, top-level await is usually less common in component code. Most async work belongs in event handlers, effects, data loaders, or dedicated data-fetching layers.
Common mistakes
Common mistakes with Promises and asynchronous JavaScript include:
- Forgetting to
returna Promise inside.then. - Forgetting to
awaitan async function. - Using
forEachwith async callbacks and expecting it to wait. - Running independent API calls sequentially instead of in parallel.
- Running too many requests in parallel with unbounded
Promise.all. - Not handling errors.
- Assuming
fetchrejects on HTTP404or500. - Making a
useEffectcallbackasync. - Setting React state after a component unmounts.
- Letting stale async responses overwrite newer state.
- Not cancelling requests when dependencies change.
- Retrying non-idempotent operations.
- Swallowing errors without logging or user feedback.
- Mixing
.thenandawaitin confusing ways. - Assuming
setTimeout(..., 0)runs before Promise callbacks. - Blocking the main thread with heavy synchronous work.
Best practices
Good asynchronous JavaScript habits include:
- Prefer
async/awaitfor readable sequential logic. - Use Promise chains where they are concise and clear.
- Always handle errors with
try/catchor.catch. - Check
response.okafterfetch. - Use
Promise.allfor independent required operations. - Use
Promise.allSettledwhen partial success is acceptable. - Use
Promise.racecarefully for first-settled behavior or timeout patterns. - Use
Promise.anywhen the first successful result is enough. - Limit concurrency for large batches.
- Use
AbortControllerto cancel stale or unnecessary requests. - Keep React effects synchronous and call an inner async function.
- Clean up async effects on unmount or dependency change.
- Model loading, success, error, and empty states explicitly.
- Avoid fire-and-forget async work unless failure is intentionally ignored and logged.
- Make retry behavior deliberate and safe.
- Keep async business logic out of deeply nested UI components when it becomes complex.
Practical decision guide
Use this guide during interviews and real implementation:
Do I need to wait for one async operation?
-> Use await with try/catch.
Do I need several independent results and all are required?
-> Use Promise.all.
Do I need every result, even failures?
-> Use Promise.allSettled.
Do I need the first operation to settle?
-> Use Promise.race.
Do I need the first successful operation?
-> Use Promise.any.
Do I need to process many items but avoid overload?
-> Use controlled concurrency.
Can the request become stale or unnecessary?
-> Use AbortController or an ignore-latest strategy.
Is this in React useEffect?
-> Keep the effect callback synchronous and call an inner async function with cleanup.