<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>imlargo · Writing</title>
<link>https://imlargo.dev/blog</link>
<description>Things I have built, the decisions behind them and the parts I got wrong.</description>
<language>en</language>
<managingEditor>jclargob@gmail.com (Juan Carlos Largo)</managingEditor>
<atom:link href="https://imlargo.dev/blog/rss.xml" rel="self" type="application/rss+xml" />
<lastBuildDate>Sat, 05 Sep 2026 00:00:00 GMT</lastBuildDate>
<item>
<title>The fetch wrapper I had rewritten in every project</title>
<link>https://imlargo.dev/blog/building-air-from-empty-repo-to-npm</link>
<guid isPermaLink="true">https://imlargo.dev/blog/building-air-from-empty-repo-to-npm</guid>
<pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
<dc:creator>Juan Carlos Largo</dc:creator>
<description>The api.ts I had retyped from scratch in every app, written once as a package. Most of the work was deciding what belonged in it: the rules that came before the code, and the reason behind each of the eight options that survived.</description>
<category>TypeScript</category><category>Open Source</category><category>API Design</category>
<content:encoded><![CDATA[<p>Every project grows the same file. <code>api.ts</code> or <code>http.ts</code>, wrapping <code>fetch</code>: join a base URL, serialize a body, parse the response, throw on a non-2xx. None of it is hard. I retyped it every time anyway, because copying the old one over felt worse.</p><p>It is never finished, either. It grows a token header when the app adds auth, a <code>Content-Type</code> exception the first time someone uploads a file, a second branch when the framework hands me its own <code>fetch</code>. By then it is a small library living inside an application, untested and unnamed.</p><p>So I wrote it once, on purpose, as a package. The client in air is seven files, about 380 lines of code, no dependencies. It builds a URL, detects a body, parses a response, and throws an error that carries the response with it.</p><pre><code>const api = air.create({ baseURL: 'https://api.example.com' })

const user = await api.get&lt;User&gt;('/users/1')
const page = await api.get&lt;Page&lt;User&gt;&gt;('/users', { query: { page: 2, active: true } })
const created = await api.post&lt;User&gt;('/users', { body: { name: 'Ada' } })</code></pre><h2>Why not one of the existing ones</h2><p>I read the three clients people compare it to first, and measured them rather than trust the impression each one leaves. Bundled, minified and gzipped: <code>axios</code> 19.2 kB in the browser and 65 kB on the server, <code>ky</code> 8.8 kB, <code>ofetch</code> 4 kB and 36 kB.</p><ul><li><code>axios</code> predates universal <code>fetch</code>, which explains most of it: an adapter layer over XHR and Node <code>http</code>, interceptors, CJS. Still the default answer in most of the ecosystem.</li><li><code>ky</code> is the closest sibling: <code>fetch</code>-only, zero dependencies, ESM-only, one author's taste applied consistently. It makes the opposite call on batteries, and out of the box times out at 10 s and retries twice.</li><li><code>ofetch</code> has almost the ergonomics I wanted and pays for Node compatibility to get there: three dependencies and a polyfill that is most of its server cost. It also retries GET and HEAD once, silently.</li></ul><p>None of that is wrong. It is a different bet about who owns the decisions. <code>ky</code> hands back a response you call <code>.json&lt;User&gt;()</code> on; <code>axios</code>, a <code>data</code> property to unwrap on every call. Two of the three retry on their own, so a request I believe I sent once may have been sent twice. My bet is that those four things are the whole job. air is 1.9 kB gzipped, the same in the browser and on the server, because there is no second transport.</p><p>The same document carries the unflattering half: those three have years of resolved edge cases and about 160 million weekly downloads between them; air has one author, a test suite, and the four systems of mine that run it. I wrote it into the repo so I would not be tempted to frame it better later.</p><h2>The rules came before the code</h2><p>The first commit that mattered was a document, not code: less code is better, zero dependencies ever, native <code>fetch</code> with no polyfill and no second transport, ESM only, predictable over clever, types are the docs. Then a list of what air is not allowed to become: interceptor chains, plugins, retries or timeouts in any form, caching, deduplication, Node-only features that break in a browser.</p><p>A lot of ceremony for a package this size, and it earned its place. On day one the pressure to add a feature does not come from users; there are none. It comes from me at 11 p.m., deciding one small option would be convenient. With the rule written down I have to argue with the document first, and I lose more often than I win.</p><p>The first rule needed a counterweight. &quot;Less code is better&quot; justifies any omission: the cost of a feature you shipped shows up in the diff, the cost of one you did not shows up nowhere. So every review asks a second question next to <em>what can we remove</em>: what can a user not do at all?</p><p>That question found the gaps. Auto-parsing is the point of a wrapper like this, right up until you want something that lives on the response rather than in it: a <code>Link</code> header, an <code>ETag</code>, <code>201</code> versus <code>200</code>, the final URL after a redirect. None of it was reachable on a successful call, and nobody would have filed an issue; they would have dropped to <code>fetch</code> and moved on. Every client carries a <code>raw</code> twin now, same seven methods, resolving to <code>{ data, response }</code>.</p><h2>One implementation, not two</h2><p>air had to work two ways: called directly, <code>air.get(url)</code>, and as a factory, <code>air.create({ baseURL })</code>. The obvious implementation gives you two code paths, a default instance and a constructor, and they drift the first time an option lands in one and not the other.</p><p>So the root export is just another client, created with empty defaults. One implementation to keep correct.</p><pre><code>export const air = create()</code></pre><p>The same idea decided the internals. One helper lists the seven verbs and builds both the plain client and the raw one, so a method cannot be added to one and forgotten in the other. Both project from a single <code>request()</code> that resolves to both halves; a second path is where they would start disagreeing about what a request is. Seven flat files, no directory tree, no barrel except the entry point.</p><h2>Eight options, and what each one had to prove</h2><p>The options table is the part I rewrote most. Every option is permanent: something a user has to learn, something I keep true in every version after this one.</p><p><code>baseURL</code> joins as strings rather than resolving as URLs. Standard resolution treats a leading slash as origin-root, so <code>https://api.test/v1</code> plus <code>/users</code> drops the <code>/v1</code> and breaks any API mounted under a path. A leading <code>//</code> is read as a path for the same reason: stray double slashes are far more common than the protocol-relative case, which is deprecated anyway. I changed that rule once and reverted it when a test showed <code>///users</code> resolving to <code>https://users/</code>.</p><p><code>query</code> refuses to guess. Values are primitives or arrays of primitives, enforced by the type, so a <code>Date</code> or a nested object is a compile error instead of an <code>[object Object]</code> you find in production. <code>undefined</code> and <code>null</code> are dropped; <code>false</code>, <code>0</code> and the empty string are kept. That is the same bug I have written by hand in a dozen of those <code>api.ts</code> files.</p><pre><code>await api.get('/search', { query: { tags: ['a', 'b'], page: 2, draft: false } })
// ?tags=a&amp;tags=b&amp;page=2&amp;draft=false

await api.get('/search', { query: { since: new Date() } })
// compile error: serialize it yourself, so the format stays your decision</code></pre><p>Body detection never re-serializes a valid <code>fetch</code> body: objects and arrays become JSON with a <code>Content-Type</code>, while <code>FormData</code>, <code>Blob</code>, <code>URLSearchParams</code>, typed arrays and strings pass through untouched. <code>FormData</code> is the one place air overrides the caller, deleting a <code>Content-Type</code> even when it was set explicitly: the multipart boundary is generated at send time, so no literal value a caller could write is ever correct. It is the most common bug in wrappers like this.</p><p>Then errors, the reason people wrap <code>fetch</code> in the first place. A non-2xx throws an <code>AirError</code> carrying the status, the parsed body, the response, and the request as it went out, resolved headers included. <code>options.headers</code> may still be an unevaluated function, useless when you are holding a 401 and want to know which token went with it.</p><pre><code>try {
  await api.post('/users', { body: input })
} catch (error) {
  if (isAirError(error) &amp;&amp; error.status === 422) {
    return error.data as ValidationProblem
  }
  throw error
}</code></pre><h2>Two options that take a function</h2><p>The first user-facing bug was a client that kept sending an expired token. A <code>headers</code> object passed to <code>create()</code> is evaluated once and frozen in the closure, so every request after a refresh sends the stale one. A long-lived client and a rotating token are the normal case, not the exotic one.</p><p>The fix stayed inside the existing option: <code>headers</code> may be a function, called once per request. Header sources merge lazily too, so a chain of <code>create()</code> calls nests closures and nothing resolves until the request that needs it. Merging eagerly would reintroduce the frozen token one layer down.</p><pre><code>const api = air.create({
  baseURL: 'https://api.example.com',
  headers: () =&gt; ({ Authorization: `Bearer ${getToken()}` }),
  signal: () =&gt; AbortSignal.timeout(5000)
})</code></pre><p><code>signal</code> later took the same shape for the same reason, so the pattern has a name: an option may be a function when its correct value is only knowable per request. Which is not a licence to make everything a thunk: <code>baseURL</code> and <code>parse</code> cannot go stale between requests.</p><h2>What I decided not to build</h2><p><code>timeout</code> and <code>retry</code> both existed, and both came out. The timeout was built the obvious way: an <code>AbortController</code> in the client, a timer that aborts it, the caller's signal forwarded in, a <code>finally</code> that tears both down. But <code>fetch()</code> resolves when the headers arrive, not when the body has been read, so the cleanup disarmed the timer exactly as the download started. Against a server that drips its body over ten seconds, the request hung forever despite a 500 ms timeout and an abort at 50 ms. <code>AbortSignal.timeout(ms)</code> and <code>AbortSignal.any([...])</code> are native, so deleting the option deleted the bridge the bug lived in. <code>signal</code> goes to <code>fetch</code> untouched.</p><p>Retry went for a subtler reason. A retry loop has to tell a transient failure from a request the caller cancelled on purpose, and the only reliable source is the <code>AbortSignal</code> itself: <code>abort(reason)</code> takes any reason, so sniffing the error's <code>name</code> for <code>AbortError</code> reads a deliberate cancellation as transient. Mine retried cancelled requests three times, and it could not be fixed where it sat: a generic helper that receives a callback and an error never has the signal in scope. In the caller's own code the loop is five lines and the signal is right there.</p><p>The rule generalizes past retries: moving a decision out of the client only works if the information behind it moves out too. Before extracting anything, check which of the two it needs.</p><h2>What the tests could not tell me</h2><p>All three bugs air has shipped got through a green test run: a streaming request body that threw at the transport, a shared signal that broke a client permanently after five seconds, a <code>null</code> header that went out as the string <code>&quot;null&quot;</code>. That is the shape of the tool rather than a coverage gap. The suite mocks <code>fetch</code>, and a mock agrees with whatever its author already believed.</p><p>Real <code>fetch</code> refuses a <code>ReadableStream</code> body without <code>duplex: 'half'</code>, rejects an already-fired signal before sending, and stringifies a <code>null</code> header instead of deleting the key. A hand-written double does none of that unless you already knew about the bug. So <code>examples/</code> became the integration lane: eight files, each a README recipe made executable against a local server and real <code>fetch</code>, asserting what it demonstrates. They are TypeScript that Node runs directly, type-checked against the built package, so a recipe cannot drift from the types it shows. CI runs them on every supported Node, and all three bugs are pinned there.</p><h2>What it cost to publish</h2><p>The last stretch had nothing to do with HTTP. <code>dist/</code> was gitignored while <code>files</code> pointed at it, so publishing from a clean checkout would have shipped a package with no code in it; <code>npm publish --dry-run</code> in a fresh clone caught it, and that is now the thing I do before every release. The name <code>air</code> was taken, so it went out as <code>@korastd/air</code>, under a studio scope, and moved to <code>@imlargo/air</code> at 2.0 once it was clear this was a personal project; the old name is deprecated and points at the new one. CI publishes through OIDC rather than a stored token.</p><h2>What a month in production changed</h2><p>Four of my own systems moved onto air the week it shipped. A month of running it there, and of measuring it against the other three clients, changed it more than the design document had. Two of the changes broke 1.0, which is why the current version is 2.1 rather than 1.3.</p><p>One was a type that lied. A <code>204</code>, or any empty body, had always resolved to <code>null</code>; the signature promised <code>T</code>. I had written that compromise down as acceptable, because <code>T | null</code> puts a null check on every caller for a case most of them never hit. Rereading it a month later, it was a lie the compiler was helping me tell. Every call now resolves to <code>T | null</code>, and the cost is one <code>if (!user)</code> per endpoint that can answer empty, which is where the check belonged all along.</p><p>The other widened the list of content types handed back unread, from three to nine, so a <code>stream+json</code> or <code>json-seq</code> endpoint is a stream on the first request rather than a promise that never settles.</p><p>The 2.1 change was the retry rule, and it came from the same lesson that removed retries in the first place. A generic helper never had the caller's signal in scope. A wrapper around <code>fetch</code> does: it receives <code>init.signal</code> and can refuse to retry anything already aborted without guessing from the error's name. So the rule went from no retries in any form to none inside the client, and <code>retry</code> ships as a function you hand to the <code>fetch</code> option, next to token refresh, download progress and two serializers, each under its own import path. Importing one loads one file. The client is still 2 kB.</p><pre><code>import { retry } from '@imlargo/air/retry'
import { refresh } from '@imlargo/air/refresh'

const api = air.create({
  baseURL: 'https://api.example.com',
  headers: () =&gt; ({ Authorization: `Bearer ${session.token}` }),
  fetch: retry({ attempts: 3, fetch: refresh({ headers: renewToken }) }),
})</code></pre><p>The review that found the bugs asked how each utility could be misused, not how it worked. Sending the renewal request through the client that carries <code>refresh</code> would deadlock it, waiting on the refresh it was part of, so <code>refresh</code> now hands your function the unwrapped <code>fetch</code> to call the renewal with. A lowercase <code>methods: ['post']</code> never matched anything. Both are tests now, and the misuse pass is a rule in the contributing guide, because I did not run it the first time.</p><p>The comparison numbers at the top no longer come from a table I measured once. <code>pnpm bench</code> runs every client in a fresh process, in random order, five rounds, with the server in its own process, and the same workflow runs from the Actions tab so anyone can reproduce the report with a button. It also retired a sentence I had been saying: on my laptop, air was indistinguishable from raw <code>fetch</code>; on a four-thread CI runner with small payloads it costs about 15 %, ofetch 18 %, ky 35 %, axios half. With large bodies every client disappears into the parsing. The version that survives both machines is that air and ofetch sit in the lowest-overhead group, so that is what the README says.</p><p>The same run recorded what each client does with the same request. <code>ky</code> throws <code>SyntaxError</code> on a 204 if you call <code>.json()</code>. <code>ky</code> and <code>axios</code> hang on an endpoint that never closes. <code>axios</code> sends a multipart body labelled <code>application/x-www-form-urlencoded</code>. None of those show up in a speed table, and they are the bugs a user meets in production.</p><p>What is left is 183 tests, a 3 kB client, five utilities that never touch it, and a contributing guide that records why each decision went the way it did, including the ones that removed something and the one that put something back.</p><p>That guide is the part I would keep if I had to throw the rest away. A feature that shipped is documented by the code that implements it. A feature that was considered and rejected leaves no trace, and without somewhere to write down why, I would eventually put every one of them back.</p>]]></content:encoded>
</item>
</channel>
</rss>