跪拜 Guibai
← Back to the summary

Hands-On Test of the 'Ban Axios' Argument: Fetch Covers the Basics, but Three Unmentioned Traps Decide the Migration

Timeout and cancellation: fetch really covers them

These two claims have no suspense, and the test results are clean. For the same endpoint that responds in 3 seconds, with a 500ms timeout, axios disconnects at 517ms and reports ECONNABORTED; fetch uses AbortSignal.timeout(500), disconnects at 504ms, and reports TimeoutError. There's no substantial difference in timing, the only thing to watch is the error type—axios throws AxiosError with a code, fetch throws TimeoutError. Your old catch blocks that check error.code === 'ECONNABORTED' will all fail after migration; you need to rewrite them.

Cancellation is more interesting. The axios docs on the Cancellation page say CancelToken is deprecated and will be removed in the next major version; the new approach is AbortController. I ran both on 1.20.0, CancelToken still works, axios.isCancel still returns true after cancel, but the error object is now CanceledError. So old code doesn't fail now, but on the day axios releases a major version, it will collapse.

So I accept the claim that fetch covers timeout and cancellation. It's not that axios's features weakened, it's that the platform has built these in; a wrapper library's extra layer now only has habitual value.

Interceptors aren't replaced, but they have an order trap

fetch has no interceptors, that's a fact; after migration, all the unified token injection and log writing logic has to be wrapped yourself. But while testing, I found something I used to think of as "hard to debug" but now I see the root cause: axios request interceptors execute in reverse order of registration.

I registered three request interceptors in the order A, B, C, and two response interceptors in the order 1, 2, and ran a request. The output was:

[req] C(最后注册) →
[req] B(后注册) →
[req] A(先注册) →
[res] ← 1(先注册)
[res] ← 2(后注册)

Response interceptors are a normal queue: first registered, first executed. Request interceptors are a stack: later registered, first executed. This design is mentioned in the docs, but it's extremely unobtrusive. In an old project, I saw someone register two request interceptors—one injecting a request-id, the other depending on that request-id for logging—and when the registration order was reversed, the logs were full of undefined, and nobody could figure out why. Now I know the root cause: we assume interceptors queue from top to bottom, but actually the later one jumps ahead.

I'm not posting this to say axios's design is wrong, but to show that the hot-list article's point about "interceptor nesting is hard to debug" is in the right direction but doesn't hit the root. The difficulty isn't the nesting, it's the intuition mismatch about order.

The biggest migration trap isn't in any feature checklist

After running the feature comparison, I thought the migration cost was manageable and planned to write the verification report in the afternoon. Then I added a 404 endpoint to the test service, and things changed.

axios, on a 404, rejects by default with error.code = ERR_BAD_REQUEST, going to catch. fetch, on a 404, completes the request normally, goes to then, with res.ok = false, and the 404 status is left for you to check.

Looked at by itself, this seems harmless, but imagine a migration scene: hundreds of call sites in a project, where the error toast logic written in the axios era, like "Network error, please retry", is based on the assumption that only the network layer can fail. After moving to fetch, every 404 and 500 returned by the server will silently pass through then, rendering with undefined data, until a corner of the page goes white and you realize something is wrong.

Later I thought about it: this is a difference in the two libraries' definition of "error". axios counts HTTP status codes as errors, fetch only cares about the transport layer. It's hard to say who is right, but during migration, if you only check items on a feature list—timeout exists, cancellation exists, JSON parsing exists—this item won't appear on the list, because it's not "a missing feature", it's a reversed semantic.

The most time-consuming part: 401 refresh, pitfalls on both sides

That article provided a 30-line fetch wrapper; I wrote a version with token refresh based on that idea, 41 lines, and it works. I was pretty satisfied at that point, thinking this was the final shape after migration.

Then I added a concurrency scenario: five requests hit a login-required endpoint simultaneously, and the token is expired. The expectation is that the first 401 triggers a refresh, the other four queue for the new token, and the refresh happens only once. I used a global refreshing variable for single-flight: on the first 401, I start the refresh request and store it in that variable; other requests see the variable is non-null and wait for it; after refresh completes, it's cleared.

let res = await doFetch();
if (res.status === 401) {
  if (!refreshing) {
    refreshing = refreshToken().finally(() => { refreshing = null; });
  }
  await refreshing;      // all 401s wait for the same refresh
  res = await doFetch(); // retry with new token
}

The fetch version worked beautifully: all five requests succeeded, and /refresh was called only once.

Then I moved the same logic into an axios interceptor—after all, a real migration needs both sides to work. The strange thing: all five requests also succeeded, but /refresh was called 3 times. Same machine, same test service, same refresh logic, only the client changed, and the refresh count tripled.

My first reaction was to blame axios, and I almost wrote that into the conclusion. Fortunately, I ran one more step: I changed the test service to return 401s in batches—three immediately, two delayed by 150 milliseconds—and the fetch version's refresh count became 2.

Now it's clear: it's not the library. This refresh code has a race window: at the moment the refresh completes, refreshing is cleared. If a 401 comes back slowly after that, it sees refreshing is empty, doesn't know that a refresh already happened, and refreshes again. axios's interceptor chain has more async layers, so the five error responses are more likely to enter the error branch at different times, making the race more likely. The fetch version had all five 401s in the same event loop tick, so it dodged the problem. It was luck, not correct logic.

The fix is simple: record the token used when the request was sent, and compare it when a 401 comes back.

const tokenAtRequest = accessToken; // token used at request time
let res = await doFetch();
if (res.status === 401) {
  if (accessToken !== tokenAtRequest) {
    // token already refreshed by another concurrent request, just retry, don't refresh again
  } else {
    if (!refreshing) {
      refreshing = refreshToken().finally(() => { refreshing = null; });
    }
    await refreshing;
  }
  res = await doFetch();
}

After adding this check, both batched 401s and same-tick 401s result in exactly one /refresh call. I looked through a few open-source repos and this logic closely resembles the widely circulated axios refresh template—which means those templates likely carry the same hidden bomb, but most projects either have low concurrency that keeps it from surfacing, or the refresh endpoint is idempotent so nobody notices three refreshes.

My conclusion

To be straightforward: for new projects, using a fetch wrapper is worth it; for existing projects, making a migration purely to "ban axios" is not. The reasons are above: functionally fetch can cover the features, but the 404 semantic flip and the error type rewrite mean an existing project migration is a full regression test–level effort. The saved bundle size (axios plus dependencies is about a hundred KB) doesn't justify that cost.

The bigger wins from this test, though, are the three points the hot-list article didn't mention: the stack order of request interceptors, the 404 semantic flip, and the concurrent 401 refresh race. The first two are axios-side traps, the third must be guarded regardless of migration. Oh, and one more: I didn't test the Edge Runtime claim because I can't spin up a real edge environment locally. I did check the docs and confirmed that axios has a fetch adapter, but I won't pretend to know more about it, so I'll leave that alone.

This whole test ran locally with zero API cost. If anyone wants to reproduce it, the script can be pasted and run.