← Back to Blog

How to Handle Axios Errors in TypeScript (Complete Guide)

By Nark Team

Wrap every axios.get(), axios.post(), and other request calls in a try-catch block, then use axios.isAxiosError(error) to narrow the error type before inspecting error.response, error.request, or error.message. Axios throws an AxiosError for any non-2xx response by default, and also throws for network failures and request setup problems. Each of these three error categories requires different handling.

Quick Answer: Catch axios errors with try-catch and narrow using axios.isAxiosError(error). Check error.response for HTTP errors (4xx/5xx), error.request for network failures, and error.message for setup errors. To verify your entire codebase handles axios errors correctly: npx nark --tsconfig ./tsconfig.json


Does Axios Throw on 4xx and 5xx Responses?

Yes. According to the axios documentation, axios throws an AxiosError for any response where the status code falls outside the 2xx range. This means 400 Bad Request, 401 Unauthorized, 404 Not Found, 429 Too Many Requests, and 500 Internal Server Error all throw by default.

This behavior is controlled by the validateStatus config option. The default is:

// axios default behavior
validateStatus: function (status) {
  return status >= 200 && status < 300;
}

Any status outside that range throws. This catches developers off guard because fetch() does not throw on 4xx/5xx. If you are migrating from fetch to axios, every API call now needs a try-catch.

import axios from 'axios';

// This WILL throw on 404, 500, etc.
const response = await axios.get('https://api.example.com/users/999');

You can change this behavior per request:

const response = await axios.get('https://api.example.com/users/999', {
  validateStatus: (status) => status < 500, // only throw on 5xx
});

if (response.status === 404) {
  // handle not found without a catch block
}

But the standard pattern is to keep the default and use try-catch.


What Does AxiosError Contain?

AxiosError extends JavaScript's Error class with three additional properties that tell you what went wrong and at which stage the failure occurred.

PropertyTypePresent WhenWhat It Tells You
error.responseAxiosResponseHTTP error (4xx/5xx)The server responded with an error. Contains status, data, headers.
error.requestXMLHttpRequest / http.ClientRequestNetwork errorThe request was sent but no response was received. DNS failure, timeout, connection refused.
error.messagestringAlwaysHuman-readable error description.
error.codestringNetwork/timeout errorsMachine-readable code: ECONNABORTED, ETIMEDOUT, ERR_NETWORK, ERR_CANCELED.
error.configAxiosRequestConfigAlwaysThe original request configuration. Useful for retry logic.

The key insight: these properties are mutually exclusive in a specific way.

  • HTTP error: error.response exists, error.request exists.
  • Network error: error.response is undefined, error.request exists.
  • Setup error: Both error.response and error.request are undefined.

Code that assumes error.response is always present will crash with a TypeError on network failures. This is one of the most common axios bugs in production.


How to Use axios.isAxiosError() for Type Narrowing

TypeScript catch blocks type the error as unknown by default (since TypeScript 4.4 with useUnknownInCatchVariables). You need axios.isAxiosError() to narrow the type before accessing axios-specific properties.

import axios, { AxiosError } from 'axios';

interface User {
  id: number;
  name: string;
  email: string;
}

async function getUser(id: number): Promise<User | null> {
  try {
    const response = await axios.get<User>(`https://api.example.com/users/${id}`);
    return response.data;
  } catch (error: unknown) {
    if (axios.isAxiosError(error)) {
      // TypeScript now knows this is AxiosError
      if (error.response) {
        // Server responded with 4xx or 5xx
        console.error(`HTTP ${error.response.status}: ${error.response.data}`);

        if (error.response.status === 404) {
          return null; // user not found
        }
        if (error.response.status === 429) {
          // rate limited - consider retry
          const retryAfter = error.response.headers['retry-after'];
          console.error(`Rate limited. Retry after ${retryAfter}s`);
        }
      } else if (error.request) {
        // Network error - request sent but no response received
        console.error(`Network error: ${error.code}`); // ETIMEDOUT, ERR_NETWORK, etc.
      } else {
        // Setup error - something wrong with the request config
        console.error(`Request setup error: ${error.message}`);
      }
    }
    throw error;
  }
}

The axios.isAxiosError() call is a TypeScript type guard. After it returns true, the compiler knows the error variable has response, request, code, and config properties. Without this guard, you get type errors or resort to unsafe type assertions.


How to Distinguish Network Errors from HTTP Errors

This is the most common source of production crashes with axios. HTTP errors and network errors both throw AxiosError, but they have fundamentally different shapes.

HTTP errors mean the server received your request and sent back an error response. You have a status code, headers, and usually a response body explaining what went wrong.

Network errors mean the request never reached the server (or the response never came back). There is no status code, no headers, no body. The error.response property is undefined.

import axios from 'axios';

async function fetchWithErrorClassification(url: string) {
  try {
    return await axios.get(url);
  } catch (error: unknown) {
    if (!axios.isAxiosError(error)) {
      throw error; // not an axios error at all
    }

    if (error.response) {
      // HTTP Error - server responded
      // error.response.status: 400, 401, 403, 404, 500, etc.
      // error.response.data: response body (often JSON with error details)
      // error.response.headers: response headers
      throw new HttpError(error.response.status, error.response.data);
    }

    if (error.request) {
      // Network Error - no response received
      // error.code: 'ECONNABORTED' | 'ETIMEDOUT' | 'ERR_NETWORK' | 'ERR_CANCELED'
      // error.message: human-readable description
      throw new NetworkError(error.code ?? 'UNKNOWN', error.message);
    }

    // Setup Error - request never sent
    // Bad URL, unsupported protocol, circular reference in data
    throw new ConfigError(error.message);
  }
}

The error.code field on network errors gives you machine-readable codes:

error.codeMeaningCommon Cause
ECONNABORTEDConnection abortedRequest exceeded timeout config
ETIMEDOUTConnection timed outServer not responding, slow network
ERR_NETWORKNetwork errorDNS failure, no internet, CORS (browser)
ERR_CANCELEDRequest canceledAbortController.abort() called
ERR_BAD_REQUESTBad request configInvalid URL, malformed data

How to Retry Failed Axios Requests

For transient failures (network timeouts, 429 rate limits, 503 service unavailable), retrying with exponential backoff is the standard pattern. The axios-retry library handles this.

import axios from 'axios';
import axiosRetry from 'axios-retry';

const client = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
});

axiosRetry(client, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay, // 1s, 2s, 4s
  retryCondition: (error) => {
    // Retry on network errors and 429/5xx
    return (
      axiosRetry.isNetworkOrIdempotentRequestError(error) ||
      error.response?.status === 429
    );
  },
  shouldResetTimeout: true, // reset timeout on each retry
  onRetry: (retryCount, error, requestConfig) => {
    console.warn(`Retry ${retryCount} for ${requestConfig.url}: ${error.message}`);
  },
});

// Now all requests through `client` auto-retry on transient failures
const response = await client.get('/users');

A few things to know about retry behavior:

  • axios-retry only retries idempotent methods by default (GET, PUT, HEAD, DELETE, OPTIONS). POST and PATCH are not retried automatically because repeating them could cause duplicates.
  • If you need to retry POST requests, use idempotency keys to prevent duplicate operations.
  • The Retry-After header from 429 responses tells you how long to wait. Respect it.

For manual retry without a library:

async function fetchWithRetry<T>(
  url: string,
  maxRetries = 3,
  baseDelay = 1000
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await axios.get<T>(url);
      return response.data;
    } catch (error: unknown) {
      if (!axios.isAxiosError(error)) throw error;

      const isRetryable =
        !error.response || // network error
        error.response.status === 429 ||
        error.response.status >= 500;

      if (!isRetryable || attempt === maxRetries) throw error;

      const delay = baseDelay * Math.pow(2, attempt);
      const retryAfter = error.response?.headers?.['retry-after'];
      const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;

      await new Promise((resolve) => setTimeout(resolve, waitMs));
    }
  }
  throw new Error('Unreachable');
}

The Wrong Way vs. the Right Way

Here is the pattern that causes production crashes. A bare axios call with no error handling:

// WRONG: unhandled axios call
async function getUsers() {
  const response = await axios.get('https://api.example.com/users');
  return response.data;
}

If the server returns 500 or the network drops, this function throws an unhandled AxiosError. If nothing upstream catches it, Node.js terminates the process with an unhandled promise rejection.

Here is the correct version:

// RIGHT: complete error handling with type narrowing
async function getUsers(): Promise<User[]> {
  try {
    const response = await axios.get<User[]>('https://api.example.com/users');
    return response.data;
  } catch (error: unknown) {
    if (axios.isAxiosError(error)) {
      if (error.response) {
        // HTTP error - log status and response body
        console.error(
          `API error ${error.response.status}:`,
          error.response.data
        );
      } else if (error.request) {
        // Network error - log the error code
        console.error(`Network error: ${error.code}`);
      } else {
        // Setup error
        console.error(`Request config error: ${error.message}`);
      }
    }
    throw error; // re-throw for upstream handling
  }
}

The difference: the correct version catches the error, inspects its shape to determine what went wrong, logs actionable information, and re-throws for upstream handlers. The wrong version just crashes.


Axios Error Handling Best Practices Checklist

The three axios error types are HTTP errors (server responded with non-2xx), network errors (no response received), and setup errors (bad request configuration). Every axios call in your codebase should handle all three. Here is a checklist for production-ready error handling:

  • Wrap every axios call in try-catch. No bare axios.get() or axios.post() calls.
  • Use axios.isAxiosError() to narrow the error type. Do not access error.response without narrowing first.
  • Check error.response existence before reading status. Network errors have no response.
  • Set an explicit timeout on every client. The default 0 means no timeout -- requests hang forever.
  • Handle 429 rate limits with retry logic. Respect the Retry-After header.
  • Log the error.code for network errors. Codes like ECONNABORTED, ETIMEDOUT, and ERR_NETWORK tell you what went wrong.
  • Re-throw errors after handling. Let upstream callers (middleware, React error boundaries) decide the final response.
  • Use axios.create() with shared config. Centralize baseURL, timeout, and headers.
  • Add retry with exponential backoff for transient failures. Use axios-retry or a manual implementation.
  • Run npx nark to verify coverage. Catch unhandled calls you missed during code review.

How Do You Handle Axios Timeout Errors?

According to the axios documentation, the default timeout is 0, which means no timeout. A request to a slow or unresponsive server will hang indefinitely. Always set an explicit timeout in production code.

const client = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000, // 10 seconds
  headers: {
    'Content-Type': 'application/json',
  },
});

When a timeout fires, axios throws an AxiosError with error.code === 'ECONNABORTED'. Handle it:

if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
  console.error('Request timed out after 10s');
}

How to Automatically Check Axios Error Handling in Your Codebase

Manually auditing every axios call in a large codebase is not practical. Bare axios.get() calls without try-catch can hide in utility files, service layers, and middleware.

Tools like Nark check this automatically by scanning your TypeScript AST for unhandled error patterns. Nark's profile for axios defines postconditions for every method (get, post, put, delete, patch, head, request, postForm, putForm, patchForm) covering HTTP errors, network failures, rate limiting, and setup errors. When Nark finds an axios call without proper error handling, it reports it as a violation with the specific postcondition that is not being met.

npx nark --tsconfig ./tsconfig.json

Nark detects:

  • Bare axios.get() / axios.post() calls without try-catch
  • Generic catch blocks that do not use axios.isAxiosError() for type narrowing
  • Instance method calls (client.get(), client.post()) on axios.create() instances without error handling
  • Missing checks for error.response existence (the network error crash bug)

Frequently Asked Questions

Does axios throw on 404?

Yes. By default, axios throws an AxiosError for any response with a status code outside 200-299. A 404 Not Found triggers a throw just like a 500 Internal Server Error. You can change this with the validateStatus config option.

What is the difference between AxiosError and Error?

AxiosError extends Error with additional properties: response (the HTTP response, if one was received), request (the outgoing request object), code (machine-readable error code like ECONNABORTED), and config (the original request configuration). Use axios.isAxiosError() to distinguish between the two.

Should I use axios interceptors for error handling?

Interceptors are useful for cross-cutting concerns like logging, token refresh on 401, or global retry logic. But they should not replace per-call error handling. An interceptor handles the general case; your business logic needs to handle the specific case (e.g., 404 means "not found" in one context and "create it" in another).

Why is the axios catch error type unknown?

Since TypeScript 4.4 with useUnknownInCatchVariables (enabled by strict mode), catch clause variables are typed as unknown instead of any. This means you cannot access error.response directly -- TypeScript does not know the error is an AxiosError. Use axios.isAxiosError(error) as a type guard to narrow the error type, then access axios-specific properties safely. This is the correct pattern for handling the "axios catch error type unknown" issue.

Does axios.create() change error behavior?

No. Instances created with axios.create() have identical error behavior. They throw AxiosError on non-2xx responses, network failures, and setup errors. The instance just carries preset config (baseURL, headers, timeout). Error handling is the same.

How do I handle errors with axios in React?

The same patterns apply. Wrap your axios calls in try-catch inside your data-fetching functions (whether that is useEffect, React Query's queryFn, or SWR's fetcher). The error types and narrowing are identical regardless of framework.


Try It Now

npx nark --tsconfig ./tsconfig.json

Nark checks 160+ packages — including axios, prisma, stripe, and redis — for correct error handling, resource cleanup, and Nark profiles. The axios profile alone covers 12 postconditions across all HTTP methods, including the network error, rate limiting, and setup error patterns described in this guide.