DEV Community

Cover image for Stop Wrapping Every fetch() in try/catch — A Safer Error Handling for TypeScript
Asouei
Asouei

Posted on

Stop Wrapping Every fetch() in try/catch — A Safer Error Handling for TypeScript

"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
  }
}
Enter fullscreen mode Exit fullscreen mode

Sound familiar? The issues are always the same:

  • fetch() only throws on network failures – HTTP 404/500 need manual checking
  • No built-in timeoutsAbortController 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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... 😵
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Key Features That Solve Real Problems

Dual Timeouts

const api = createSafeFetch({
  timeoutMs: 5000,      // 5s per attempt
  totalTimeoutMs: 30000 // 30s total (including retries)
});
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

Retry-After Respect

// Server responds: 429 Too Many Requests, Retry-After: 60
// safe-fetch automatically waits 60 seconds before retry
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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 
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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)