You set up an API client once, with sane defaults:
Every part of your app uses it.
Then your app grows. Now you’re calling three different services.
With most fetch wrappers, “default” means “global”. Set it once, and it quietly applies everywhere, including places you didn’t intend.
This is a documented, known footgun in popular libraries. Axios’s own docs warn about it directly:
Axios warning: f your app talks to more than one domain, a global default Authorization header gets sent to all of them, including third-party APIs you do not control.
Their recommended fix is discipline. You must always remember to scope a custom instance for any client that carries credentials.
Multi-service apps are especially exposed. A token meant for one host can leak into requests for another, because there was never a hard wall between them, just a shared default object.
Here’s how it actually happens. Somewhere early in the app, someone sets a convenient global default:
axios.defaults.headers.common["Authorization"] = "Bearer users-service-token";
Months later, a different part of the codebase calls a completely unrelated service:
axios.get("https://products-api.example.com/items");
No error. No warning. The users-service token just went out on a request to products-api. Nobody touched that line of code expecting it to carry credentials for a different service, it inherited them silently because that’s exactly what global defaults are designed to do.
Global defaults reduce repetition, but they don’t isolate you from the rest of your app.
When I built ApiClient in @superutils/fetch, I wanted each service my app talks to to live in its own sandbox, with zero chance of inheriting config it never asked for.
import { ApiClient } from "@superutils/fetch";
const usersApi = new ApiClient("https://dummyjson.com/users", {
fixedOptions: {
headers: { Authorization: "Bearer users-service-token" },
},
commonOptions: {
timeout: 5000,
},
});
usersApi.get("/1");
usersApi.post("/add", { firstName: "Alice" });
By default, this instance ignores fetch.defaults entirely. Nothing set globally elsewhere in your app leaks in. commonOptions can be overridden per call when you need that flexibility. fixedOptions sit at a higher tier: at the call site, you can’t pass conflicting options for them, the compiler stops you in TypeScript, and at runtime in JavaScript the fixed values simply win.
Need a second service with completely different rules?
const productsApi = new ApiClient("https://dummyjson.com/products", {
fixedOptions: {
headers: { Authorization: "Bearer products-service-token" },
},
});
Two clients, two isolated configs, no shared state, no risk of one token ending up on the wrong host.
You also get the full method suite on each instance, with every method locking in its own HTTP verb so you can’t call .get() and accidentally send a POST. It’s as if each ApiClient instance is a mini SDK for that service.
usersApi.get("/1"); // GET, always
usersApi.post("/add"); // POST, always
usersApi.delete("/1"); // DELETE, always
Beyond isolation, the client also provides a few quality-of-life improvements:
get, post, put, patch, delete, head, options) on every instance, each enforcing its own verbfetch.defaults by defaultA multi-service client isn’t the only use case, but it’s the most common one:
How are you isolating config between the different APIs your app talks to?