DEV Community

Cover image for How I Replaced Axios With `fetch` Without Breaking the App
Md Kaif Ansari
Md Kaif Ansari

Posted on

How I Replaced Axios With `fetch` Without Breaking the App

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.json for the affected Axios versions
  • check node_modules for plain-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.data directly
  • 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(...)
Enter fullscreen mode Exit fullscreen mode

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 baseURL with 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:

  • message
  • code
  • status

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") { ... }
Enter fullscreen mode Exit fullscreen mode

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)