A Robust REST API Client in Vue 3: Axios Interceptors, Pinia, Retries & Token Refresh

A Robust REST API Client in Vue 3: Axios Interceptors, Pinia, Retries & Token Refresh

Reading time: ~10 min

Front-end apps fail where networks are flaky and tokens expire. This article shows how to build a production-ready REST client for Vue 3 with Axios and Pinia that gracefully handles 401s, queues requests during refresh, retries transient errors with backoff, and cancels in-flight calls on navigation.

You’ll get a clean architecture typed services, a single Axios instance, and centralized error parsing so features ship faster and bugs surface sooner. If you’ve ever sprinkled try/catch across components or fought duplicated headers, this is your path to a resilient, testable API layer.

Architecture goals

  • Centralized Axios instance
  • Typed endpoints (TS)
  • Auto-refresh 401s
  • Exponential backoff retries
  • Request cancellation (user navigates away)
  • Toasting/snackbar from a single place

Pinia auth store

// stores/auth.ts
import { defineStore } from 'pinia';

export const useAuth = defineStore('auth', {
  state: () => ({ accessToken: '' as string, refreshToken: '' as string }),
  actions: {
    setTokens(at: string, rt: string) { this.accessToken = at; this.refreshToken = rt; },
    clear() { this.accessToken = ''; this.refreshToken = ''; },
  },
});

Axios instance with interceptors

// lib/api.ts
import axios, { AxiosError } from 'axios';
import { useAuth } from '@/stores/auth';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
});

let refreshing = false;
let pending: Array<() => void> = [];

function onRefreshed(token: string) {
  pending.forEach(cb => cb());
  pending = [];
}

api.interceptors.request.use((config) => {
  const auth = useAuth();
  if (auth.accessToken) {
    config.headers.Authorization = `Bearer ${auth.accessToken}`;
  }
  // Add idempotency key for POST/PUT if needed
  if (config.method && ['post','put','patch'].includes(config.method)) {
    config.headers['Idempotency-Key'] = crypto.randomUUID();
  }
  return config;
});

api.interceptors.response.use(
  r => r,
  async (error: AxiosError) => {
    const auth = useAuth();
    const original = error.config!;
    const status = error.response?.status;

    // Simple retry w/ backoff for 5xx/429
    const shouldRetry = [429, 500, 502, 503].includes(status ?? 0);
    original.__retryCount = (original as any).__retryCount ?? 0;
    if (shouldRetry && original.__retryCount < 3) {
      await new Promise(res => setTimeout(res, 2 ** original.__retryCount * 400));
      (original as any).__retryCount++;
      return api(original);
    }

    // 401: try refresh once
    if (status === 401 && !original.__isRetryRequest && auth.refreshToken) {
      if (!refreshing) {
        refreshing = true;
        try {
          const { data } = await axios.post(`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`, {
            refreshToken: auth.refreshToken,
          });
          auth.setTokens(data.accessToken, data.refreshToken);
          refreshing = false;
          onRefreshed(data.accessToken);
        } catch {
          refreshing = false;
          auth.clear();
          return Promise.reject(error);
        }
      }
      return new Promise((resolve) => {
        pending.push(async () => {
          original.headers = { ...(original.headers || {}), Authorization: `Bearer ${useAuth().accessToken}` };
          original.__isRetryRequest = true;
          resolve(api(original));
        });
      });
    }

    // One place to show toasts/snackbars
    // showToast(parseAxiosError(error));
    return Promise.reject(error);
  }
);

export default api;

Typed endpoint helpers

// services/users.ts
import api from '@/lib/api';

export type User = { id: string; email: string };
export async function fetchUsers(signal?: AbortSignal): Promise<User[]> {
  const { data } = await api.get('/users', { signal });
  return data;
}

Cancellation in components

// Example.vue (script setup)
import { onBeforeUnmount } from 'vue';
import { fetchUsers } from '@/services/users';

const controller = new AbortController();
fetchUsers(controller.signal);

onBeforeUnmount(() => controller.abort());

Error parsing helper (user-friendly)

export function parseAxiosError(e: unknown): string {
  if (axios.isAxiosError(e)) {
    return e.response?.data?.message ?? e.message;
  }
  return 'Unexpected error';
}

Bonus: ETag caching

Have the server return ETag and support If-None-Match for cheap 304s.

FAQ

A: Centralize in response interceptor, then forward clean messages to your UI toast.

A: 2–3 with exponential backoff is a common balance.

© 2025 All rights reserved | made with by Obeydi using