"You can't have bugs if you catch everything" — every junior dev, ever.
Ever felt like half your codebase is just try/catch
around fetch()
? That was me, until I decided enough is enough.
After shipping dozens of React apps, I noticed a pattern: every HTTP request meant the same boilerplate. try/catch
, check res.ok
, handle timeouts, pray the API returns what you expect. My team was spending more time debugging error handlers than building features.
The breaking point came during a critical bug fix at 2 AM. A single unhandled promise rejection crashed our dashboard because someone forgot to wrap a fetch()
call. That's when I realized: the problem isn't that we handle errors badly – it's that fetch() forces us to handle them everywhere, every time.
Spoiler alert: it doesn't have to be this way.
So I built @asouei/safe-fetch to eliminate try/catch
from HTTP requests forever.
A Story From the Trenches
Picture this: you're integrating with three different APIs for a client project. On paper, simple stuff – fetch data, show it in the UI. Reality hits different.
What went sideways:
- Payment API randomly threw 500s during peak hours
- User service returned empty
{}
objects with 200 status (thanks, PHP backend!) - Analytics endpoint hung for 30+ seconds, users thought our app was broken
- Error logs filled with unhelpful "Request failed" messages
The codebase turned into error-handling spaghetti. Every function had the same try/catch
boilerplate. Junior devs kept forgetting to check res.ok
. Code reviews became "did you add error handling?" discussions.
Six months later with safe-fetch:
- All error handling consolidated in interceptors – one place to debug
- GET retries handled flaky APIs automatically (no more "just refresh the page")
- Global timeouts eliminated hanging requests
- Error logs finally showed meaningful names (
NetworkError
,TimeoutError
) - We completely stopped writing try/catch around HTTP calls
Plot twist: the productivity boost was immediate. More features, fewer bugs, happier developers.
The Pain Points We All Know
Here's what every frontend developer has written a thousand times:
async function fetchUserProfile(id: string) {
try {
const response = await fetch(`/api/users/${id}`, {
signal: AbortSignal.timeout(5000) // Only works in newer browsers
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('User not found');
}
throw new Error(`Server error: ${response.status}`);
}
const profile = await response.json();
// Manual validation because APIs lie
if (!profile.id || !profile.email) {
throw new Error('Invalid user data');
}
return profile;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout');
}
// What else could go wrong? 🤷♂️
console.error('Profile fetch failed:', error.message);
throw error; // Re-throw and hope someone else deals with it
}
}
Sound familiar? The issues are always the same:
- fetch() only throws on network failures – HTTP 404/500 need manual checking
-
No built-in timeouts –
AbortController
hacks everywhere -
Untyped errors – good luck knowing what
error.message
contains - Retry logic? Write it yourself or pull in axios
- Copy-paste boilerplate in every HTTP function
What I Built Instead
Three core principles:
1. No exceptions, ever
Every call returns a result with a clear ok
flag. No surprises.
2. Typed errors
Instead of mysterious error.message
, get proper types: NetworkError
, TimeoutError
, HttpError
, ValidationError
.
3. Production features out of the box
Global timeouts, smart retries, Retry-After
support, request/response interceptors.
Here's the same function with safe-fetch:
import { safeFetch } from '@asouei/safe-fetch';
async function fetchUserProfile(id: string) {
return safeFetch.get<UserProfile>(`/api/users/${id}`, {
timeoutMs: 5000,
validate: (data) => UserSchema.safeParse(data).success
? { success: true, data }
: { success: false, error: 'Invalid user format' }
});
}
// Usage
const result = await fetchUserProfile('123');
if (result.ok) {
console.log(result.data.email); // TypeScript knows this exists
} else {
// Handle specific error types
switch (result.error.name) {
case 'HttpError':
if (result.error.status === 404) {
showNotFoundMessage();
}
break;
case 'TimeoutError':
showRetryButton();
break;
case 'ValidationError':
reportBadAPI();
break;
}
}
Always predictable: either { ok: true, data }
or { ok: false, error }
.
Zero try/catch
blocks in business logic.
Real-World Comparison: Before vs After
Before: Boilerplate Hell
class ApiService {
async getUsers() {
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
logger.error('Users fetch failed', e);
throw e;
}
}
async createUser(data: NewUser) {
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
logger.error('User creation failed', e);
throw e;
}
}
// Copy-paste this pattern 47 more times... 😵
}
After: Clean and Predictable
const api = createSafeFetch({
baseURL: '/api',
timeoutMs: 5000,
retries: { retries: 2 },
interceptors: {
onError: (error) => logger.error('API Error', error),
onRequest: (config) => {
config.headers['Authorization'] = `Bearer ${getToken()}`;
return config;
}
}
});
class ApiService {
async getUsers() {
return api.get<User[]>('/users');
}
async createUser(data: NewUser) {
return api.post<User>('/users', data);
}
}
// Usage is consistent everywhere
const users = await apiService.getUsers();
if (users.ok) {
renderUserList(users.data);
} else {
showErrorToast(users.error.message);
}
Key Features That Solve Real Problems
Dual Timeouts
const api = createSafeFetch({
timeoutMs: 5000, // 5s per attempt
totalTimeoutMs: 30000 // 30s total (including retries)
});
Smart Retries (Safe by Default)
// Only retries GET/HEAD by default (no accidental POST duplicates)
const result = await safeFetch.get('/api/flaky-service', {
retries: {
retries: 3,
baseDelayMs: 300 // Exponential backoff: 300ms, 600ms, 1200ms
}
});
Retry-After Respect
// Server responds: 429 Too Many Requests, Retry-After: 60
// safe-fetch automatically waits 60 seconds before retry
Type-Safe Validation
const result = await safeFetch.get('/user/profile', {
validate: (data) => {
const parsed = UserProfileSchema.safeParse(data);
return parsed.success
? { success: true, data: parsed.data }
: { success: false, error: 'API returned invalid user data' };
}
});
// result.data is fully typed and validated if result.ok === true
Migration Guide
Moving from fetch to safe-fetch is straightforward:
// Old fetch pattern
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed');
const data = await response.json();
setData(data);
} catch (error) {
setError(error.message);
}
// New safe-fetch pattern
const result = await safeFetch.get('/api/data');
if (result.ok) {
setData(result.data);
} else {
setError(result.error.message);
}
Works great with all modern stacks: Next.js, Vite, Create React App, Node.js, Cloudflare Workers.
Bundle Size Comparison
Library | Gzipped Size | Safe Results | Typed Errors | Retries | Global Timeout |
---|---|---|---|---|---|
safe-fetch | ~3kb | ✅ | ✅ | ✅ | ✅ |
axios | ~13kb | ❌ | ❌ | ❌ | ❌ |
ky | ~11kb | ❌ | ❌ | ✅ | ❌ |
fetch | 0kb | ❌ | ❌ | ❌ | ❌ |
Getting Started
npm install @asouei/safe-fetch
Basic usage:
import { safeFetch } from '@asouei/safe-fetch';
const users = await safeFetch.get<User[]>('/api/users');
if (users.ok) {
console.log(`Found ${users.data.length} users`);
} else {
console.error(`Failed: ${users.error.name}`);
}
Production setup:
import { createSafeFetch } from '@asouei/safe-fetch';
export const api = createSafeFetch({
baseURL: process.env.API_BASE_URL,
timeoutMs: 10000,
retries: { retries: 2 },
interceptors: {
onRequest: (config) => {
// Add auth headers
const token = localStorage.getItem('auth_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
onError: (error) => {
// Global error tracking
analytics.track('api_error', {
endpoint: error.url,
type: error.name
});
}
}
});
Who This Is For
- Teams tired of repetitive error handling and unpredictable failures
- Production apps that need reliable timeouts and retry strategies
- TypeScript projects wanting precise error types and data validation
- Developers who love fetch's simplicity but need enterprise features
-
Anyone who's ever written the same
try/catch
pattern more than twice
What's Next
The library is battle-tested and production-ready. Coming soon:
- ESLint plugin to enforce safe error handling patterns
- React Query and SWR adapters for seamless integration
- Framework-specific guides (Next.js App Router, Remix, SvelteKit)
Give It a Try
I built this because I was genuinely frustrated with the state of HTTP error handling in JavaScript. Turns out, that frustration is pretty universal.
safe-fetch doesn't try to reinvent HTTP clients. It solves one specific problem: making fetch() behave predictably in production apps. No revolution – just removing the daily friction we've all learned to live with.
If you're tired of explaining to junior devs why fetch('/api/users')
doesn't throw on 404, or writing identical error handlers in every API method, give it a shot. You might find it hard to go back.
The library has already gained recognition from the TypeScript community – it was recently added to Awesome TypeScript, one of the most comprehensive curated lists of quality TypeScript projects and resources.
Try it out and let me know in the comments what you think! If you've been burned by fetch boilerplate – star the repo ⭐ and share your war stories below.
Links:
P.S. The project is featured in Awesome TypeScript 🎉 – one of the largest curated lists of quality TypeScript projects and resources. Thanks to the community for the recognition!
Top comments (0)