A guide to Javascript HTTP clients in 2025

One of the most common tasks your Javascript/Typescript code will perform is sending HTTP requests, whether it's to a local API or a third party service. HTTP clients have changed a lot since the days of AJAX and JQuery but choosing the right client is still an important decision. Here's your 2025 guide to HTTP clients.
We'll break down everthing you need to know about HTTP communication in 2025. Our deep dive will:
HTTP clients are the unsung heroes of web devleopment. Here's what you need to know:
The key is finding a library that matches your project requirements - read on for the lowdown on each client.
HTTP (HyperText Transfer Protocol) defines how computers talk over the web. Each time you load a website, your browser uses HTTP to request resources from a remote server. Often you'll write TypeScript code that sends and receives data from a URL - for that you need an HTTP client.
All HTTP clients:
SoFetch is an HTTP client that simplifies sending and receiving data via HTTP. It uses sensible defaults to minimize boilerplate, so you only write code relevant to your application. Since JSON APIs dominate in 2025, SoFetch defaults to JSON for requests and responses and supports type‑safe usage with minimal configuration.
Fetch is the standard HTTP client shipped with modern browsers and Node. It's unopinionated, stable, and flexible, but you may write extra boilerplate for common patterns (JSON, error handling, auth, etc.), which higher‑level clients often abstract.
Before Fetch became ubiquitous, Axios was a widely used HTTP client. It is mature, well‑documented, and feature rich. For large projects Axios can be a good fit, but it adds significant bundle size (~2 MB unpacked).
Below are three common scenarios (POST request, error handling, and bearer authentication) comparing SoFetch, vanilla Fetch, and Axios.
const loginRequest = {
username: "Regina.George",
password: "soFetch1234",
stayLoggedIn: true
}
interface LoginResponse {
authToken: string,
expiresTimestamp: number
}
In SoFetch (without any configuration) the request would be a single line of code:
const loginResponse = await soFetch<LoginResponse>("/api/login", loginRequest)
By default soFetch assumes:
LoginResponse(*A request to soFetch with only one argument are assumed to be a GET request but don't worry - you can make PUT, PATCH and DELETE requests too)
Let's compare this to the same request using Fetch:
const requestBody = JSON.stringify(loginRequest)
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(loginRequest),
});
)
if (!response.ok) {
throw new Error(`Login failed: ${response.status} ${response.statusText}`);
}
const data: LoginResponse = await response.json();
return data;
soFetch throws an error for non-2XX (error) response codes by default, but with Fetch you'll need to handle the error yourself. In Fetch you'll also need to write some more boilerplate to specify a POST request in JSON format, and explicitly parse the JSON response.
How does Axios compare for the same request?
const loginResponse = await axios.post<LoginResponse>("/api/login", loginRequest).data;
This is much more like soFetch since Axios also serialises the request and adds the "Content-Type": "application/json" header automatically, but you still need to specify the request type and the .data property.
We can expand our previous example by handling some possible error response. Let's assume that we'll want to specifically handle:
401 Unauthorized - the user has supplied the wrong password
404 Not found - the user was not found
and have a generic handler for all other errors.
Let's compare SoFetch, Fetch and Axios for this expanded example:
const loginResponse = await soFetch<LoginResponse>("/api/login", loginRequest)
.catchHttp(Status.Unauthorized401, () => alert("Username/password incorrect"))
.catchHttp(Status.NotFound404, () => alert("User not found"))
.catch(() => alert("An unexpected error occurred")
Here we can use the SoFetch catchHttp handler to handle a specific error response and a regular catch() to handle all other errors. We're also using the SoFetch Status enum here to make the code easier to understand, but we could also just use an integer to specify the error code
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(loginRequest),
})
if (response.status === 401) {
alert("Username/password incorrect")
}
if (response.status === 404) {
alert("User not found")
}
if (!response.ok) {
alert("An unexpected error occurred")
}
const data: LoginResponse = await response.json()
return data
In regular Fetch we'll need to check the status code after we get a response but before we serialise the response body.
try {
const response = await axios.post<LoginResponse>("/api/login", loginRequest);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) {
alert("Username/password incorrect")
}
if (status === 404) {
alert("User not found")
}
throw new Error(
alert("An unexpected error occurred")
);
} else {
alert("An unexpected error occurred")
}
}
}
We're having to retrieve the status code here and check for both response errors and network errors (which is why the line alert("An unexpected error occurred") is in two places.
For our final example, let's imagine that we're communicating with an API that uses Bearer Tokens and that we want to persist a token using local storage. Authenticating our HTTP requests will consist of two parts:
soFetch.useBearerAuthentication()
//Then, once we've received a bearer token from a successful login:
soFetch.setAuthToken("MY_GROOVY_AUTH_TOKEN")
By default when you call useBearerAuthentication() on the browser soFetch assumes:
SOFETCH_AUTHENTICATION(As with all the soFetch authentication helpers, you can also pass an object that allows you to specify the persistence method and the authentication key, if you don't want to use SOFETCH_AUTHENTICATION. Persistence using localStorage, sessionStorage, cookies and a custom function are all supported, or you can hold the authentication key in memory if you prefer.)
Fetch is pretty unopinionated out of the box, so you'd need to roll your own client to create similar functionality. It might look something like:
export class HttpClient {
private getHeaders(): HeadersInit {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
const token = localStorage.getItem("BEARER_TOKEN");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`${endpoint}`, {
...options,
headers: {
...this.getHeaders(),
...(options.headers || {}),
},
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
}
and then to set the Bearer Token you'd just do:
localStorage.setItem("MY_GROOVY_AUTH_TOKEN")
Using Axios you could implement similar functionality with something like:
axios.interceptors.request.use((config) => {
// Add Content-Type header if missing
config.headers = config.headers ?? {};
if (!config.headers["Content-Type"]) {
config.headers["Content-Type"] = "application/json";
}
// Attach bearer token from localStorage if present
const token = localStorage.getItem("BEARER_TOKEN");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
//Add the bearer token after login using:
localStorage.setItem("MY_GROOVY_AUTH_TOKEN")
| Feature | SoFetch | Fetch | Axios |
|---|---|---|---|
| Footprint | Additional dependency (277 kB) | Built-in to modern browsers and Node(18+) | Additional dependency (2.24 MB) |
| Installation | npm i @antoinette-agency/sofetch | No installation needed | npm i axios |
| Readability | Very readable, concice | Well structured, but can be verbose | Readable, occasionally verbose |
| JSON Handling | Automatic | Manual | Automatic (via response.data) |
| Error handling | Throws on non‑2xx; Fluent, expressive API. Access to full rejection response | Only network error throws. HTTP 4xx/5xx are resolved — manual checks needed | Rejects on non‑2xx; |
| Authentication | - Built-in helper methods cover majority of modern authentication methods. - Built-in token persistence. - Authentication via interceptors for more complex cases. | None built-in | Via interceptors |
| Typescript Support | Excellent | Basic | Excellent |
Choosing an HTTP client in 2025 isn't about sticking with your favourite library for all your projects. It's more about choosing the right tool for the job - each one has a sweet spot.
Pro tip: Don't be afraid to mix approaches. In particular SoFetch is a minimal layer over the Fetch API so they play nicely together. There's nothing to stop you from using both.
The HTTP Client landscape continues to evolve so make sure you review your choices periodically. The client you're using today might not be the best choice for your next project.
Hi, we're Antoinette.
We help our clients improve performance, reduce costs and save money. Get in touch to see how we could help you.
By clicking the button above, you consent for Antoinette to contact you at the email address provided. Privacy policy
Antoinette: a digital agency specialising in site performance, rebuilds and headless CMS.
We help our clients reduce their hosting costs, improve site speed and save money. We believe the practices that improve your codebase also improve your bottom line.
Antoinette is based in Brighton, UK
By clicking the button above, you consent for Antoinette to contact you at the email address provided. Privacy policy
Antoinette uses cookies to improve site performance.