How I Replaced Axios With fetch Without Breaking the App
This migration did not happen because I woke up one day and decided to become a minimalist monk of HTTP clients.
It happened because of the Axios compromise story.
According to the Fireship video summary that pushed me to take this seriously, attackers allegedly compromised the project maintainer's npm account and published malicious Axios versions. Those versions reportedly pulled in a rogue dependency called plain-crypto-js, which is exactly the sort of package name that sounds fake even before it ruins your week.
From the summary, the malicious package used a post-install script as a RAT dropper. It would detect the OS, download a second-stage payload from a command-and-control server, establish remote access, and then clean up after itself to avoid leaving obvious evidence in security audits.
That is an insane sentence to write about a package manager.
So yes, this migration started because of a security concern, but also because npm packages have been losing trust lately in a very "are we serious right now?" kind of way.
So I removed it.
Not in a dramatic, "delete it and pray" kind of way. More in a calm, boring, production-safe kind of way. Which, honestly, is the better kind of dramatic.
Also, sorry Fireship for using your YouTube thumbnail.
Why I Did This Now
The security angle was the whole reason this moved from "nice cleanup idea" to "do it now."
Based on that Fireship summary, the practical advice was:
- check
package.jsonfor the affected Axios versions - check
node_modulesforplain-crypto-js - if compromised, do not just delete the file and move on
- roll API keys and tokens immediately
- follow proper incident cleanup guidance, like the kind Step Security documents
That last part is important. If a machine is compromised, deleting one sketchy package is not a victory lap. That is just the beginning of a worse conversation.
In my case, the safest and cleanest move for the project was simple: remove Axios entirely and replace it with something tiny that I control.
The Goal
I did not want a huge rewrite.
I did not want to touch every API call in the app.
And I definitely did not want to replace Axios with "just raw fetch" and then spend the next two days fixing things that Axios had been quietly doing for me all along.
So the real goal was simple:
Replace Axios with a small internal client built on top of fetch, while keeping the same developer experience for the app.
The Important Realization
A lot of people say, "Why not just use fetch? It already returns promises."
That part is true.
But Axios was doing a few useful things for us:
- It handled a base URL
- It serialized JSON requests nicely
- It rejected on failed responses
- It let us return
response.datadirectly - It gave us a clean place to normalize errors
fetch gives you the raw ingredients. Axios gives you the sandwich.
So instead of rebuilding all of Axios, I only rebuilt the tiny slice of it that this project actually used.
That was the key decision.
Step 1: Audit What the App Was Really Using
Before changing anything, I checked how axios was being used across the project.
The result was better than I expected: all usage was already going through one wrapper file.
That meant I didn’t have to go on a treasure hunt through 47 random components yelling "who imported axios directly?"
The app mostly relied on:
api.get(...)api.post(...)api.patch(...)api.delete(...)
And the wrapper was already doing two important things:
- unwrapping the API response envelope
- converting server errors into a custom
ApiClientError
That made the migration way easier, because I didn’t need Axios compatibility. I just needed behavior compatibility.
Big difference.
Step 2: Keep the Same API Surface
Instead of changing the whole app, I created a small internal client with the same shape:
api.get(...)
api.post(...)
api.patch(...)
api.put(...)
api.delete(...)
api.request(...)
Everything still returns promises.
So the rest of the code didn’t need to change its mental model at all.
React Query still works the same.
Mutations still work the same.
Error handling still works the same.
The app basically says, "cool story," and keeps moving.
Step 3: Build a Thin fetch Wrapper
The core implementation lives in lib/api-client.ts.
That file handles the boring but important stuff:
- joining
baseURLwith request paths - adding query params
- merging headers
- automatically JSON-stringifying request bodies
- calling
fetch - parsing JSON responses
- rejecting on non-2xx responses
- converting API errors into
ApiClientError
This is the kind of code nobody posts on social media, but it’s exactly the kind of code that saves you from pain later.
Which is basically backend romance.
Step 4: Preserve Existing Error Behavior
This part mattered a lot.
The app already expected a custom error class with:
messagecodestatus
That’s how some UI states worked, especially for share pages with cases like:
- revoked link
- expired link
- not found
If I had changed the error shape, the UI would not have exploded loudly. It would have broken politely, which is honestly worse.
So I kept the exact same error contract.
That way the UI could keep doing:
if (apiError?.code === "EXPIRED") { ... }
No changes needed.
No "small follow-up fix" at 1:30 AM.
Step 5: Leave Upload Progress Alone
One thing I did not touch was the upload flow that uses XMLHttpRequest.
Why?
Because file upload progress with browser fetch is still not as straightforward as people wish it were. And this project already had working upload progress with XHR.
So I kept it.
Not everything needs to be rewritten just because we are already in the file pretending to be productive.
Sometimes the best engineering choice is to leave the working part alone.
Step 6: Migrate Imports, Then Remove Axios
Once the new client was ready, I updated imports from the old Axios wrapper to the new internal client.
After that, I removed axios from package.json and refreshed the lockfile.
That order matters.
You don’t pull the ladder away before checking whether you’re still on the roof.
Step 7: Validate Like a Responsible Adult
After the migration, I ran:
- tests
- TypeScript
- a production build
That last one is especially important. A migration is not done when the code "looks right." It’s done when the app actually builds and behaves correctly.
There was one unrelated build issue caused by sandboxed Google Fonts fetching, but once that was allowed, the production build passed cleanly.
So the migration wasn’t just theoretically correct. It was actually verified.
A rare and beautiful moment in software.
What I Ended Up With
Now the app has:
- no direct Axios dependency
- a small internal HTTP client
- the same promise-based developer experience
- the same API error handling behavior
- no large-scale call site rewrite
Which is exactly what I wanted.
This is one of those migrations that sounds dramatic when you describe it out loud:
"I replaced Axios with a custom fetch client."
But in practice, the best version of this job is the boring version.
No broken flows.
No weird regressions.
No emergency patch.
No Slack message that starts with "quick question."
npm Trust Is Weird Right Now
The funniest and least funny part of modern JavaScript is that half the ecosystem is brilliant, and the other half feels like we are all three typo packages away from a cybersecurity documentary.
I still love JS and TS. I really do.
In fact, JavaScript and TypeScript played a huge part in making one of my biggest dreams come true: buying a MacBook as a teenager.
That still means something to me.
So this is not me doing the dramatic "JavaScript is dead" speech from the back of the conference room.
But it is me saying I have a very keen plan to move more critical parts of my stack to Go or Rust sooner rather than later.
Not because JS/TS failed me.
More because the supply-chain circus around npm keeps reminding me that reducing unnecessary dependencies is just good survival instinct now.
Final Thought
I didn’t replace Axios because fetch is cooler.
I replaced it because the dependency no longer made sense for this project, and the app only needed a very small subset of what Axios was providing.
So instead of dragging around a whole library, I kept the useful behavior, dropped the unnecessary dependency, and made the system simpler.
Which, in my opinion, is the nicest kind of refactor:
less code, fewer dependencies, same behavior, zero chaos.
That’s the dream.
And if one small migration also means one less reason to trust a random install script with my machine, even better.
Top comments (0)