Resilient API Consumption in Unreliable Enterprise Networks

Date:

Share post:

Enterprise networks are often noisy. VPNs, WAFs, proxies, mobile hotspots, and transient gateway hiccups can cause timeouts, packet loss, throttling, and abrupt connection resets. Designing resilient clients minimizes checkout/MACD friction, prevents duplicate actions, and keeps the UI responsive even when backends or the network are unstable.

We have a strong toolkit for making API calls, but how do we make them safe for users and painless for developers? Which stack should we choose? How do we cut duplication and keep code maintainable at enterprise scale? These questions matter when you have hundreds of endpoints: some triggered by CTAs, some on page load, others quietly prefetching data in the background, and a few that need streaming. There’s no one-size-fits-all — each job has a best-fit approach. 

In this article, we’ll compare three common strategies — Fetch, Axios, and RTK Query — then help you choose the right toolset for your context. We’ll also share practical examples that save time and solve everyday problems developers face.

Goals

  • Resilience: Tolerate flaky networks (WAFs, proxies, mobile, VPN).
  • Correctness: Avoid duplicate writes, handle concurrency, and enforce contracts.
  • Simplicity: Centralize configuration, reduce boilerplate.
  • Observability: Trace requests, measure latency/errors/retries.
  • Security: Protect tokens, validate inputs/outputs.

What to Cover for Every API Call

Contract and Types

  • Generate types from OpenAPI/Swagger or validate with schemas at runtime.
  • Tools: openapi-typescript, openapi-generator, zod, or valibot.

Authentication and Headers

  • Attach tokens/cookies securely; rotate/refresh as needed.
  • Include correlation headers (e.g., X-Request-Id).

Timeouts

  • Explicit per-endpoint timeouts; keep below gateway/WAF limits.
  • Keep defaults tight (e.g., 8s); use shorter timeouts for read endpoints and slightly longer for write paths that touch several services.
    // api.ts
    import axios from 'axios';
    
    export const api = axios.create({
      baseURL: '/api',
      timeout: 8000, // sensible default; override per request where needed
      withCredentials: true,
      headers: { 'Content-Type': 'application/json' }
    });
    
    // Attach a requestId and simple timing metadata
    api.interceptors.request.use((config) => {
      (config as any).metadata = { start: Date.now() };
      config.headers = {
        ...config.headers,
        'X-Request-Id': crypto.randomUUID()
      };
      return config;
    });
    
    api.interceptors.response.use(
      (res) => {
        const meta = (res.config as any).metadata;
        if (meta) res.headers['x-client-latency-ms'] = String(Date.now() - meta.start);
        return res;
      },
      (error) => Promise.reject(error)
    );
    

  • Configure per-request timeouts and provide a friendly timeout error message.
    // timeouts.ts
    import { api } from './api';
    
    export async function fetchCatalog(signal?: AbortSignal) {
      // Read path: lower timeout
      return api.get('/catalog', { timeout: 5000, signal, timeoutErrorMessage: 'Catalog timed out' });
    }
    
    export async function submitOrder(payload: unknown, signal?: AbortSignal) {
      // Write path: a bit higher, but still below gateway/WAF timeouts
      return api.post('/orders', payload, { timeout: 12000, signal, timeoutErrorMessage: 'Order submission timed out' });
    }
    

Retries and Backoff

  • Only retry idempotent GET/PUT/DELETE or POST with an idempotency key.
  • Back off with jitter to avoid thundering herds; cap max attempts.
    // retry.ts
    import axios, { AxiosError } from 'axios';
    import { api } from './api';
    
    const MAX_RETRIES = 3;
    const BASE_DELAY_MS = 300;
    
    //could you debounce lib instead 
    function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); }
    
    function isIdempotent(method?: string) {
      return ['get', 'head', 'options', 'put', 'delete'].includes((method || '').toLowerCase());
    }
    
    function hasIdempotencyKey(headers?: any) {
      const key = headers?.['Idempotency-Key'] || headers?.['idempotency-key'];
      return Boolean(key);
    }
    
    function shouldRetry(error: AxiosError) {
      if (!error.config) return false;
      const status = error.response?.status;
      const transient =
        !status || [408, 425, 429, 500, 502, 503, 504].includes(status) || (error.code === 'ECONNABORTED');
      const safeMethod = isIdempotent(error.config.method || '');
      const postIsSafe = (error.config.method || '').toLowerCase() === 'post' && hasIdempotencyKey(error.config.headers);
      return transient && (safeMethod || postIsSafe);
    }
    
    api.interceptors.response.use(undefined, async (error: AxiosError) => {
      const config: any = error.config || {};
      if (!shouldRetry(error)) return Promise.reject(error);
    
      config._retryCount = (config._retryCount ?? 0) + 1;
      if (config._retryCount > MAX_RETRIES) return Promise.reject(error);
    
      const jitter = Math.random() * 100;
      const delay = BASE_DELAY_MS * 2 ** (config._retryCount - 1) + jitter; // 300, 600, 1200 (+ jitter)
      await sleep(delay);
      return api.request(config);
    });
    

Circuit Breaker

  • Prevents hammering a failing downstream service. Trips open after N consecutive failures, cool down, then half-open to probe recovery.
    (config: AxiosRequestConfig): Promise> {
    const now = Date.now();

    if (this.state === ‘OPEN’) {
    if (now < this.nextAttemptAt) {
    const err = new axios.AxiosError(‘Circuit open’, ‘ECIRCUITOPEN’, config);
    return Promise.reject(err);
    }
    this.state=”HALF_OPEN”;
    }

    try {
    const res = await this.client.request(config);
    this.onSuccess();
    return res;
    } catch (err) {
    this.onFailure();
    throw err;
    }
    }

    private onSuccess() {
    this.failures = 0;
    this.state=”CLOSED”;
    }

    private onFailure() {
    if (this.state === ‘HALF_OPEN’) {
    this.trip();
    return;
    }
    this.failures += 1;
    if (this.failures >= this.opts.failureThreshold) this.trip();
    }

    private trip() {
    this.state=”OPEN”;
    this.nextAttemptAt = Date.now() + this.opts.cooldownMs;
    }
    }

    // usage
    import { api } from ‘./api’;
    export const breaker = new AxiosCircuitBreaker(api, { failureThreshold: 4, cooldownMs: 10000 });

    // Example call
    // await breaker.request({ method: ‘get’, url: ‘/inventory’ });
    ” data-lang=”application/typescript”>

    // circuitBreaker.ts
    import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
    import axios from 'axios';
    
    type State="CLOSED" | 'OPEN' | 'HALF_OPEN';
    
    export class AxiosCircuitBreaker {
      private state: State="CLOSED";
      private failures = 0;
      private nextAttemptAt = 0;
    
      constructor(
        private readonly client: AxiosInstance,
        private readonly opts = { failureThreshold: 5, cooldownMs: 15000 }
      ) {}
    
      async request(config: AxiosRequestConfig): Promise> {
        const now = Date.now();
    
        if (this.state === 'OPEN') {
          if (now < this.nextAttemptAt) {
            const err = new axios.AxiosError('Circuit open', 'ECIRCUITOPEN', config);
            return Promise.reject(err);
          }
          this.state="HALF_OPEN";
        }
    
        try {
          const res = await this.client.request(config);
          this.onSuccess();
          return res;
        } catch (err) {
          this.onFailure();
          throw err;
        }
      }
    
      private onSuccess() {
        this.failures = 0;
        this.state="CLOSED";
      }
    
      private onFailure() {
        if (this.state === 'HALF_OPEN') {
          this.trip();
          return;
        }
        this.failures += 1;
        if (this.failures >= this.opts.failureThreshold) this.trip();
      }
    
      private trip() {
        this.state="OPEN";
        this.nextAttemptAt = Date.now() + this.opts.cooldownMs;
      }
    }
    
    // usage
    import { api } from './api';
    export const breaker = new AxiosCircuitBreaker(api, { failureThreshold: 4, cooldownMs: 10000 });
    
    // Example call
    // await breaker.request({ method: 'get', url: '/inventory' });
    

Cancellation

  • Use AbortController (Axios supports the standard AbortSignal).
  • With Redux Toolkit createAsyncThunk, the abort signal is provided for you.
{ s.items = a.payload; s.status=”succeeded”; })
.addCase(fetchProducts.rejected, (s) => { s.status=”failed”; });
}
});

export default productsSlice.reducer;
” data-lang=”application/typescript”>

// products.slice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { api } from '../api';

export const fetchProducts = createAsyncThunk('products/fetch', async (_, { signal }) => {
  const res = await api.get('/products', { signal, timeout: 6000 });
  return res.data as { id: string; name: string }[];
});

const productsSlice = createSlice({
  name: 'products',
  initialState: { items: [] as { id: string; name: string }[], status: 'idle' as 'idle'|'loading'|'succeeded'|'failed' },
  reducers: {},
  extraReducers: (b) => {
    b.addCase(fetchProducts.pending, (s) => { s.status="loading"; })
     .addCase(fetchProducts.fulfilled, (s, a) => { s.items = a.payload; s.status="succeeded"; })
     .addCase(fetchProducts.rejected, (s) => { s.status="failed"; });
  }
});

export default productsSlice.reducer;

// ProductsList.tsx
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from './store';
import { fetchProducts } from './products.slice';

export function ProductsList() {
  const dispatch = useAppDispatch();
  const items = useAppSelector((s) => s.products.items);

  useEffect(() => {
    const promise = dispatch(fetchProducts());
    return () => {
      // Abort when the component unmounts or route changes
      promise.abort();
    };
  }, [dispatch]);

  return 
    {items.map((p) =>
  • {p.name}
  • )}
; }

Idempotency and Concurrency

  • Choose PUT/PATCH for idempotent updates when possible. For POSTs that create or change server state, include an idempotency key header to ensure the server applies the operation at most once, even under retries.
  • Use optimistic UI to immediately reflect intended changes, then reconcile or rollback on failure.
  • For conflicting updates, consider ETags + If-Match to avoid lost updates.

Optimistic update with idempotency key:

// checkout.slice.ts
import { createSlice, createAsyncThunk, nanoid } from '@reduxjs/toolkit';
import { api } from '../api';

type CartItem = { id: string; qty: number };
type State = { items: CartItem[]; pendingOps: Record };

const initialState: State = { items: [], pendingOps: {} };

export const updateCartItem = createAsyncThunk(
  'checkout/updateCartItem',
  async ({ id, qty, idempotencyKey }: { id: string; qty: number; idempotencyKey: string }, { signal, rejectWithValue }) => {
    try {
      const res = await api.put(`/cart/items/${id}`, { qty }, {
        signal,
        timeout: 10000,
        headers: { 'Idempotency-Key': idempotencyKey }
      });
      return res.data as CartItem;
    } catch (e: any) {
      return rejectWithValue({ id, qty, message: e?.message ?? 'update failed' });
    }
  }
);

const slice = createSlice({
  name: 'checkout',
  initialState,
  reducers: {
    startOptimisticUpdate(s, a: { payload: { id: string; qty: number; opId: string } }) {
      const { id, qty, opId } = a.payload;
      const idx = s.items.findIndex((it) => it.id === id);
      const prev = idx >= 0 ? { ...s.items[idx] } : undefined;
      if (idx >= 0) s.items[idx].qty = qty;
      s.pendingOps[opId] = { prev };
    },
    rollback(s, a: { payload: { opId: string } }) {
      const { prev } = s.pendingOps[a.payload.opId] || {};
      if (prev) {
        const idx = s.items.findIndex((it) => it.id === prev.id);
        if (idx >= 0) s.items[idx] = prev;
      }
      delete s.pendingOps[a.payload.opId];
    }
  },
  extraReducers: (b) => {
    b.addCase(updateCartItem.fulfilled, (s, a) => {
      // Confirm final state from server
      const updated = a.payload;
      const idx = s.items.findIndex((it) => it.id === updated.id);
      if (idx >= 0) s.items[idx] = updated;
      // Clean up any matching pending op if you track by id
    })
    .addCase(updateCartItem.rejected, (s, a) => {
      // Rollback is performed from UI where opId is known
    });
  }
});

export const { startOptimisticUpdate, rollback } = slice.actions;
export default slice.reducer;

// CartItem.tsx
import { useAppDispatch } from './store';
import { startOptimisticUpdate, updateCartItem, rollback } from './checkout.slice';

export function CartItem({ id, qty }: { id: string; qty: number }) {
  const dispatch = useAppDispatch();

  const onChangeQty = (nextQty: number) => {
    const opId = crypto.randomUUID();
    const idempotencyKey = opId; // reuse opId for Idempotency-Key

    // 1) optimistic update
    dispatch(startOptimisticUpdate({ id, qty: nextQty, opId }));

    // 2) server update with retry/cancellation baked via api config
    const thunk = dispatch(updateCartItem({ id, qty: nextQty, idempotencyKey }));

    // 3) rollback on failure
    thunk.unwrap().catch(() => dispatch(rollback({ opId })));
  };

  return (
    

Qty: {qty}

); }

Notes for checkout/MACD:

  • Use the same idempotency key when retrying POST/PUT requests to prevent duplicate order lines or duplicated MACD changes.
  • For MACD, consider representing each change as a deterministic resource (PUT /services/{id}/config), so retries remain safe.
  • If the server supports ETag, add If-Match headers to ensure you aren’t overwriting concurrent updates.

Error Handling

  • Normalize errors to a single shape for UI; map known status codes to friendly messages.
    • One error shape – consistent fields your UI can rely on (message, status, retryable, requestId, details).
    • Deterministic mapping – known status codes and transport errors become clear, human-readable messages.
    • Separation of concerns – keep low-level errors in the transport; normalize at the boundary (thunks/RTK Query baseQuery), so retries/circuit breakers still work.

TypeScript example: normalizer and usage (Axios and RTK Query):

// error.ts
import type { AxiosError } from 'axios';

export type AppError = {
  code: string;
  status?: number;
  message: string;
  requestId?: string;
  retryable: boolean;
  details?: unknown;
};

const FRIENDLY: Record = {
  400: { code: 'BAD_REQUEST',       message: 'Request is invalid. Please check inputs.',         retryable: false },
  401: { code: 'UNAUTHENTICATED',   message: 'You’re signed out. Please sign in and try again.', retryable: false },
  403: { code: 'FORBIDDEN',         message: 'You don’t have permission to do this.',            retryable: false },
  404: { code: 'NOT_FOUND',         message: 'The resource was not found.',                      retryable: false },
  409: { code: 'CONFLICT',          message: 'Your changes conflict with another update.',       retryable: true  },
  412: { code: 'PRECONDITION',      message: 'Version mismatch. Refresh and try again.',         retryable: true  },
  422: { code: 'VALIDATION',        message: 'Some fields need attention.',                      retryable: false },
  425: { code: 'TOO_EARLY',         message: 'Service not ready. Please try again shortly.',     retryable: true  },
  429: { code: 'RATE_LIMITED',      message: 'Too many requests. Please wait and retry.',        retryable: true  },
  500: { code: 'SERVER_ERROR',      message: 'We hit a snag. Please try again.',                 retryable: true  },
  502: { code: 'BAD_GATEWAY',       message: 'Upstream gateway error. Try again.',               retryable: true  },
  503: { code: 'UNAVAILABLE',       message: 'Service is temporarily unavailable.',              retryable: true  },
  504: { code: 'GATEWAY_TIMEOUT',   message: 'Service timed out. Please try again.',             retryable: true  }
};

export function normalizeAxiosError(e: unknown): AppError {
  const ax = e as AxiosError;
  const status = ax?.response?.status;
  const requestId =
    (ax?.response?.headers?.['x-request-id'] as string) ||
    (ax?.response?.headers?.['x-correlation-id'] as string);

  // Transport-level signals
  if ((ax as any)?.code === 'ECONNABORTED') {
    return { code: 'TIMEOUT', message: 'Request timed out. Please try again.', retryable: true, status, requestId, details: ax?.response?.data };
  }
  if ((ax as any)?.code === 'ERR_NETWORK' || !status) {
    return { code: 'NETWORK', message: 'Network issue detected. Check connection and retry.', retryable: true, status, requestId, details: ax?.message };
  }
  if ((ax as any)?.code === 'ECIRCUITOPEN') {
    return { code: 'CIRCUIT_OPEN', message: 'Service is recovering. Please try again shortly.', retryable: true, status, requestId };
  }

  // HTTP mapping
  const preset = status ? (FRIENDLY[status] ?? defaultFor(status)) : defaultFor(undefined);
  const serverMessage = ax?.response?.data?.message || ax?.response?.data?.error || ax?.message;
  return {
    code: preset.code,
    status,
    message: serverMessage || preset.message,
    retryable: preset.retryable,
    requestId,
    details: ax?.response?.data
  };
}

function defaultFor(status?: number) {
  if (!status) return { code: 'UNKNOWN', message: 'Unexpected error. Please try again.', retryable: true };
  if (status >= 500) return { code: 'SERVER_ERROR', message: 'We hit a snag. Please try again.', retryable: true };
  return { code: 'CLIENT_ERROR', message: 'Something went wrong with the request.', retryable: false };
}

Use with Axios thunks (keep retries/circuit breakers in transport; normalize at boundary):

// orders.thunk.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { api } from './api';
import { normalizeAxiosError, type AppError } from './error';

export const fetchOrder = createAsyncThunk(
  'orders/fetch',
  async (id, { signal, rejectWithValue }) => {
    try {
      const res = await api.get(`/orders/${id}`, { signal, timeout: 8000 });
      return res.data;
    } catch (err) {
      return rejectWithValue(normalizeAxiosError(err));
    }
  }
);

Use with RTK Query baseQuery (all errors become AppError):

// rtkAxiosBaseQuery.ts
import type { BaseQueryFn } from '@reduxjs/toolkit/query';
import type { AxiosRequestConfig } from 'axios';
import { api } from './api';
import { normalizeAxiosError, type AppError } from './error';

export const axiosBaseQuery = (): BaseQueryFn =>
  async (config) => {
    try {
      const res = await api.request(config);
      return { data: res.data };
    } catch (e) {
      return { error: normalizeAxiosError(e) };
    }
  };

Simple UI usage example:

// ErrorBanner.tsx
import type { AppError } from './error';

export function ErrorBanner({ error }: { error: AppError }) {
  return (
    

{error.message} {error.requestId && · Ref: {error.requestId}} {error.retryable && }

); }

Observability

  • Log requestId, latency, retry count, and breaker state; integrate with your APM.

Performance and Caching

  • Deduplicate in-flight requests
  • Cache read data (with TTL)
  • Invalidate with tags (RTK Query)

Deduplicate in-flight requests (Axios). Avoid duplicate GETs fired concurrently from multiple components by reusing the same Promise.

// inflight.ts
import { api } from './api';

const inflight = new Map>();

function stable(params: Record = {}) {
  return JSON.stringify(Object.keys(params).sort().reduce((a, k) => (a[k] = params[k], a), {} as any));
}

export function getWithDedupe(url: string, params: Record = {}, timeout = 6000) {
  const key = `GET ${url}?${stable(params)}`;
  const existing = inflight.get(key);
  if (existing) return existing as Promise<{ data: T }>;

  const p = api.get(url, { params, timeout })
    .finally(() => inflight.delete(key));
  inflight.set(key, p as Promise);
  return p;
}

// usage
// const { data } = await getWithDedupe('/products', { q: 'router' });

Simple read cache with TTL (Axios). Cache successful GET responses for a time window to reduce network load.

// ttlCache.ts
import { api } from './api';

type Entry = { expires: number; data: T };
const cache = new Map>();

function now() { return Date.now(); }
function key(url: string, params?: Record) {
  return `GET ${url}:${JSON.stringify(params ?? {})}`;
}

export async function getCached(url: string, params?: Record, ttlMs = 30_000) {
  const k = key(url, params);
  const hit = cache.get(k);
  if (hit && hit.expires > now()) {
    return { data: hit.data as T, fromCache: true as const };
  }
  const res = await api.get(url, { params, timeout: 6000 });
  cache.set(k, { data: res.data, expires: now() + ttlMs });
  return { data: res.data, fromCache: false as const };
}

// usage
// const res = await getCached('/products', { q: '5g' }, 60000);

RTK Query: caching, de-duplication, and tag-based invalidation. RTK Query caches responses, dedupes in-flight requests, and lets you invalidate specific data via tags.

// services/products.api.ts
import { createApi } from '@reduxjs/toolkit/query/react';
import { axiosBaseQuery } from './rtkAxiosBaseQuery'; // wraps your Axios instance

type Product = { id: string; name: string; price: number };

export const productsApi = createApi({
  reducerPath: 'productsApi',
  baseQuery: axiosBaseQuery(),
  tagTypes: ['Product'],
  keepUnusedDataFor: 60, // seconds
  endpoints: (build) => ({
    getProducts: build.query({
      query: () => ({ url: '/products', method: 'GET', timeout: 5000 }),
      providesTags: (result) =>
        result
          ? [
              ...result.map((p) => ({ type: 'Product' as const, id: p.id })),
              { type: 'Product' as const, id: 'LIST' }
            ]
          : [{ type: 'Product', id: 'LIST' }]
    }),
    updateProduct: build.mutation & Pick>({
      query: ({ id, ...patch }) => ({
        url: `/products/${id}`,
        method: 'PATCH',
        data: patch,
        headers: { 'Idempotency-Key': crypto.randomUUID() }
      }),
      // Invalidate the specific product and the list
      invalidatesTags: (result, error, { id }) => [{ type: 'Product', id }, { type: 'Product', id: 'LIST' }]
    }),
    createProduct: build.mutation>({
      query: (body) => ({ url: '/products', method: 'POST', data: body }),
      invalidatesTags: [{ type: 'Product', id: 'LIST' }]
    })
  })
});

export const { useGetProductsQuery, useUpdateProductMutation, useCreateProductMutation } = productsApi;

// usage in a component
// const { data, isFetching } = useGetProductsQuery();
// const [updateProduct] = useUpdateProductMutation();

Security and Compliance

  • Don’t log PII.
  • Redact sensitive fields. 
  • Follow CSP/CORS.
  • Protect tokens in memory only.

Quick Checklist for Enterprise Networks

  • Timeouts are defined per request, always below gateway/WAF limits.
  • Retries limited, exponential backoff, and jitter.
  • Circuit breaker enabled per service domain.
  • All requests are cancellable; navigation aborts in-flight calls.
  • Optimistic UI with rollback and server-side idempotency keys.
  • Observability: track retry counts, breaker state, and timeout rates.

Simple end-to-end example in one place:

// endToEnd.ts
import { breaker } from './circuitBreaker';

export async function safeGet(url: string, signal?: AbortSignal, timeout = 6000) {
  // GET with timeout, retries (via interceptor), and circuit breaker
  return breaker.request({ method: 'GET', url, signal, timeout }).then((r) => r.data);
}

export async function safeIdempotentPost(url: string, body: unknown, signal?: AbortSignal, timeout = 10000) {
  const idempotencyKey = crypto.randomUUID();
  return breaker
    .request({ method: 'POST', url, data: body, signal, timeout, headers: { 'Idempotency-Key': idempotencyKey } })
    .then((r) => r.data);
}

Decision Guide (Quick Comparison)

Variants: Fetch vs. Axios vs. RTK Query (where each fits)

Fetch (Native)

  • Pros: built-in, standards-based, streaming support, Request/AbortController.
  • Cons: no interceptors, manual timeouts (via AbortController), manual JSON/error parsing, no built-in retries.
  • Use when: you want minimal dependencies or need streaming; prepared to build wrappers.
  • Doc: https://developer.mozilla.org/docs/Web/API/Fetch_API

Axios (Library)

  • Pros: interceptors, concise API, per-request timeout, JSON by default, good error objects, and upload/download progress.
  • Cons: no built-in caching/dedupe, you manage retries and invalidation yourself.
  • Use when: you need centralized control (headers, auth, tracing) and enterprise behaviors (retries, circuit breaker).
  • Doc: https://axios-http.com/docs/intro

RTK Query (Redux Toolkit Query)

  • Pros: cache/dedupe, polling, refetch on focus/reconnect, optimistic updates, auto-cancel, generated hooks, integration with Redux DevTools.
  • Cons: learning curve for tags/invalidation; you’ll still choose fetch or axios as the underlying transport.
  • Use when: you want to standardize data fetching with first-class caching and request lifecycle management.
  • Doc: https://redux-toolkit.js.org/rtk-query/overview

Decision Guide

Choose Axios if you need:

  • You need fine-grained control of interceptors, custom circuit breakers, corporate proxy/WAF nuances, and non-standard auth headers.
  • You already have significant middleware, logging, and observability built around Axios.
  • You prefer explicit ownership of caching, dedupe, and invalidation logic.

Choose RTK Query (with Axios under the hood) if you need:

  • You want built-in caching, de-duplication, polling, refetch on focus/reconnect, and request lifecycle management.
  • You prefer generated hooks and minimal boilerplate for data fetching.
  • You want first-class optimistic updates and cancellation via onQueryStarted and patchQueryData.
  • You can standardize on a baseQuery (fetch or axios) and tags for cache invalidation.

Choose plain Fetch if you:

  • Want zero dependency and are comfortable building wrappers for timeouts, retries, and error handling.

Good compromise: Keep Axios as the transport and use RTK Query’s axios-compatible baseQuery so you retain interceptors, timeouts, retries, and circuit breaker logic while gaining RTK Query’s caching and lifecycle.

Using RTK Query with Axios:

// rtkAxiosBaseQuery.ts
import type { BaseQueryFn } from '@reduxjs/toolkit/query';
import type { AxiosRequestConfig, AxiosError } from 'axios';
import { api } from './api'; // your axios instance with interceptors

export const axiosBaseQuery =
  (): BaseQueryFn =>
  async (config) => {
    try {
      const result = await api.request(config);
      return { data: result.data };
    } catch (axiosError) {
      const err = axiosError as AxiosError;
      return {
        error: { status: err.response?.status, data: err.response?.data || err.message }
      };
    }
  };

Feature Fetch (native) Axios (library) RTK Query
(Redux Toolkit Query)
Built into the platform
Streaming response (browser)
Interceptors ◐ (via baseQuery/transport)
Per-request timeout ◐ (via transport)
Abort/cancel requests ✓ (AbortController) ✓ (AbortSignal) ✓ (auto-cancel)
Built-in retries ◐ (retry wrapper)
Caching and de-duplication
Refetch on focus/reconnect
Polling
Optimistic updates
Generated React hooks
Redux DevTools integration
Upload/download progress (browser)
Rich error objects ◐ (standardized shape)
Automatic JSON parsing — (res.json)
Cache invalidation by tags
Request de-duplication
Works with custom auth headers ✓ (manual) ✓ (via baseQuery)

Key documents:

Source link

spot_img

Related articles

Ball x Pit’s First Free Content Update Bounces Onto Switch Next Week

https://www.youtube.com/watch?v=vgGaNZTeEIg Just when we thought we were out, BALL x PIT drags us back in. Yes, after being teased back...

Gollumfun (Part 2) – Darknet Diaries

Full Transcript Brett Johnson, AKA Gollumfun (twitter.com/GOllumfun) was involved with the websites Counterfeit Library and...