Cross-Runtime JavaScript Guide
Write code once, run it in Service Workers, Cloudflare Workers, and Node.js by keeping your core logic on Web-standard APIs and isolating runtime differences in thin adapters.
Goals and boundaries
Your goal is a shared request handler that:
- uses
Request,Response,URL, andHeaders - avoids direct filesystem access
- treats environment bindings as optional
- keeps runtime-specific glue in one place
What this guide is not: a framework-specific adapter. The examples show the smallest portable core you can embed in any runtime.
Runtime differences at a glance
Service Worker:
- no filesystem
- event-driven via
fetchevent cachesis available in browsers
Cloudflare Worker:
- no filesystem
- module or service-worker syntax
- bindings come from
env
Node.js (>=18):
fetch/Request/Responseavailable- filesystem exists, but avoid for portability
- environment variables via
process.env
Compatibility checklist
- Use
globalThisand Web APIs (fetch,Request,Response,Headers,URL). - Avoid
Bufferandfsin shared logic; preferArrayBuffer/Uint8Array. - Keep JSON/text handling explicit (
response.json(),response.text()). - Treat
envas optional and feature-detect missing bindings. - Do not rely on
window/document/DOM in shared modules. - Avoid Node-only globals (
__dirname,process), or guard them. - Keep modules ESM to match Worker runtimes.
Minimal cross-runtime handler
ts
export type RuntimeEnv = Record<string, string | undefined>
export async function handleRequest(
request: Request,
env?: RuntimeEnv,
): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/health') {
return new Response('ok')
}
const apiBase = env?.API_BASE ?? 'https://example.com'
const upstream = new URL(url.pathname, apiBase)
const response = await fetch(upstream.toString(), {
method: request.method,
headers: request.headers,
body: request.method === 'GET' ? undefined : request.body,
})
return response
}Service Worker adapter
ts
import { handleRequest } from './shared'
globalThis.addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request))
})Cloudflare Worker adapter
ts
import { handleRequest } from './shared'
export default {
fetch(request: Request, env: Record<string, string>) {
return handleRequest(request, env)
},
}Node.js adapter (>=18)
ts
import { createServer } from 'node:http'
import { handleRequest } from './shared'
const server = createServer(async (req, res) => {
const request = new Request(`http://localhost${req.url}`, {
method: req.method,
headers: req.headers as Record<string, string>,
})
const response = await handleRequest(request, process.env)
res.writeHead(response.status, Object.fromEntries(response.headers))
res.end(await response.text())
})
server.listen(3000)Common pitfalls
- Passing Node
IncomingMessagedirectly into the shared handler. - Assuming
process.envexists in Workers. - Using
Bufferin shared logic without guards. - Relying on
windowor DOM APIs for parsing URLs or storage.