Engineering / / 5 min read
Stop Making HTTP Requests to Yourself During SSR
The Round Trip to Nowhere
In most fullstack frameworks, server-side rendering fetches data the same way the browser does: over HTTP.
export default async function PlanetListPage() {
const response = await fetch('https://myapp.com/api/planets')
const planets = await response.json()
// ...
}
The server needs data, so it opens a socket, serializes a request, sends it across the network stack, and waits, only to land in an API route running in the very same process.
This works, but you pay for it on every render:
- A full HTTP round trip: connection handling, header parsing, a second request lifecycle.
- On serverless platforms, a self-fetch can invoke a second function instance: extra cold starts, double billing, and a deadlock risk when concurrency is capped.
- Cookies and auth headers are not forwarded automatically. Everyone learns this the first time SSR renders the logged-out page for a logged-in user.
- Relative URLs like
fetch('/api/planets')fail on the server, so now you maintain a base-URL environment variable per deployment.
The data logic is right there in the same process. SSR should call it directly.
An API Handler Is Just a Function
The Fetch API made this fixable. Modern API code, whether plain or built with a fetch-standard framework, boils down to one signature:
export async function handleApi(request: Request): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/api/planets' && request.method === 'GET') {
return Response.json(await db.planets.list())
}
return new Response('Not Found', { status: 404 })
}
Nothing in that signature requires a network. fetch itself is just a function from a request to a promise of a response, so you can implement it with a direct call:
const internalFetch: typeof fetch = async (input, init) =>
handleApi(new Request(input, init))
Any code written against fetch now runs your API in-process: no socket, no port, no second request lifecycle. The origin in the URL becomes a formality, since it never leaves the process, which also kills the base-URL problem. And because you construct the internal Request yourself, passing the incoming request’s headers along is one argument, not a forwarding layer.
Write your data-fetching client to accept a fetch implementation and it works in both worlds:
export function createClient(fetchFn: typeof fetch, origin: string) {
return {
async listPlanets(): Promise<Planet[]> {
const response = await fetchFn(new URL('/api/planets', origin))
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
return response.json()
},
}
}
This is not a fringe idea. SvelteKit’s load function does exactly this with the fetch it hands you: during SSR it invokes your endpoint handler directly, no HTTP request issued.
The Trap: One Client, Two Environments
You want a single client import that every page uses, on the server and in the browser. The obvious version is a runtime check:
// ❌ looks fine, ships your database to the browser
import { handleApi } from './api'
export const client = typeof window === 'undefined'
? createClient(internalFetch, 'http://internal')
: createClient(fetch, window.location.origin)
The branch runs at runtime, but bundling happens at build time. The import { handleApi } line pulls your entire API, and everything it imports, such as the database driver and secrets handling, into the client module graph. Best case, your framework fails the build. Worst case, server code quietly ships to every visitor.
The globalThis Trick
The fix is to make the server side of the wiring invisible to the bundler. The shared module never imports server code; it looks up a well-known global and falls back to the browser client:
declare global {
var $client: Client | undefined
}
/**
* SSR uses the client registered on globalThis.
* The browser falls back to a real fetch against the current origin.
*/
export const client: Client = globalThis.$client
?? createClient(fetch, window.location.origin)
A separate server-only module creates the internal client and registers it:
import 'server-only'
import { handleApi } from './api'
import { createClient } from './client'
globalThis.$client = createClient(
(input, init) => handleApi(new Request(input, init)),
'http://internal',
)
There is no import path from client.ts to client.server.ts, so the client bundle contains only the shared module. The link between the two exists purely at runtime, on the server, where both live in the same process. The server-only marker turns any accidental client-side import into a build error instead of a leak.
Two details make this robust:
- Load order. The server module must run before anything renders. In Next.js that means importing it in both
instrumentation.tsand the root layout; other frameworks have an equivalent server entry point. - A loud failure mode. If registration is ever skipped, the fallback touches
windowon the server and throws immediately, instead of silently fetching from yourself again.
Skipping the Fetch Layer Entirely
The internal fetch still constructs Request and Response objects and serializes your data to JSON just to parse it back. If your API logic is reachable as plain functions, SSR can call those directly and skip serialization too. The trade-off: anything living in your HTTP layer, such as middleware, logging, or plugins, no longer runs, so per-request concerns like auth context have to be applied another way.
Both are valid endpoints of the same idea. The fetch-based internal client keeps your whole HTTP pipeline while removing the network; the direct call removes everything but your logic.
The Takeaway
SSR data fetching has a default shape, HTTP to yourself, that exists only because it is the shortest code. The Fetch API’s real gift to servers is that a handler is a callable function, and callable functions do not need sockets to be called. Wire the internal client through globalThis so the bundler never sees it, guard the server module with server-only, and the same one-line import { client } does the right thing everywhere.
If you use oRPC, this whole pattern is prepackaged: Optimizing SSR shows both the internal RPC Link and the even leaner server-side client, fully typed end to end. If you are building your own, steal the global.