{"slug":"the-fetch-handler-pattern","url":"https://tako.sh/blog/the-fetch-handler-pattern/","canonical":"https://tako.sh/blog/the-fetch-handler-pattern/","title":"The Fetch Handler Pattern: One Function, Every Runtime","date":"2026-04-06T11:48","description":"Why Tako chose the web-standard fetch handler as its universal app interface — and how the same export runs on Bun and Node.","author":null,"image":"8bc5d8514b34","imageAlt":null,"headings":[{"depth":2,"slug":"why-not-express-style-handlers","text":"Why not Express-style handlers?"},{"depth":2,"slug":"the-nodejs-bridge","text":"The Node.js bridge"},{"depth":2,"slug":"what-the-sdk-adds","text":"What the SDK adds"},{"depth":2,"slug":"framework-ssr-works-too","text":"Framework SSR works too"},{"depth":2,"slug":"the-portability-argument","text":"The portability argument"}],"markdown":"Here's a Tako app:\n\n```typescript\nexport default function fetch(request: Request): Response {\n  return new Response(\"Hello\");\n}\n```\n\nThat's a complete, deployable application. Same file runs on Bun and Node.js. No framework, no adapter, no `createServer`. One function that takes a `Request` and returns a `Response` — both standard [Web APIs](https://developer.mozilla.org/en-US/docs/Web/API) that exist in every modern JavaScript runtime.\n\nThis is the interface Tako chose for everything. Your app is a fetch handler.\n\n## Why not Express-style handlers?\n\nMost Node.js frameworks invented their own request/response types before the web had standard ones. Express has `(req, res, next)`. Fastify has `(request, reply)`. Koa has `(ctx)`. Each one is a proprietary interface that locks your code to that framework's runtime model.\n\n| Pattern       | Interface                                           | Portable?                                   |\n| ------------- | --------------------------------------------------- | ------------------------------------------- |\n| Express       | `(req: IncomingMessage, res: ServerResponse, next)` | Node.js only                                |\n| Fastify       | `(request: FastifyRequest, reply: FastifyReply)`    | Node.js only                                |\n| Koa           | `(ctx: Context)`                                    | Node.js only                                |\n| **Web fetch** | `(request: Request) → Response`                     | **Bun, Node, Cloudflare Workers, browsers** |\n\nThe web fetch pattern won. Bun launched with `Bun.serve({ fetch })` as its primary API. Cloudflare Workers uses `export default { fetch }`. Frameworks like [Hono](https://hono.dev) and [Elysia](https://elysiajs.com) build on it natively — a Hono app is already a fetch handler, so it works with Tako out of the box:\n\n```typescript\nimport { Hono } from \"hono\";\n\nconst app = new Hono();\napp.get(\"/\", (c) => c.text(\"Hello from Hono\"));\n\nexport default app; // app.fetch is the handler\n```\n\nNo adapter needed. No `toNodeHandler()`. The app _is_ the interface.\n\n## The Node.js bridge\n\nThere's one catch: Node.js still doesn't have a native fetch-based HTTP server. `http.createServer()` gives you `IncomingMessage` and `ServerResponse` — the same callback shape from 2009.\n\nThe [Tako SDK](/docs) bridges this gap. When your app runs on Node, the SDK's entrypoint converts between the two worlds:\n\n```d2\ndirection: right\n\nnode: Node HTTP Server {\n  style.font-size: 13\n}\n\nbridge: SDK Bridge {\n  shape: circle\n  style.font-size: 13\n}\n\nhandler: Your fetch() {\n  shape: hexagon\n}\n\nnode -> bridge: \"IncomingMessage\\nServerResponse\"\nbridge -> handler: \"Request\"\nhandler -> bridge: \"Response\"\n```\n\nIncoming: the SDK reads the Node request's URL, method, headers, and body stream, then constructs a standard `Request`. Outgoing: it takes your `Response`, writes the status and headers back through Node's `ServerResponse`, and pipes the body. About 60 lines of adapter code that you never see.\n\nOn Bun it's `Bun.serve({ fetch: handler })`. Node uses the SDK's small server bridge so your exported fetch handler keeps the same shape.\n\n## What the SDK adds\n\nYour fetch handler is your app's logic. The SDK wraps it with infrastructure concerns — things that happen _around_ your handler, not inside it:\n\n```typescript\n// What you write:\nexport default function fetch(request: Request): Response {\n  return new Response(\"Hello\");\n}\n\n// What actually runs (simplified):\nfunction wrappedHandler(request: Request): Response {\n  if (request.headers.get(\"host\") === \"tako\") {\n    return statusEndpoint(); // built-in health check\n  }\n  return yourFetchHandler(request, env);\n}\n```\n\nThe SDK reads [secrets from fd 3](/blog/secrets-without-env-files) before importing your code, intercepts internal health check requests, and signals readiness to the server. Your function stays clean — just `Request` in, `Response` out. The [Why Tako Ships an SDK](/blog/why-tako-ships-an-sdk) post covers this in more detail.\n\n## Framework SSR works too\n\nWhat about full-stack frameworks that aren't just API servers? TanStack Start, Nuxt, SolidStart — they all have SSR builds that produce a server entry.\n\nTako's [Vite plugin](/docs/presets) normalizes their output. After the framework builds, the plugin emits a thin wrapper that finds the fetch handler in the build output — whether it's a default export, a named `fetch` export, or a module with a `.fetch` method — and re-exports it in the shape Tako expects. Same pattern, same infrastructure, same [deploy flow](/docs/deployment).\n\n## The portability argument\n\nThe fetch handler pattern isn't ours. It's the web platform's. If you ever move off Tako, your app is still a valid Bun server or Cloudflare Worker. Remove the SDK, add a small server binding, and you're done.\n\nThis matters because deploy tools come and go. ([RIP Waypoint, RIP Nginx Unit.](/blog/tako-vs-coolify)) The web `Request`/`Response` API is an IETF standard backed by every major runtime. Betting on it means your app code outlives whatever infrastructure runs it.\n\nWe think the best app interface is one you already know. If you've used `fetch()` to make an HTTP call, you already understand how to handle one.\n\nCheck out the [docs](/docs) to get started, or the [CLI reference](/docs/cli) for the full command set."}