DevCerts logo DevCerts

Frontend Without Chaos: API Clients, Tokens, Errors, and Refresh Logic

A frontend API client is not just a wrapper around Axios or fetch. In production, it becomes the boundary where authentication state, retry behavior, error normalization, and request isolation either stay predictable or turn into hidden application-wide chaos.

TypeScript
Frontend Without Chaos: API Clients, Tokens, Errors, and Refresh Logic

A frontend API client looks simple until authentication expires in the middle of user activity. One request receives 401, five more fail at the same time, refresh logic starts racing itself, and a harmless interceptor becomes a global side effect that is hard to debug.

The core mistake is treating the API layer as a convenience utility. In a real application, it is a state boundary. It decides how requests are authenticated, how errors are shaped, when requests may be retried, and how the UI learns that a user session is no longer valid. That logic needs structure, not scattered try/catch blocks and duplicated token handling.

What usually goes wrong

Most frontend API chaos comes from mixing three concerns in every feature module:

  • Transport details: fetch, Axios, headers, timeouts, JSON parsing

  • Authentication lifecycle: access token, refresh token, logout, expired sessions

  • Product behavior: showing errors, retrying actions, redirecting users

At first, this looks productive. A component calls axios.get('/profile'), catches an error, checks the status code, and redirects on 401. Then another module copies the same pattern. Then a background request adds a custom retry. Then refresh token logic lands in an interceptor that every request depends on.

The failure mode is not immediate. It appears later, when several requests fail together, a refresh request also returns 401, or a retry submits the same mutation twice.

The API client should be the only place where transport-level failure becomes application-level meaning.

That does not mean every UI error message belongs in the client. It means the client should return predictable results, classify failures consistently, and keep token refresh behavior isolated from feature code.

Shortcut vs production-grade API client

Area

Shortcut implementation

Production-grade implementation

Token attachment

Set header manually per request

Central request interceptor or wrapper

Error format

Raw Axios or fetch error

Normalized domain error object

401 handling

Redirect from any failed request

Single refresh flow, then controlled logout

Refresh concurrency

Every failed request refreshes independently

Single-flight refresh shared by waiting requests

Retry behavior

Retry everything after refresh

Retry only safe or explicitly allowed requests

Debugging

Depends on call site

Central logging hooks and request metadata

Testability

Mock each feature request separately

Test client behavior once, feature logic separately

The goal is not abstraction for its own sake. The goal is to make failure behavior boring.

Normalize errors before they reach the app

Axios and fetch expose errors differently. Axios rejects non-2xx responses. fetch resolves them and expects you to check response.ok. If your application consumes both styles directly, every feature must know transport details.

Create one application error shape instead.

type ApiErrorCode =
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'VALIDATION'
  | 'NETWORK'
  | 'SERVER'
  | 'UNKNOWN';

export class ApiError extends Error {
  constructor(
    public code: ApiErrorCode,
    public status?: number,
    public details?: unknown
  ) {
    super(code);
  }
}

function classifyStatus(status: number): ApiErrorCode {
  if (status === 401) return 'UNAUTHORIZED';
  if (status === 403) return 'FORBIDDEN';
  if (status === 422) return 'VALIDATION';
  if (status >= 500) return 'SERVER';
  return 'UNKNOWN';
}

Now UI code can handle ApiError rather than checking whether the failure came from Axios, fetch, a timeout, or a backend validation response.

This also improves testing. Feature tests do not need to construct realistic Axios internals. They can assert behavior for VALIDATION, UNAUTHORIZED, or NETWORK.

Design refresh as a controlled state transition

Refresh logic is usually where API clients become fragile. The common bug is simple: several requests receive 401 at the same time, and each one sends a refresh request. Depending on backend behavior, this can invalidate tokens, overwrite newer credentials, or create a loop.

Refresh must be single-flight: while one refresh request is running, other failed requests should wait for the same result.

import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';

const api = axios.create({
  baseURL: '/api',
  timeout: 15000,
});

let accessToken: string | null = null;
let refreshPromise: Promise<string> | null = null;

function setAccessToken(token: string | null) {
  accessToken = token;
}

api.interceptors.request.use((config) => {
  if (accessToken && !config.headers.Authorization) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }

  return config;
});

This request interceptor does one thing: attach the current access token. It does not refresh, redirect, show notifications, or mutate UI state.

The response interceptor can then handle 401 in one place.

type RetryableRequest = InternalAxiosRequestConfig & {
  _retry?: boolean;
  skipAuthRefresh?: boolean;
};

async function refreshAccessToken(): Promise<string> {
  if (!refreshPromise) {
    refreshPromise = axios
      .post('/api/auth/refresh', undefined, { withCredentials: true })
      .then((response) => response.data.accessToken)
      .finally(() => {
        refreshPromise = null;
      });
  }

  return refreshPromise;
}

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const response = error.response;
    const original = error.config as RetryableRequest | undefined;

    if (!response || !original) {
      throw new ApiError('NETWORK');
    }

    const shouldRefresh =
      response.status === 401 &&
      !original._retry &&
      !original.skipAuthRefresh &&
      !original.url?.includes('/auth/refresh');

    if (!shouldRefresh) {
      throw new ApiError(classifyStatus(response.status), response.status, response.data);
    }

    original._retry = true;

    try {
      const token = await refreshAccessToken();
      setAccessToken(token);
      original.headers.Authorization = `Bearer ${token}`;

      return api(original);
    } catch {
      setAccessToken(null);
      throw new ApiError('UNAUTHORIZED', 401);
    }
  }
);

Several details matter here:

  • _retry prevents the same request from refreshing forever.

  • skipAuthRefresh allows login, logout, and refresh requests to bypass refresh behavior.

  • The refresh endpoint is not retried through the same refresh logic.

  • Only one refresh request is active at a time.

  • Logout is a consequence of refresh failure, not every 401.

This pattern does not remove the need for backend session rules. It simply prevents the frontend from amplifying one expired token into several competing refresh attempts.

Be careful with automatic request replay

Retrying after refresh is useful for read requests. It is riskier for mutations.

A retried GET /me is normally harmless. A retried POST /orders may create duplicate work if the backend processed the original request but the frontend received an authentication or network failure afterward. Even when the backend rejects the unauthenticated request before execution, the frontend should not assume every mutation is safe to replay.

A practical approach:

  1. Automatically replay idempotent reads after successful refresh.

  2. Replay mutations only when the backend supports idempotency keys or the operation is explicitly safe.

  3. Avoid retrying file uploads, payment actions, destructive actions, and long-running commands by default.

  4. Use request metadata to opt in or out.

type ApiRequestOptions = {
  auth?: boolean;
  retryAfterRefresh?: boolean;
};

export function getProfile() {
  return api.get('/me', {
    retryAfterRefresh: true,
  } as ApiRequestOptions);
}

export function createOrder(payload: unknown, idempotencyKey: string) {
  return api.post('/orders', payload, {
    headers: {
      'Idempotency-Key': idempotencyKey,
    },
    retryAfterRefresh: true,
  } as ApiRequestOptions);
}

The exact typing depends on how you extend Axios configuration, but the principle matters more than the syntax: retry policy should be explicit for operations where duplicate execution would be costly.

The fetch version: no interceptors, same architecture

fetch does not have built-in interceptors, but the same design works with a wrapper. In many teams, this is easier to reason about because all behavior is visible in one function.

async function request<T>(
  input: RequestInfo,
  init: RequestInit & { retryAfterRefresh?: boolean } = {}
): Promise<T> {
  const response = await fetch(input, {
    ...init,
    headers: {
      ...init.headers,
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
    },
  });

  if (response.ok) {
    return response.json() as Promise<T>;
  }

  if (response.status === 401 && init.retryAfterRefresh !== false) {
    const token = await refreshAccessToken();
    setAccessToken(token);

    const retry = await fetch(input, {
      ...init,
      headers: {
        ...init.headers,
        Authorization: `Bearer ${token}`,
      },
    });

    if (retry.ok) {
      return retry.json() as Promise<T>;
    }

    throw new ApiError(classifyStatus(retry.status), retry.status, await retry.json().catch(() => null));
  }

  throw new ApiError(classifyStatus(response.status), response.status, await response.json().catch(() => null));
}

The trade-off is clear. Axios gives you interceptor hooks and a richer error model. fetch gives you lower-level control and fewer hidden behaviors. Either can work if refresh, retry, and error normalization are treated as architecture, not call-site convenience.

Token storage is an architecture decision

Frontend code should not casually decide where tokens live. The storage model affects security, refresh behavior, cross-tab synchronization, and server requirements.

Common patterns include:

Storage model

Access from JavaScript

Survives reload

Main operational concern

In-memory access token

Requires refresh after reload

localStorage access token

Higher exposure if injected scripts run

HttpOnly refresh cookie

Requires backend cookie and CSRF strategy

Backend-for-frontend session

More server-side session coordination

There is no universal client-only answer. A common production pattern is a short-lived in-memory access token plus a refresh mechanism backed by a secure server-controlled session or cookie. But the right design depends on the backend, browser constraints, mobile needs, domain structure, and threat model.

What matters for the API client is that token access is centralized. Components should not read storage, parse tokens, or decide refresh behavior.

Testing the API client like infrastructure

An API client with refresh logic should be tested as infrastructure. Unit tests for components are not enough because most failures happen across requests.

Test these scenarios directly:

  • A normal request attaches the access token.

  • One expired request refreshes and retries once.

  • Five concurrent expired requests trigger one refresh request.

  • Refresh failure clears auth state and returns UNAUTHORIZED.

  • The refresh endpoint does not recursively refresh itself.

  • Non-idempotent requests are not replayed unless explicitly allowed.

  • Network errors are normalized separately from HTTP errors.

These tests prevent regressions when teams add new endpoints, change authentication flows, or migrate from Axios to fetch.

What to adopt first

You do not need to rewrite the whole frontend at once. The highest-return sequence is usually:

  1. Create one exported API client and stop importing raw Axios or calling raw fetch in feature modules.

  2. Normalize errors into an application-level error type.

  3. Centralize access token attachment.

  4. Add single-flight refresh handling.

  5. Make retry policy explicit.

  6. Add tests for concurrent 401 behavior.

For engineers who work on this kind of frontend infrastructure in TypeScript-heavy applications, the Senior TypeScript Developer certification is the most relevant DevCerts track to review.


Conclusion

A reliable frontend API client is less about choosing Axios or fetch and more about owning the lifecycle of a request. Authentication, refresh, retries, and error classification are cross-cutting concerns. If they are spread across components, they will eventually conflict.

The practical target is simple: feature code should describe product actions, while the API client handles transport rules. A request either succeeds, fails with a normalized error, refreshes once under controlled conditions, or ends the session predictably. That boundary is what keeps the frontend maintainable when the application grows, the backend evolves, and failures happen at the worst possible time.