Skip to main content

API Client Libraries: Axios vs ky vs ofetch in 2026

·PkgPulse Team
0

Every JavaScript application makes HTTP requests. The question is: should you use the built-in fetch API, or reach for a library? And if a library, which one?

We compared Axios, ky, and ofetch — the three most popular HTTP client libraries — using data from PkgPulse.

Why Not Just Use fetch?

Native fetch is available everywhere in 2026 — browsers, Node.js 22, Deno, and Bun. But it has ergonomic gaps:

// Native fetch — verbose, no automatic error handling
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice' }),
});

if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const data = await response.json(); // Type: any

Libraries fix these pain points: automatic JSON handling, error throwing on non-2xx responses, retries, interceptors, and TypeScript generics.

The Contenders

LibraryWeekly DownloadsSize (gzip)Built On
Axios50M+13KBXMLHttpRequest + Node adapter
ky2M3.3KBNative fetch
ofetch8M3KBNative fetch

Axios

The veteran. Axios has been the default HTTP client since 2014. Massive ecosystem, familiar API, works everywhere.

import axios from 'axios';

// POST with automatic JSON serialization
const { data } = await axios.post<User>('/api/users', {
  name: 'Alice',
  email: 'alice@example.com',
});

// Interceptors for auth
axios.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${token}`;
  return config;
});

Strengths

  • Massive ecosystem — Most tutorials and examples use Axios
  • Interceptors — Request and response interceptors for auth, logging, etc.
  • Request cancellation — Via AbortController (and legacy CancelToken)
  • Progress tracking — Upload and download progress events
  • Browser + Node — Works in both environments with environment-specific adapters

Weaknesses

  • Large bundle — 13KB gzipped, 4x larger than alternatives
  • Based on XMLHttpRequest (browser) — Not built on modern fetch API
  • Feature bloat — Many features most projects don't use
  • Slower development — Fewer updates compared to newer alternatives

ky

The modern, tiny HTTP client from Sindre Sorhus. Built on native fetch, ky adds just the features you need — retries, JSON shortcuts, hooks — in 3.3KB.

import ky from 'ky';

// POST with automatic JSON
const data = await ky.post('/api/users', {
  json: { name: 'Alice', email: 'alice@example.com' },
}).json<User>();

// Retries with exponential backoff
const data = await ky.get('/api/data', {
  retry: { limit: 3, methods: ['get'] },
}).json();

// Hooks (like interceptors)
const api = ky.create({
  prefixUrl: 'https://api.example.com',
  hooks: {
    beforeRequest: [(request) => {
      request.headers.set('Authorization', `Bearer ${token}`);
    }],
    afterResponse: [(_request, _options, response) => {
      if (response.status === 401) refreshToken();
    }],
  },
});

Strengths

  • Tiny — 3.3KB gzipped (4x smaller than Axios)
  • Built on fetch — Modern, standard API underneath
  • Smart retries — Exponential backoff with configurable methods
  • Hooks — Before/after request hooks (cleaner than interceptors)
  • TypeScript — Excellent type support

Weaknesses

  • Browser only — No Node.js support (use ky-universal or ofetch for universal)
  • Smaller ecosystem — Fewer guides and community resources
  • No progress tracking — fetch doesn't support upload progress natively

ofetch

Universal fetch from the UnJS ecosystem (the team behind Nuxt). Works identically in browsers, Node.js, Deno, Bun, and edge runtimes.

import { ofetch } from 'ofetch';

// POST with automatic JSON
const data = await ofetch<User>('/api/users', {
  method: 'POST',
  body: { name: 'Alice', email: 'alice@example.com' },
});

// Retries built-in
const data = await ofetch('/api/data', {
  retry: 3,
  retryDelay: 500,
});

// Interceptors
const api = ofetch.create({
  baseURL: 'https://api.example.com',
  onRequest({ options }) {
    options.headers.set('Authorization', `Bearer ${token}`);
  },
  onResponseError({ response }) {
    if (response.status === 401) refreshToken();
  },
});

Strengths

  • Universal — Same code runs in browsers, Node.js, Deno, Bun, Workers
  • Tiny — 3KB gzipped
  • Built on fetch — Modern standard
  • Auto-detection — Automatically sets Content-Type, parses JSON responses
  • Smart retries — Configurable retry logic
  • UnJS ecosystem — Used by Nuxt, Nitro, and other popular frameworks

Weaknesses

  • Smaller community — Fewer guides outside the Nuxt ecosystem
  • Less feature-rich — No upload progress, fewer advanced options
  • Interceptor API — Less flexible than Axios's interceptor chain

Feature Comparison

FeatureAxioskyofetch
Size (gzip)13KB3.3KB3KB
Built onXHR + adaptersfetchfetch
Browser
Node.js
Edge runtimes
Auto JSON
RetriesPlugin✅ Built-in✅ Built-in
Interceptors✅ (rich)Hooks
Upload progress
Timeout
TypeScript
AbortController
Streaming

Performance Comparison

For 1,000 sequential API calls:

LibraryTotal TimeBundle Cost
Native fetch8.2s0KB
Axios8.5s13KB
ky8.3s3.3KB
ofetch8.2s3KB

Runtime performance differences are negligible — the network is the bottleneck. The real difference is bundle size.

Which Should You Choose?

Choose Axios If:

  • You need upload progress tracking — Axios is the only option with built-in progress events
  • Your project already uses it — migration cost isn't worth the bundle savings
  • You need the most community resources — tutorials, Stack Overflow answers, examples

Choose ky If:

  • You're building a browser-only application
  • Bundle size matters — 4x smaller than Axios
  • You want a modern, fetch-based API
  • You appreciate built-in retries with exponential backoff

Choose ofetch If:

  • You need a universal client (browser + Node.js + edge)
  • You're using Nuxt, Nitro, or the UnJS ecosystem
  • Bundle size matters — smallest option
  • You want the simplest possible API

Choose Native fetch If:

  • Your requests are simple (no retries, interceptors, or complex error handling)
  • You want zero dependencies
  • You're comfortable writing a thin wrapper

Migration: Axios to ky

// Before (Axios)
const { data } = await axios.get<User[]>('/api/users');
const { data: user } = await axios.post<User>('/api/users', { name: 'Alice' });

// After (ky)
const data = await ky.get('/api/users').json<User[]>();
const user = await ky.post('/api/users', { json: { name: 'Alice' } }).json<User>();

The migration is straightforward. The main API differences are:

  1. ky chains .json() instead of destructuring { data }
  2. POST body uses { json: ... } instead of a direct second argument
  3. Interceptors become hooks

Our Recommendation

For new projects: ofetch. It's the smallest, runs everywhere, and has the cleanest API. If you're building browser-only, ky is equally good.

For existing Axios projects: Don't migrate unless bundle size is a pressing concern. Axios works fine — the 10KB difference isn't worth the refactoring cost for most projects.

Advanced Patterns: Building a Robust API Client

A raw HTTP library is rarely used directly across a large application. Production-grade apps wrap their HTTP client in an API client layer that handles authentication, error normalization, retry logic, and TypeScript types in one place. Here's how to build one with each major library.

The goals of a well-designed API client are consistency (every call uses the same auth and error handling), type safety (responses are typed, not any), and testability (easy to mock in unit tests).

// api-client.ts — ofetch-based API client with full TypeScript
import { ofetch, FetchError } from 'ofetch';

// Define your API's error structure
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

// Typed response wrapper
interface ApiResponse<T> {
  data: T;
  meta?: { total: number; page: number };
}

// Token refresh logic
let refreshPromise: Promise<string> | null = null;

async function getAccessToken(): Promise<string> {
  const token = localStorage.getItem('access_token');
  const expiry = Number(localStorage.getItem('token_expiry'));
  
  if (expiry && Date.now() > expiry - 60_000) {
    // Refresh within 1 minute of expiry — share the promise so
    // concurrent requests don't trigger multiple refreshes
    if (!refreshPromise) {
      refreshPromise = refreshAccessToken().finally(() => {
        refreshPromise = null;
      });
    }
    return refreshPromise;
  }
  
  return token!;
}

// Create the configured client
export const api = ofetch.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  retry: 1,
  async onRequest({ options }) {
    const token = await getAccessToken();
    options.headers.set('Authorization', `Bearer ${token}`);
    options.headers.set('X-Request-ID', crypto.randomUUID());
  },
  onResponseError({ response }) {
    const error = response._data as ApiError;
    // Normalize errors for consistent handling upstream
    throw new Error(error?.message ?? `HTTP ${response.status}`);
  },
});

// Typed resource functions
export const usersApi = {
  list: (params?: { page?: number; limit?: number }) =>
    api<ApiResponse<User[]>>('/users', { params }),
  get: (id: string) =>
    api<User>(`/users/${id}`),
  create: (body: CreateUserInput) =>
    api<User>('/users', { method: 'POST', body }),
  update: (id: string, body: Partial<CreateUserInput>) =>
    api<User>(`/users/${id}`, { method: 'PATCH', body }),
  delete: (id: string) =>
    api<void>(`/users/${id}`, { method: 'DELETE' }),
};

This pattern — a configured client instance with typed resource modules — is the most maintainable structure for API clients in large TypeScript applications. It keeps error handling and auth logic in one place, and every call site is fully typed without any as casting.


Error Handling Patterns

Inconsistent error handling is one of the most common problems in JavaScript HTTP code. Each call site reinvents the error-handling wheel, leading to some that swallow errors silently and others that crash the app. Here's how to handle errors consistently with each library.

Native fetch does not throw on non-2xx responses — you must check response.ok yourself. Axios and ky throw AxiosError and HTTPError respectively on non-2xx. ofetch throws a FetchError. Knowing which type of error you'll receive lets you write predictable error boundaries.

// Discriminated union for typed error handling
type Result<T> = 
  | { ok: true; data: T }
  | { ok: false; status: number; message: string };

// Wrap your HTTP calls for explicit error handling
async function safeGet<T>(url: string): Promise<Result<T>> {
  try {
    const data = await api<T>(url);
    return { ok: true, data };
  } catch (error) {
    if (error instanceof FetchError) {
      return {
        ok: false,
        status: error.response?.status ?? 0,
        message: error.data?.message ?? error.message,
      };
    }
    return { ok: false, status: 0, message: 'Unknown error' };
  }
}

// Callsite — forced to handle both cases
const result = await safeGet<User>('/users/123');
if (!result.ok) {
  if (result.status === 404) return notFound();
  return error('Failed to load user');
}
// result.data is typed as User here
const user = result.data;

The discriminated union pattern eliminates the most common source of runtime errors: assuming a request succeeded without checking. TypeScript's narrowing ensures you handle both the success and error paths.

For React applications, pair this with error boundaries at the page level. Sentry or Highlight.io (covered in our error tracking article) can capture unhandled HTTP errors automatically, but explicit error handling in API calls gives you much better observability than relying on automatic capture alone.


When NOT to Use an HTTP Library

Not every project needs an HTTP client library. Here are scenarios where the added dependency isn't worth it.

Use native fetch when your requests are simple and you have full control over the API. If you're making a handful of fetch calls in a Next.js app that controls both the frontend and backend, native fetch with a small helper function is sufficient. Next.js extends fetch with caching and revalidation behavior — using Axios or ky bypasses these built-in features.

// next.js — use native fetch for RSC caching benefits
async function getUser(id: string) {
  const res = await fetch(`${process.env.API_URL}/users/${id}`, {
    next: { revalidate: 60 },  // ISR: revalidate every 60 seconds
  });
  if (!res.ok) throw new Error('Failed to fetch user');
  return res.json() as Promise<User>;
}
// This caching is lost if you replace fetch with axios.get()

Avoid HTTP libraries in serverless functions where bundle size matters. Edge functions (Vercel Edge, Cloudflare Workers) have bundle size limits (1–4MB). Axios at 13KB gzip is fine, but it does add cold-start overhead. If your edge function makes one internal API call, native fetch is the right choice.

Don't add an HTTP library to fix a backend problem. If you're reaching for axios-retry because your backend intermittently returns 503 errors, the retry library addresses a symptom. Fix the backend instability. Retries are appropriate for transient network conditions, not for masking unreliable services.


FAQ: HTTP Client Libraries

Q: Should I use Axios with React Query / TanStack Query?

Yes, they work at different levels. TanStack Query manages the client-side cache, loading states, and background refetching. Axios (or ky, or ofetch) is the actual HTTP transport. TanStack Query's queryFn is just a function that returns a promise — you can use any HTTP library inside it. Most teams using TanStack Query use ofetch or ky for the smaller bundle, but Axios works equally well.

Q: Is Axios dead? Should I migrate?

Axios is not dead — it has 50M+ weekly downloads and active maintenance. "You should migrate to ky/ofetch" advice makes sense for new projects where you're choosing a library from scratch. For existing projects with Axios deeply integrated, migration cost (testing, refactoring, potential behavior differences) rarely justifies the 10KB bundle savings unless you're aggressively optimizing Core Web Vitals.

Q: How do I handle file uploads with progress tracking?

Axios is the only library that supports upload progress natively via onUploadProgress. If file upload progress is a requirement, Axios is your answer. With native fetch and other libraries, you can approximate progress using the ReadableStream API, but it's significantly more complex to implement correctly.

Q: What's the best way to mock HTTP clients in tests?

For Axios, axios-mock-adapter is the standard. For ky and ofetch (which wrap native fetch), mock at the fetch level using vitest-fetch-mock or msw (Mock Service Worker). MSW is the preferred approach in 2026 — it intercepts requests at the network level, meaning your tests use the same code paths as production, and you can reuse MSW handlers for Storybook and E2E tests.


Compare all HTTP client libraries on PkgPulse.

See also: Axios vs node-fetch and Axios vs got, tRPC vs GraphQL (2026).

See the live comparison

View axios vs. ky on PkgPulse →

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.