Mastering The Native Fetch Async/Await
And why you do not need Axios to do it

Preface: This is a bit of a long and detailed article, and while I have a greater point to this article, and I’m going to show you the fallbacks and best practices for how to go about avoiding Axios as a whole for your future software and web apps, I feel it necessary to give a brief (albeit technical) backstory to why I feel Axios on the whole has not only never been my go-to (unless a company I’m working w/already has it in place which unfortunately has happened), but will now absolutely continue never to be in any future software products or even side-projects I work on in my career. So bare w/me here, but I truly think it’s important to fill you in on the exact details of what happened, as far as we the public know.
The Gritty Start
On March 31, 2026, someone ran npm install on a fresh clone of a project depending on the npm package axios, and got a remote access trojan for their trouble.
Here’s what actually happened. An attacker compromised the npm account of Axio’s lead maintainer, not through a code vulnerability, but through a targeted social-engineering campaign against the maintainer directly. Roughly 18+ hours before the main event, they published a quiet, unassuming decoy package called plain-crypto-js, just to establish it as a normal-looking thing that had existed for a while.
Then, at 00:21 UTC, using the stolen credentials, they manually published axios@1.14.1 — bypassing npm's Trusted Publishing safeguard entirely, since that mechanism only applies to packages published through a verified CI pipeline, and a manual publish from a compromised account walks right around it. Around 40 minutes later, they did the same thing to the legacy branch with axios@0.30.4. Both versions quietly pulled in plain-crypto-js as a dependency (big uh oh) — a package that does not appear anywhere in the actual Axios source code, mainly because it isn't actually a part of Axios. It's a cross-platform remote access trojan, built for macOS, Windows, and Linux (all 3 of the main OS's in case you're unaware), which phoned home to what is called a 'command-and-control' server and then attempted to erase its own tracks before anyone was the wiser. The whole thing was live for a grand total of about 3 hours before it was discovered and pulled.
TL;DR — Axios Got Hacked.
Here are some figures you should know going into this article as well.
Axios sees somewhere north of 85+ million weekly downloads. A 3-hour window on a package that size is not a “near-miss” statistic. It’s a real number of real machines that got backdoored because a project somewhere in their dependency tree ran npm install at the wrong moment within such a short fraction of time. I realize 'backdoored' is not a word, but I'm making it a word because it fits so beautifully for the context of the situation.
To the Point
I’m not telling you this story to make you paranoid about npm as a concept — open-source at scale for sure requires trusting some semblance of code that you in fact did not write yourself, that’s the deal, that’s how the entire ecosystem functions (not perfectly, but smoothly). I’m telling you this story because it’s the cleanest possible illustration of a fact that gets treated as background noise until it isn’t: every dependency in your package.json file is code that runs w/the same privileges as the code you wrote yourself, maintained by people you've never met, updated on a schedule you have zero control over, and it takes exactly one singular compromised account to turn any of them into an attack vector. That's true of every single npm package available. It just so happen to be Axios's turn.
Which brings me to the actual point of this article. Axios exists to solve a specific, narrow problem: making HTTP requests from JavaScript (or TypeScript) w/o having to write a lot of repetitive boilerplate. That probelm does not require a 3rd-party dependency anymore. It requires fetch w/the async/await values; which has been sitting in every browser and in Node itself for years, and about 40-ish lines of code you write once, read in full, and never have to trust a stranger's npm account to maintain. Crazy concept nowadays I know…writing your own code.
The Myth of Fetch’s Verbosity
The standard argument against fetch goes something like: it's too low-level, you have to manually check the response.ok request, you have to manually call a .json() for the response data, there's no built-in timeout, no interceptors, no automatic error-throwing on a bad status code. All of that is true, and easily fixable. None if it is an argument for a dependency. It's an argument for writing about (like I said) 40 lines of your own code, exactly once (reusable things called functions are pretty great for this…), instead of installing someone else's several-hundred-kilobyte abstraction over the same lines of code — an abstraction mind you that comes w/its own maintainers, npm account, and its own attack surface, as in the last section I just demonstrated in detail.
Here’s what actually changed the calculus. When Axios first got popular, JavaScript’s story around asynchronous code was callbacks and, later, raw promise chains (genuinely awkward to compose/too verbose at the time of its inception). The async/await fixed that at the language level.
“De-structuring assignment” means pulling data and error off a returned object costs you zero extra lines. Native fetch has supported an AbortController (built-in timeout) — you get the timeout by writing about 6 lines around a controller, not by installing a library. Every specific complaint about fetch's verbosity is a complaint about the absence of a thin, specific wrapper, not an argument that the wrapper has to come from node_modules.
So build the wrapper. Once. Read every line of it. Nobody else’s compromised account or packages can touch it.
Building the Wrapper
The rest of this article builds a small, dependency-free HTTP client on top of native fetch w/using async/await, showing you how to setup and use stuff like:
global-base URL
request interceptors
normalized error-handling
closures and good old fashioned FP (functional programming)
no classes/constructors/methods
no
thisuses
Side Note: I am the type of programmer that prefers FP > OOP. If you disagree, and are more of an “object-oriented is better” person, that’s completely fine! (You’re just wrong and I hate you no worries). If you’ve spent any time around functional-style JavaScript, none of this will feel unfamiliar; it’s the same discipline applied to network requests instead of array transforms.
Step 1: A Configuration Closure, Not a Class
The instinct a lot of intermediate devs reach for here is a class ApiClient type of thing, with this.base_url set in a constructor. Skip it. A factory function that closes over its configuration and returns a plain old object of methods does the exact same job, w/no this binding footguns and no instantiation ceremony.
// Creating an API client is super simple
const create_api_client = ({ base_url = '', default_headers = {} } = {}) => {
const request_interceptors = []; // new request array
const use_request_interceptor = interceptor_fn =>
request_interceptors.push(interceptor_fn); // push the data into the array
// more to come, this is just the shape so far
return { use_request_interceptor };
};
// Btw, you all need to quit the python method of never using semicolons. Stop it;
The create_api_client runs once at startup, and every method it returns closes over base_url, default_headers, and request_interceptors for the lifetime of the app. Nothing outside this function can reach the request_interceptors directly — the only way in is through the use_request_interceptor() function, which is the exact same encapsulation-through-closure pattern that makes const count = 0; inside a counter function private w/o a single access modifier keyword.
Step 2: Normalized Error Handling
This is the part most hand-rolled fetch wrappers get lazy about, and it’s the part that actually matters. Fetch’s promise only rejects on a genuine network failure (DNS not resolving, connection refused). A 404 or a 500 status is — as far as fetch is concerned — a perfectly successful round trip. If your wrapper doesn’t check for a green (good) status (response.ok) itself and convert a bad status into an explicit failure, every single call site in your app has to remember to do it manually, and someone eventually won't.
The fix: a wrapper that never throws to its caller and never hands back a ‘successful’ response for a failed request. Every call resolves to the same predictable shape, always: { data, error }. Easy to remember and use, I swear.
// Create a normalize error for both the response and request
const normalize_error = (message, status = null) => ({
data: null, // initial data pull should be null
error: { message, status },
});
// Notice the status parameter (optional btw) has a default null value,
// this is because the error object is a generic object,
// so you don't need to specify the status code
One shape. Every failure — a network error, a bad status code, a JSON parse failure — collapses into this same structure. No call site downstream needs a try/catch block of its own, and no call site needs to know or care why something failed to handle the failure correctly.
Step 3: Request Interceptors
So it’s a clunky term to be fair, but really an interceptor here is just a function that takes a request config and returns a (possibly modified) one. The most common real use is attaching an auth token to every outgoing request w/o repeating that logic at every call site, which can be beyond annoying as hell and tedious.
// Build out the request interceptors (async function)
const apply_request_interceptors = async (config, interceptors) => {
let current_config = config; // this applies the config object to the first interceptor
for (const int of interceptors) current_config = await int(current_config);
// Loop through each interceptor and await the result and apply it to the config
return current_config;
};
// NOTE: that the function opens up w/using the 'async' keyword to tell the
// browser that this is an async function and the await keyword is used to
// wait for the async function to complete or return a value before proceeding
The await inside the loop matters here. I wanted to make a note on how important the second part of async/await is — an interceptor might need to do something asynchronous itself, like refreshing an expired token before attaching it, and this makes that possible w/o the caller needing to know or really even care.
Step 4: The Core Request Function
This is where everything from the last 3 steps gets all wired together and put in place. Gear up, and pay attention!
// Create the actual API Client (this is important)
const create_api_client = ({ base_url = '', default_headers = {} } = {}) => {
const request_interceptors = [];
const use_request_interceptor = interceptor_fn =>
request_interceptors.push(interceptor_fn);
const normalize_error = (message, status = null) => ({
data: null,
error: { message, status },
});
const apply_request_interceptors = async config => {
let current_config = config;
for (const int of request_interceptors)
current_config = await int(current_config);
return current_config;
};
const request = async (path, options = {}) => {
try {
// Setup the request options and headers data
const config = await apply_request_interceptors({
...options,
headers: { ...default_headers, ...options.headers },
});
// Save the full API fetch to the response variable
const response = await fetch(`${base_url}${path}`, config);
// IMPORTANT! This runs a check to see if the request failed
if (!response.ok)
return normalize_error(
`Request failed with status: ${response_status}`,
response.status,
);
// Save the full response data as JSON and immediately return
const data = await response.json();
// Return the data and no error
return { data, error: null };
} catch (error) {
// return the exact error message from the request
return normalize_error(error.message);
}
};
return { request, use_request_interceptor };
};
Notice what’s absent: no call site of request ever needs a try/catch block. Every failure mode — the network dying mid-request, a 404, malformed JSON in the response body — gets caught here, once, and normalized into the same { data, error } shape before it ever even leaves the dang function.
That’s the entire value proposition of this wrapper, and it’s the exact same ‘push mutation to a small, disposable, locally-scoped place instead of letitng it leak everywhere’ discipline that makes the .reduce() accumulator acceptable to mutate even in an otherwise-immutable codebase — request_interceptors is the one array in this whole file that gets mutated, and it's mutated in exactly one controlled spot; invisible to everything outside this closure.
Step 5: The Verb Methods
request alone works, but nobody wants to write-out client.request('/tickets', { method: 'GET' }) at every call site. Thin, specific verb methods on top of it:
// Now we'll add the API specific CRUD calls (Create, Read, Update, Delete)
const create_api_client = ({ base_url = '', default_headers = {} } = {}) => {
// ...(same as above)...
// The GET request
const GET = (path, options) => request(path, { ...options, method: 'GET' });
// The POST request
const POST = (path, body, options) =>
request(path, {
...options,
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
// The PUT request
const PUT = (path, body, options) =>
request(path, {
...options,
method: 'PUT',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
// The DELETE request
const REMOVE = (path, options) =>
request(path, { ...options, method: 'DELETE' });
// Return all the methods
return { GET, POST, PUT, delete: REMOVE, use_request_interceptor };
};
// NOTE: We use the 'remove' word instead of 'delete', I'll explain why below.
The delete keyword is a reserved word as a variable name, not as an object property, so the internal function is named REMOVE and exposed on the returned object as delete. The callers get the natural client.delete(...) they'd expect, and nothing internal has to fight the parser to get there.
NOTE: I used uppercase words for these method calls but it is to show that these (while only lexical scope, not global) should be used as global keywords. I’m old school, and I was taught that global variables and functions should be uppercase to help separate them from localized (lexical) scope variables. Feel free to name them anything you’d like, again so long as they are not a reserved keyword in JavaScript.
So now we just need to wire it up once, at the edge of the app:
// lib/create_api_client.js (everything from steps 1-5 above)
// lib/api_client.js
import { create_api_client } from './create_api_client';
export const api = create_api_client({
base_url: import.meta.env.VITE_API_BASE_URL,
default_headers: { 'Content-Type': 'application/json' },
});
api.use_request_interceptor(config => {
const TOKEN = localStorage.getItem('auth_token');
if (!TOKEN) return config;
return {
...config,
headers: {
...config.headers,
Authorization: `Bearer ${TOKEN}`,
},
};
});
Now setup a .env file:
# Create a (.env) file in the same directory,
# and add the following line:
VITE_API_BASE_URL=http://localhost:1337
Note: You can choose whichever Port # you’d like, so long as it’s not a currently used one like 3000 or something. I just prefer 1337 because of a silly old Leetspeak reference from an old comic. Feel free to not check it out.
The line import.meta.env.VITE_API_BASE_URL is Vite's environment-variable convention. This whole setup assumes a standard Vite client build, not a meta-framework doing anything clever w/the request lifecycle on your behalf (looking at you Next.js).
Wiring It Into React and Zustand
The point of building this as plain functions instead of a class is that it composes into any state layer w/o any ceremony. Here’s the part that actually matters architecturally:
Components should not know fetch even exists. Data fetching lives in the store. Components read state and call actions. This is where a state management system like Zustand comes in handy!
// store/use_ticket_store.js
import { create } from 'zustand'; // the 'create' function is not a default export in zustand
import { api } from '../lib/api_client'; // import the api info from what you createconst initial_state = {
tickets: [],
is_loading: false,
error: null,
};
export const use_ticket_store = create(set => ({
// import the initial_state object or simply put it right here;
// dealer's choice.
...initial_state,
fetch_tickets: async () => { // notice the function call is async
set({ is_loading: true, error: null });
const { data, error } = await api.GET('/tickets');
// Remember, the GET request method was uppercased in the api_client file
if (error) {
// Check for errors on the callback here as well
set({ error, is_loading: false });
return;
}
// Now set the tickets to the data and shut off the is_loading state
set({ tickets: data, is_loading: false });
},
}));
The fetch_tickets function is the only place in the entire app that knows /tickets is an endpoint, or that fetching it might fail. The component consuming this store doesn't import api, doesn't know the create_api_client function even exists, and absolutely does not contain a try/catch block. Now set it up:
// components/TicketList.jsx
import React, { useEffect } from 'react'; // React is no longer necessary; I do it anyway
import { use_ticket_store } from '../store/use_ticket_store';
import Loading from './Loading'; // a Loading component
const TicketList = () => {
const tickets = use_ticket_store(state => state.tickets);
const is_loading = use_ticket_store(state => state.is_loading);
const error = use_ticket_store(state => state.error);
const fetch_tickets = use_ticket_store(state => state.fetch_tickets);
// Create a useEffect that will run every time the fetch_tickets function is called
useEffect(() => {
if (tickets.length === 0) fetch_tickets(); // check if tickets are empty
}, [fetch_tickets]);
if (is_loading) return <Loading />;
if (error) return <h1>Something went wrong: {error.message}</h1>;
return (
<ul className='ticket-list'>
{tickets.map(ticket => (
<li key={ticket.id}>
{ticket.customer} - {ticket.status}
</li>
))}
</ul>
);
};
export default TicketList;
// NOTE: Some people simply deconstruct here and do something like:
// const { tickets, is_loading, etc... } = use_ticket_store();
// I prefer to create each variable explicitly for each state slice.
This is the decoupling that actually matters; it’s worth being explicit about why.
If TicketList imports the api directly and calls api.GET('/tickets') itself instead of using a state store, every component that needs ticket data would need its own loading state, its own error state, and its own opinion about what 'failed' looks like. Centralizing the fetch inside the store means there's exactly one place that understands the network, and an unlimited number of components that just read a plain, predictable state shape. Swap the entire backend, change the endpoint, add caching — none of it touches a single component.
The Takeaway
The Axios incident wasn’t a failure of Axios as a project, and it isn’t really an argument specifically against Axios going forward either. The maintainers responded, the malicious versions got pulled, the ecosystem’s tooling did roughly what it’s supposed to do once the compromise was detected.
It’s an argument about the shape of the risk itself: the moment you add a dependency to your code, you’ve added a second party who can ship code directly into your production environment, and you don’t get to vet every release the way you’d review a PR (pull request) from your own team.
Fetch is not going to get compromised by a hijacked npm account, because it isn’t an npm package. It ships w/the runtime, it’s specified by a standards body, and the worst thing that can happen to your request-handling logic is a bug you personally wrote and can personally read, in a file that’s maybe 80 lines long, that you understand completely because you built every line of it yourself. That’s not a purity argument. It’s a risk-surface argument, and after March 2026, it’s a considerably easier one to make outloud.
Learn the platform first. Reach for a dependency only once you’ve confirmed the platform genuinely cannot do it, not before.
Sources on the March 2026 Axios compromise:
// EOF


