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.