TypeScript Client: fetch vs axios

The TypeScript client generated by protoc-gen-ts-client lets you inject the HTTP implementation it uses. This document explains:

  • What fetch and axios are, and how they differ
  • Why teams historically reached for axios, and why fetch is now the default
  • How the injection point works in the generated sebuf client
  • How to use axios with sebuf via an adapter — and exactly what that costs
  • Patches and workarounds for the adapter's limitations (SSE, per-call config, progress)
  • A recommendation

1. Background: fetch vs axios#

fetch#

fetch is the web-standard HTTP API. It's built into every modern runtime — browsers, Node 18+, Deno, Bun, and Cloudflare Workers. It needs no installation.

const resp = await fetch("https://api.example.com/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Ada" }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const user = await resp.json();

Its contract is a function (url, init) => Promise<Response>, where Response exposes .ok, .status, .json(), .text(), .headers, and a streaming .body (ReadableStream).

axios#

axios is a third-party HTTP library (a dependency you install). It predates a usable fetch and shipped a lot of convenience out of the box.

import axios from "axios";
 
const { data: user } = await axios.post("https://api.example.com/users", {
  name: "Ada",
});

Its contract is completely different: axios(config) => Promise<AxiosResponse>, where the parsed body lives on .data (no .json(), no .ok, no streaming .body).

Why this difference matters#

These two are not interchangeable. A function written for fetch cannot be handed an axios instance, because:

fetch axios
Call shape fetch(url, init) axios(config)
Body access await resp.json() resp.data
Error status (404/500) resolves; you check resp.ok throws by default
Streaming body resp.body is a ReadableStream not a web stream

2. Why people used axios — and why fetch is winning#

When fetch was young (and absent from Node), axios offered batteries-included ergonomics:

  • Interceptors — hooks that run before every request / after every response. This is the #1 reason people love axios.
  • Auth injection — usually via a request interceptor (attach a token, refresh on 401).
  • Automatic retries — via axios-retry.
  • Throws on non-2xx — a 404/500 rejects the promise automatically.
  • Built-in timeoutstimeout: 5000.
  • Upload/download progress eventsonUploadProgress / onDownloadProgress.
  • Automatic JSON — serializes request bodies and parses responses.
  • baseURL, default headers, proxy, withCredentials, XSRF handling.

Why fetch is now the default#

By 2026 most of axios's advantages have eroded:

  • fetch is everywhere and needs no dependency.
  • Timeouts are now native: AbortSignal.timeout(ms).
  • Cancellation is native: AbortController.
  • The interceptor/auth/retry conveniences can be reproduced in a ~15-line wrapper (see below) with no library.
  • fetch supports streaming cleanly via ReadableStream, which axios does not.

So why does anyone still use axios in 2026?#

Three reasons, in descending order of legitimacy:

  1. Inertia (the biggest). Large existing codebases with shared interceptors, auth setups, mocks, and instrumentation built around axios. Migrating is work with little payoff, so it stays.
  2. Interceptor ergonomics. axios is batteries-included; fetch needs a small wrapper for the same behavior. Some teams prefer that out of the box.
  3. One genuine technical gap: upload progress. fetch still cannot natively report upload progress in 2026 (download progress is doable via streams; upload isn't). For file-upload UIs with a progress bar, axios (built on XHR) still wins here.

For a greenfield project with no upload bars, plain fetch (or a thin wrapper) is the better default.

axios vs fetch + a wrapper#

Most of what people use axios for — interceptors, auth, retries — is achievable with a small fetch wrapper that still matches the fetch signature, with zero loss of streaming:

function makeFetch(getToken: () => string): typeof fetch {
  return async (input, init) => {
    // "request interceptor": inject auth
    const headers = new Headers(init?.headers);
    headers.set("Authorization", `Bearer ${getToken()}`);
 
    // "retry"
    for (let attempt = 0; ; attempt++) {
      const resp = await globalThis.fetch(input, { ...init, headers });
      // "response interceptor": inspect / log / refresh on 401
      if (resp.status >= 500 && attempt < 3) continue;
      return resp; // a REAL Response — .body is still a live ReadableStream
    }
  };
}

The wrapper is the interceptor: code before the call edits the request, code after inspects the response, and a loop adds retries. Because it returns a real Response, SSE streaming keeps working.


3. How sebuf's client injection works#

The generated client accepts an optional fetch implementation, typed as the standard fetch signature:

export interface UserServiceClientOptions {
  fetch?: typeof fetch;
  defaultHeaders?: Record<string, string>;
  // ...typed service headers
}
 
// In the constructor:
this.fetchFn = options?.fetch ?? globalThis.fetch;

Every RPC calls it with the fetch shape and consumes a Response:

const resp = await this.fetchFn(url, {
  method: "POST",
  headers,
  body: JSON.stringify(req),
  signal: options?.signal,
});
if (!resp.ok) return this.handleError(resp);
return (await resp.json()) as User;

For SSE streaming RPCs, the client reads resp.body as a ReadableStream and parses data: lines.

What this means#

  • You can inject any fetch-compatible implementation: native fetch, undici, node-fetch, cross-fetch, a custom wrapper, or a test mock.
  • You cannot inject axios directly — its signature and response shape don't match. You need an adapter.

4. Using axios with sebuf: the adapter#

To use axios, wrap it as a fetch-compatible function:

import type { AxiosInstance } from "axios";
 
/**
 * Wraps an axios instance so it can be used wherever sebuf expects `fetch`.
 * Works for normal request/response RPCs. Does NOT work for streaming (SSE) RPCs.
 */
export function axiosToFetch(instance: AxiosInstance): typeof fetch {
  return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
    const url =
      typeof input === "string" ? input
      : input instanceof URL ? input.toString()
      : input.url;
 
    // Copy request headers across (handles all the shapes fetch allows).
    const headers: Record<string, string> = {};
    new Headers(init?.headers).forEach((value, key) => {
      headers[key] = value;
    });
 
    const res = await instance.request<ArrayBuffer>({
      url,
      method: init?.method ?? "GET",
      headers,
      data: init?.body ?? undefined,
      responseType: "arraybuffer",
      validateStatus: () => true, // let sebuf handle error statuses; don't throw
      signal: init?.signal ?? undefined,
    });
 
    // Copy response headers back.
    const responseHeaders = new Headers();
    for (const [key, value] of Object.entries(res.headers)) {
      if (value != null) responseHeaders.set(key, String(value));
    }
 
    // Null-body statuses must carry no body, or the Response ctor throws.
    const nullBody = [101, 204, 205, 304].includes(res.status);
    return new Response(nullBody ? null : res.data, {
      status: res.status,
      statusText: res.statusText,
      headers: responseHeaders,
    });
  };
}
const client = new UserServiceClient(baseURL, { fetch: axiosToFetch(myAxios) });

Three non-obvious correctness points#

Each is a real bug if omitted:

  1. validateStatus: () => true — axios throws on 4xx/5xx by default; fetch resolves and you check resp.ok. Without this, sebuf's handleError(resp) path never runs, and you get an axios throw instead of a typed ApiError / ValidationError.
  2. responseType: "arraybuffer" + new Response(...) — building a real Response gives .ok, .status, .json(), .text(), .headers for free and stays binary-safe (proto content-type works, not just JSON).
  3. The null-body-status guardnew Response(emptyBuffer, { status: 204 }) throws a RangeError. axios returns a zero-length buffer for 204, so you must pass null.

5. What the adapter costs#

This is not "axios breaks sebuf" or "sebuf breaks axios." The adapter is a bridge between two things that don't naturally fit, and the cost falls on both sides.

What you lose from axios's side#

  • Upload/download progress (onUploadProgress / onDownloadProgress) — these are per-call and can't be wired through.
  • Per-call axios config — a custom timeout or option for one specific call. Config you set on the axios instance (the shared stuff) still works fine.
  • Auto-throw on non-2xx — intentionally disabled (relocated to sebuf's typed errors). Not really a loss, but your try/catch-around-axios habit changes.

What you lose from sebuf's side#

  • SSE streaming RPCs — these break. The adapter buffers the whole response (responseType: "arraybuffer"), so a stream that never closes never resolves. And you can't fix it with responseType: "stream": that's Node-only and yields a Node Readable, not the web ReadableStream the sebuf SSE parser expects.

What you keep#

Everything that motivates axios in the first place — interceptors, auth tokens, retries, timeouts, proxy, withCredentials — still works, because the adapter calls through the configured instance.

Quick reference#

You inject... Unary RPCs SSE streaming RPCs axios extras (interceptors/auth/retry)
native fetch ✅ works ✅ works ❌ n/a (no axios)
fetch wrapper ✅ works ✅ works ✅ works (re-implemented in the wrapper)
axios via adapter ✅ works ❌ broken ✅ works (instance-level only)

Plain-language summary: Through the adapter, normal calls work great and you keep your axios setup — but you give up progress bars, per-call tweaks, and sebuf's streaming endpoints. If you never use progress bars or streaming, you lose nothing that matters.


6. Patches for the three limitations#

6.1 SSE streaming — ✅ clean patch#

Route streaming requests to native fetch and everything else to axios. sebuf marks SSE requests with Accept: text/event-stream, so you can detect them:

export function axiosOrFetch(instance: AxiosInstance): typeof fetch {
  const viaAxios = axiosToFetch(instance);
  return (input, init) => {
    const isStream =
      new Headers(init?.headers).get("Accept") === "text/event-stream";
    return isStream ? globalThis.fetch(input, init) : viaAxios(input, init);
  };
}

Now unary calls go through axios (you keep interceptors/auth/retry) and streaming works (via native fetch).

Caveat: axios interceptors won't run on streaming requests. If auth lives in an interceptor, re-add the token in the fetch branch manually.

6.2 Per-call config — 🟡 partial patch#

The most common need — a per-call timeout or cancellation — already works via the generated CallOptions.signal:

await client.getUser({ id: "1" }, { signal: AbortSignal.timeout(5000) });

Arbitrary axios per-call config (custom responseType, etc.) is not patchable from user space — sebuf would have to widen CallOptions to pass a per-call init override down to the transport. That's a feature request, not a workaround.

6.3 Upload/download progress — ❌ needs a sebuf change#

This can't be fixed from outside:

  • sebuf consumes the response itself (resp.json()), so it never hands you a hook to observe progress per call.
  • The adapter buffers, so even download progress is gone there.
  • Upload progress isn't possible in fetch natively at all.

Getting progress would require sebuf itself to expose progress callbacks in CallOptions and thread them through the generated client.

Summary of patches#

Problem Patchable? How
SSE streaming ✅ Yes Hybrid transport — fetch for streams, axios for the rest
Per-call timeout/cancel ✅ Yes Already works via CallOptions.signal + AbortSignal.timeout
Arbitrary per-call config 🟡 Needs sebuf change Widen CallOptions to pass a per-call init override
Upload/download progress ❌ Needs sebuf change Requires progress hooks in the generated client

7. Recommendation#

  • Default to native fetch. It needs no install, supports every sebuf feature including streaming, and is the standard the client is built around.
  • If you only want interceptors / auth / retry, write a thin typeof fetch wrapper (Section 2). Zero dependency, streaming intact.
  • Use the axios adapter only when you have a large existing axios setup (shared interceptors, mocks, instrumentation) you genuinely want to reuse — and accept that streaming RPCs and per-call config won't come along, or use the hybrid axiosOrFetch transport (Section 6.1) to keep streaming.

Why a fetch | AxiosInstance union type is not offered#

It's tempting to let the client accept fetch?: typeof fetch | AxiosInstance, but it's the wrong trade:

  • It forces an axios type dependency into otherwise dependency-free generated output — everyone pays, even pure-fetch users.
  • It doubles every code path (request building, response parsing, error handling, SSE parsing) for two transports.
  • It still can't stream with axios, so the union would be valid for only part of the API — a false promise.

Keeping the contract as the single, standard fetch and adapting non-fetch clients at the edge (via axiosToFetch) keeps the generated core small, dependency-free, and honest.


8. Adopting sebuf on a team that loves axios#

This section speaks directly to a common situation: a team is being asked to adopt sebuf, and the frontend engineers push back because they already use axios. The worry is understandable — but it rests on a misunderstanding of what sebuf actually is, and the gap is smaller than it feels. Here is the case, and the solutions.

Start with what sebuf actually replaces#

The pushback usually sounds like "sebuf wants us to drop axios." It doesn't. sebuf is not an HTTP library, and it is not competing with axios. axios answers the question "how do I send an HTTP request?" sebuf answers a completely different question: "where do my request and response types come from, and how do I stop hand-writing and hand-maintaining client code for every endpoint?"

Today, without sebuf, a frontend engineer reads the backend, hand-writes an axios call, hand-types the request and response shapes in TypeScript, and hopes those types stay in sync with the server. When the backend changes a field, nothing tells the frontend — it breaks at runtime, in production. sebuf removes that entire class of work and that entire class of bug. The protobuf definition is the single source of truth, and the Go server, the TypeScript client, and the OpenAPI spec are all generated from it. They cannot drift apart, because they all come from the same file.

So the real comparison is not "axios vs sebuf." It is "hand-written, hand-typed, drift-prone client code vs a generated, always-in-sync, type-safe client." The HTTP mechanism underneath — fetch or axios — is a detail. And that detail is the part the team is allowed to keep controlling.

The key reassurance: you are not forced off axios#

The generated sebuf client has an injection point for the HTTP implementation. The team is not locked into anything. There are four ways to wire it, and three of them let the team keep using axios in some form:

  1. Native fetch — pass nothing. It just works, including streaming, with zero dependencies. This is the recommended default for new code.
  2. A thin fetch wrapper — about fifteen lines that reproduce the axios features the team actually relies on: interceptors, auth-token injection, and retries. Everything keeps working, streaming included, and the axios dependency goes away.
  3. The axios adapter — if the team has a large, established axios setup with shared interceptors, mocks, and instrumentation they genuinely want to reuse, the axiosToFetch adapter lets them route the generated client straight through their existing axios instance. Their interceptors, auth, retries, timeouts, and proxy settings all keep working.
  4. The hybrid transportaxiosOrFetch, which sends streaming requests through native fetch and everything else through axios, so the team keeps their full axios setup and keeps sebuf's streaming endpoints.

In other words, the answer to "but we use axios" is not "too bad." It is "fine — here are four supported ways to do exactly that, pick the one that fits how much of your axios investment you want to carry forward."

Be honest about the trade-offs#

Credibility matters more than a hard sell, so it is worth being upfront about the two real costs of staying on axios through the adapter.

First, the adapter cannot stream. sebuf's Server-Sent Events endpoints — the live, push-style feeds — rely on fetch's streaming response body, which axios does not provide. If the team uses streaming, they should use native fetch for it, or the hybrid transport that routes streaming through fetch automatically. This is solved; it just needs the right wiring.

Second, a couple of per-call axios conveniences don't survive the adapter: upload and download progress bars, and per-individual-call axios config. The shared, instance-level axios behavior — interceptors, auth, retries — all survives. Only the per-call extras are affected, and the most common one, a per-call timeout, is already handled by the generated client through a standard abort signal.

That honesty is itself part of the pitch. The team's instinct that they might lose something is correct in a narrow way, and naming exactly what — and showing it is either solved or rarely used — is far more persuasive than pretending there is no cost at all.

The reframe that usually lands#

The deepest version of the pushback is emotional, not technical: engineers feel that adopting a code generator takes away control of their own tooling. The reframe is that sebuf gives up the one thing they care about controlling — the HTTP layer, which stays fully injectable — while taking away the tedious, error-prone work they don't actually want to own: writing client boilerplate by hand and keeping frontend types manually in sync with a backend that changes without warning.

Most of what the team loves about axios is interceptors, auth injection, and retries. None of that is unique to axios; all of it is a small wrapper around fetch. What sebuf adds on top — end-to-end type safety, generated clients, automatic validation, and a backend and frontend that can never silently disagree about a contract — is something no HTTP library gives them, axios included.

The path forward is therefore not a fight. It is a menu. Keep axios with the adapter, keep axios plus streaming with the hybrid, or take the opportunity to replace axios with a fifteen-line fetch wrapper and shed a dependency. Every one of those options sits on top of sebuf, and every one of them ends with a frontend whose types are generated, validated, and permanently in sync with the backend. That is the win, and it is available no matter which HTTP client the team chooses to keep.