Why Async Error Handling Is Tricky
Synchronous errors are caught and propagated naturally up the call stack. Asynchronous errors — errors inside Promises or async functions — behave differently, and the gaps in handling them can cause silent failures, unhandled rejections, and hard-to-debug crashes.
This guide covers every pattern for handling errors in modern async JavaScript and TypeScript.
The Basics: Try-Catch with Async/Await
async function fetchUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
} catch (err) {
console.error('Failed to fetch user:', err);
throw err; // re-throw so callers can handle it
}
}
The await keyword "unwraps" a rejected promise into a thrown error. Without await, you're working with a Promise object and errors won't be caught by try-catch.
Common mistake:
// ❌ Error is NOT caught — missing await
async function buggy() {
try {
fetch('/api/data') // Promise returned but not awaited
.then(res => res.json()); // rejection here won't be caught
} catch (err) {
// This never runs for fetch errors
}
}
// ✅ Fixed
async function correct() {
try {
const res = await fetch('/api/data');
return await res.json();
} catch (err) {
// Catches both network errors and response parsing errors
}
}
Error Types to Handle
async function robustFetch(url) {
try {
const res = await fetch(url);
// HTTP errors (4xx, 5xx) don't throw by default — you must check
if (!res.ok) {
const body = await res.text();
throw new HttpError(res.status, res.statusText, body);
}
return await res.json();
} catch (err) {
if (err instanceof HttpError) {
// Handle server error (404, 500, etc.)
throw err;
}
if (err instanceof TypeError && err.message.includes('fetch')) {
// Network error (offline, DNS failure, CORS)
throw new NetworkError('Network unavailable', { cause: err });
}
if (err instanceof SyntaxError) {
// JSON parse error
throw new ParseError('Invalid JSON response', { cause: err });
}
throw err; // Unknown error — re-throw
}
}
class HttpError extends Error {
constructor(status, statusText, body) {
super(`HTTP ${status}: ${statusText}`);
this.name = 'HttpError';
this.status = status;
this.body = body;
}
}
Promise.all vs Promise.allSettled
Promise.all rejects immediately when any promise rejects — other promises are abandoned:
// ❌ If any request fails, all results are lost
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
Promise.allSettled waits for all promises and gives you each result individually:
// ✅ Get whatever succeeded, handle whatever failed
const results = await Promise.allSettled([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
const users = results[0].status === 'fulfilled' ? results[0].value : [];
const posts = results[1].status === 'fulfilled' ? results[1].value : [];
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
if (errors.length > 0) {
console.warn('Some requests failed:', errors);
}
Use Promise.all when all requests must succeed. Use Promise.allSettled when partial success is acceptable.
Timeout Pattern
fetch doesn't have a built-in timeout. Use AbortController:
async function fetchWithTimeout(url, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId); // always clean up
}
}
Or use Promise.race:
function timeout(ms) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
);
}
async function fetchWithRaceTimeout(url, ms = 5000) {
return Promise.race([
fetch(url).then(r => r.json()),
timeout(ms),
]);
}
Retry Logic with Exponential Backoff
async function fetchWithRetry(url, options = {}) {
const { maxRetries = 3, baseDelay = 1000, retryOn = [429, 503] } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(url, options);
if (retryOn.includes(res.status) && attempt < maxRetries) {
const delay = baseDelay * 2 ** attempt + Math.random() * 1000;
console.log(`Retrying in ${Math.round(delay)}ms (attempt ${attempt + 1})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt === maxRetries) throw err;
const isRetryable = err.name === 'TypeError'; // network errors
if (!isRetryable) throw err;
const delay = baseDelay * 2 ** attempt;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Avoiding Unhandled Promise Rejections
An unhandled promise rejection crashes Node.js (since v15) and logs warnings in browsers.
// ❌ Unhandled rejection — no .catch() and no await with try-catch
const promise = fetchData();
// promise might reject, but nothing handles it
// ✅ Handle it
fetchData().catch(err => console.error('fetchData failed:', err));
// ✅ Or with async/await
async function init() {
try {
await fetchData();
} catch (err) {
console.error('fetchData failed:', err);
}
}
// ✅ Global handler as last resort
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason);
// Don't swallow — log and optionally exit
process.exit(1);
});
TypeScript: Typed Error Handling
TypeScript doesn't type errors in catch blocks — they're always unknown:
async function fetchData(url: string): Promise<Data> {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json() as Data;
} catch (err) {
// err is 'unknown' in TypeScript strict mode
if (err instanceof Error) {
throw new Error(`Failed to fetch: ${err.message}`);
}
throw new Error('Unknown error');
}
}
Result pattern (no exceptions):
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
async function safeFetch<T>(url: string): Promise<Result<T>> {
try {
const res = await fetch(url);
if (!res.ok) {
return { ok: false, error: new Error(`HTTP ${res.status}`) };
}
const data = await res.json() as T;
return { ok: true, value: data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
}
}
// Usage — no try-catch at call site
const result = await safeFetch<User>('/api/user/1');
if (!result.ok) {
console.error('Failed:', result.error.message);
return;
}
const user = result.value; // typed as User
Error Boundary in React
For async errors in React components, use error boundaries:
// React Query handles async errors gracefully
const { data, error, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
if (error) {
return <ErrorMessage message={error.message} />;
}
Quick Reference
| Scenario | Solution |
|---|---|
| Single async operation | try/catch with await |
| Multiple operations, all must succeed | Promise.all + try/catch |
| Multiple operations, partial failure OK | Promise.allSettled |
| Timeout needed | AbortController + timeout |
| Transient failures (rate limits, 503) | Retry with exponential backoff |
| Prevent unhandled rejections | Always .catch() or await in try/catch |
→ Use the JSON Viewer to inspect API error responses and understand their structure.