Release candidate — 1.0.0-rc.7
← Back to blog

The Web Ecosystem Is Missing a Layer

Oleksandr Zhuravlov

Every project that calls other people's APIs ends up hand-building the same layer — typed wrappers around endpoints, validation, retries, timeouts, auth — because this is the one boundary of the web stack the ecosystem never claimed. The layer has no name, no shared implementation, and no finished state; it gets rewritten, project after project, team after team. This post is about why the gap exists, why the usual tools don't close it, and what the layer looks like when you stop implementing it and start declaring it.

The layer you keep writing

It starts the same way every time. A feature needs data from another service, so you write listUsers — a dozen lines around fetch that hit /users and parse the JSON. Then the layer grows, one incident at a time. The response needs a type, so you cast it. The provider has a bad week, so you write withRetry and wrap the call. A request hangs in production, so you add an AbortController and a timeout. The token expires mid-request, so refresh logic moves into a helper. A few months of this, and the function looks like:

// Grown, not designed: each piece arrived the week something broke.
import {  } from './auth/token';
import {  } from './utils/retry';

export async function (): <[]> {
    const  = new ();
    const  = (() => .(), 10_000);
    try {
        const  = await (() =>
            ('https://api.example.com/users', {
                : { : `Bearer ${await ()}` },
                : .,
            }),
        );
        if (!.ok) throw new (`HTTP ${.status}`);
        return (await .json()) as []; // a claim, not a check
    } finally {
        ();
    }
}

None of it is wrong. This is what a careful engineer does when the platform hands them a transport and the problem lives above it. But step back and look at what the code is: infrastructure. Not one line of it is the feature you were building, and every hour it took came out of the budget for domain logic.

Worse, the layer has two properties you would never accept from a dependency. It's never finished — it's a side project inside your product, so it gets a retry but no backoff, a timeout on one call but not the other eleven, and rate limiting only after the provider starts sending 429s, usually as a sleep(200). And it doesn't transfer — the next project needs the same layer, but this one grew into the shapes of this codebase, so you write it again. One of the most common pieces of infrastructure in software is rebuilt by hand, everywhere, continuously, and has no name.

Every other boundary got a layer

Compare that with how the stack treats every other boundary your code crosses:

The boundaryWhat owns it
Your app ↔ the screenReact, Vue, Svelte
Your app ↔ inbound requestsFastify, Hono, Nest
Your app ↔ the databasePrisma, Drizzle, Kysely
Your app ↔ untrusted inputZod, Valibot, ArkType
Your app ↔ server state in the UITanStack Query, SWR
Your app ↔ someone else's APIfetch

Every tool in that table earns its row the same way: it turns something you used to implement into something you declare. You don't implement DOM updates; you declare components and React reconciles. You don't implement connection pools and query builders; you declare a schema. You don't implement parsers; you declare a Zod schema and the parser falls out. That trade — implementation in the library, declaration in your code — is what it means for a layer to exist.

Except the last row. fetch is a transport. It moves bytes well, and that is the whole job: it knows nothing about types, retries, rate limits, auth, or whether the response is still shaped the way your code assumes. Everything above the transport — everything in listUsers — is still implemented by you, in every codebase. For the most common boundary of all, your app calling an API you don't control, the ecosystem's answer is a pipe.

You don't own the other end

The gap isn't an oversight. This is the one boundary in the stack where the other side doesn't live in your repository, and that breaks the two tricks every other layer relies on.

Your components, routes, and database schema ship together with the code that uses them, so their layers can enforce contracts ahead of time — rename a column, and the migration and the query break in the same commit. The API you call ships from someone else's repository, on someone else's schedule, with no duty to tell you. When its response shape changes, nothing on your side moves: nothing recompiles, no type goes red, and the first environment to notice is production (schema drift is a production bug). A contract with code you don't own can't be enforced at compile time. It can only be checked at runtime, against the bytes that arrive.

That single fact is why the tools closest to this space stop short of it. HTTP clients — axios, ky, got — improve the transport and stop where the transport stops: the body is still untyped, and the retry, auth, and validation glue around the call is still yours to write (the glue, not the wrapper, was always the hard part). Codegen — openapi-typescript, Orval, Kubb — aims at the right layer and reaches it when a spec exists, is current, and is truthful, which holds for large public APIs and fails for the internal service whose documentation is a README (what codegen buys, and where it runs out). One family stops below the layer; the other depends on a spec the long tail of APIs never publishes. Between them, the layer stayed yours.

The same layer, declared

A layer this common should have a name, and the right one comes from what the layer does. A stitch is how you join two pieces of fabric that were made separately. That is the work at this boundary — joining your app to software made in someone else's repository — so call the layer stitching: one stitch per endpoint you depend on.

StitchAPI is that layer as a library, and its design follows one rule: at this boundary, nothing should be implemented — only declared. It's in-process TypeScript with zero runtime dependencies, built around one unit, the stitch: you declare an endpoint — where it lives, what shape it returns, how it authenticates, how it's allowed to fail — and get back a typed function.

import { ,  } from 'stitchapi';
import { ,  } from 'stitchapi/auth';
import {  } from 'zod';

const  = .({ : .(), : .() });

const  = ({
    : 'https://api.example.com',
    : '/users',
    : (('API_TOKEN')),
    // validate every live response; report what drifted
    : (.()),
    : 3,
    : '10/s',
    : '10s',
});

const users = await ();
const users: {
    id: number;
    name: string;
}[]

That declaration is listUsers again — same endpoint, same concerns — but every line is now configuration, and the implementation behind each line ships with the library instead of growing in your utils folder. The bare values carry full policies: retry: 3 is exponential backoff with jitter that honors Retry-After when a server sends one; timeout: '10s' bounds the whole call, retries and backoff waits included; throttle: '10/s' paces every call through this stitch. And each shorthand grows into an envelope the day the endpoint needs more — pool: 'host' on the throttle shares one limiter across every stitch hitting that host, the thing no hand-rolled helper can do, because each call site only knows about itself.

drift is the part that takes the boundary seriously. Because the other end can change without telling you, the stitch validates every live response against the declared schema and reports differences as a leveled drift signal at the boundary — an undeclared new field as information, a broken shape as a typed error — instead of an undefined surfacing three layers into your app. The static type on users is inferred from the same schema that runs at runtime, so the type you program against and the check that guards it can't fall out of sync.

And declaring is what ends the side-project problem: extending this layer is no longer engineering work. When an endpoint starts failing in bursts next quarter, the fix is one more field on the declaration — circuit: [5, '30s'], a circuit breaker — not a helper to design, implement, test, and maintain. The layer stops being under-implemented because there is nothing left for you to implement.

What it isn't

Three familiar tools sit near this space, and none of them is this layer:

  • TanStack Query manages server state in your UI; a stitch is the call itself. Query owns caching, invalidation, and refetching inside a component tree, and takes any promise-returning function as its queryFn — what happens inside that function is out of its scope on purpose. A stitch is that function, typed and validated and resilient, and it runs the same outside the UI — in a cron job, a queue worker, an agent (a stitch as your queryFn).
  • Workflow platforms orchestrate processes; a stitch is one function. Temporal, n8n, and Zapier run multi-step flows on their own runtime, with infrastructure to deploy and operate. A stitch is in-process — composing two calls is plain TypeScript, and there is nothing to host (runtime stitching vs workflow platforms).
  • It's not a new HTTP client, and not a generator. The transport stays whatever you use today — fetch, axios, anything behind an adapter — and there is no generated code to commit: the declaration is the runtime, which is why it works for APIs that never published a spec.

Start with one endpoint

You already maintain this layer. The only question is its form: implemented by hand in each project, or declared per endpoint on top of a shared implementation. Moving isn't a migration — pick the endpoint that costs you the most, the flaky one or the rate-limited one or the one whose shape changed under you last quarter, and declare it next to the code it replaces (adopt StitchAPI without a rewrite). Every other call site keeps working, and the layer converges one endpoint at a time. What disappears isn't the layer — you always needed it — but the routine of building it: the next thing this boundary demands from you is a field on a declaration, not another helper in utils.

If the argument holds for your codebase, the playground tests it in two minutes — declare a stitch against a live API in the browser, nothing to install. The library ships as stitchapi on npm and lives at rejifald/StitchAPI on GitHub — star it and watch releases to keep the trade running in your favor: every capability that lands in the library is one more thing you never implement at this boundary.