Back to blog
engineering

I Built a Zero-Dependency Alternative to axios After the Supply Chain Attack

axios got compromised through a supply chain attack on one of its transitive dependencies. I built reqcraft, a 3kb HTTP client with zero dependencies and the same API. Here's the full story and how it works.


I Built a Zero-Dependency Alternative to axios

On 31 March 2026, axios got hit by a supply chain attack. One of its transitive dependencies was hijacked, and malicious code was injected into the package. If your project had axios installed, you were exposed. Millions of projects were affected.

The thing is, the axios source code itself looked fine. The attack came through a sub-dependency. A package that axios depends on, that you probably never looked at, that got compromised. And that's the whole problem.

I've been using axios for years. Probably used it in 30+ projects. But when this happened, I asked myself a question that I should have asked a long time ago: why does an HTTP client need dependencies at all?

So I sat down and built one that doesn't.

TL;DR I built reqcraft, a zero-dependency HTTP client in ~500 lines of TypeScript. It has the same API as axios (interceptors, retry, timeout, typed errors, plugins) but weighs 3kb and has nothing in the dependency tree to compromise. GitHub | npm


Why Dependencies Are the Problem

When you run npm install axios, you're not just trusting the axios team. You're trusting every maintainer of every transitive dependency. Every GitHub account connected to those repos. Every npm publish token that exists for those packages.

axios has 8+ transitive dependencies. That's 8+ potential points of failure. 8+ packages where one compromised account, one stolen token, one malicious publish can inject code into your project.

And you'd never know. Because you're not auditing those packages. Nobody is. We just trust the tree.

That trust got broken.


The Idea Behind reqcraft

Modern JavaScript runtimes already ship with everything you need. Node.js 18+, Deno, Bun, and every modern browser have the fetch API built in. It handles HTTP/1.1, HTTP/2, streaming, AbortController, headers, redirects, CORS, TLS. All of it.

What axios actually adds on top is developer ergonomics. Base URLs, interceptors, automatic JSON serialization, timeouts, retries. And none of that requires external code. It's string manipulation, promise chains, and thin wrappers around native APIs.

So I built reqcraft with one rule: zero runtime dependencies.

The entire dependency tree looks like this:

reqcraft@1.0.0
└── (empty)

That's it. There's nothing to compromise because there's nothing there.


What It Does

Despite being ~500 lines of TypeScript and 3kb minified, reqcraft covers everything I actually used from axios.

All HTTP methods. GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.

Auto JSON serialization. Send an object, it gets stringified. Receive JSON, it gets parsed. Content-type headers are set automatically.

Interceptors. Same .use() and .eject() API that axios has. Request interceptors run before every request, response interceptors run after.

Retry with backoff. Built in, not a separate package. Only retries on 5xx errors, 429 (rate limited), or network failures.

Timeout. Uses native AbortController under the hood. You can chain it with your own abort signals.

Response types. JSON, text, blob, arraybuffer, stream.

Download progress. Via ReadableStream. You get loaded, total, and percent.

Request/response transforms. transformRequest and transformResponse pipelines, same as axios.

Custom status validation. Define what status codes count as "success."

Plugin system. Extend the client without monkey patching anything.

Full TypeScript. Strict mode, generics on every method, typed errors with a type guard.


How It Works

Here's a quick look at the code. If you've used axios before, this will feel familiar.

Creating a Client

import { createClient } from "reqcraft";
 
const api = createClient({
  baseURL: "https://api.example.com",
  headers: { Authorization: "Bearer token" },
  timeout: 5000,
});

Making Requests

// GET with typed response
const { data } = await api.get<User[]>("/users");
 
// POST with auto JSON serialization
await api.post("/users", { name: "John", email: "john@example.com" });
 
// PUT
await api.put("/users/1", { name: "Jane" });
 
// DELETE
await api.delete("/users/1");

Interceptors

api.interceptors.request.use((config) => {
  config.headers = {
    ...config.headers,
    "X-Request-Id": crypto.randomUUID(),
  };
  return config;
});
 
api.interceptors.response.use((response) => {
  console.log(`${response.config.method} ${response.config.url}${response.status}`);
  return response;
});

Error Handling

import { isReqcraftError } from "reqcraft";
 
try {
  await api.get("/not-found");
} catch (err) {
  if (isReqcraftError(err)) {
    console.log(err.status);     // 404
    console.log(err.statusText); // "Not Found"
    console.log(err.data);       // response body
  }
}

Retry

// Retry up to 3 times with linear backoff (300ms, 600ms, 900ms)
api.get("/flaky-endpoint", { retry: 3, retryDelay: 300 });

Download Progress

api.get("/large-file", {
  responseType: "blob",
  onDownloadProgress: ({ loaded, total, percent }) => {
    console.log(`${percent}% downloaded`);
  },
});

Migrating from axios

Switching takes about 2 minutes. The API is intentionally similar.

// Before
import axios from "axios";
const api = axios.create({
  baseURL: "https://api.example.com",
});
 
// After
import { createClient } from "reqcraft";
const api = createClient({
  baseURL: "https://api.example.com",
});

Everything else stays the same. api.get(), api.post(), interceptors, params, timeout. The only differences are the import and the error type guard (isReqcraftError instead of isAxiosError).

I wrote a full migration guide that covers every scenario.


The Numbers

Here's how reqcraft compares to axios:

reqcraftaxios
Dependencies08+ transitive
Bundle size~3kb min~13kb min
Source lines~500~2,000+ (plus deps)
Supply chain riskNoneDemonstrated
Full audit time5 minutesHours

What I Left Out (On Purpose)

Not everything in axios needs to exist. I intentionally skipped:

axios.all and axios.spread. Just use Promise.all. It's native JavaScript. There's no reason to wrap it.

CancelToken. This is deprecated in axios itself. Use AbortController instead. reqcraft supports it natively.

XSRF token handling. Very niche. If you need it, add it through an interceptor in 5 lines.

HTTP adapter pattern. Unnecessary complexity. The fetch API works everywhere now.

These aren't missing features. They're design decisions to keep the surface area small and auditable.


The Security Angle

This isn't just about bundle size, even though 3kb vs 13kb is nice. It's about attack surface.

When you install a package with zero dependencies:

  • There are no transitive trust chains to exploit.
  • There are no postinstall scripts to hijack.
  • The entire codebase is auditable in 5 minutes.
  • You can vendor the source directly if you want complete control.

I wrote a SECURITY.md documenting all of this. No eval(), no dynamic imports, no postinstall scripts, no file system access, no prototype pollution vectors. The package is inert until you import it and call a function.


Try It

npm install reqcraft

GitHub: github.com/junaiddshaukat/reqcraft

npm: npmjs.com/package/reqcraft

The source is intentionally small enough to read in one sitting. If you find an issue or want a feature, open an issue or a PR. I'm actively maintaining this.

The best dependency is no dependency.