Skip to main content
Remix packages work seamlessly across all JavaScript runtimes without modification. Write your code once and run it everywhere - Node.js, Bun, Deno, Cloudflare Workers, and any future runtime that implements web standards.

What Does Runtime Agnostic Mean?

Runtime agnostic means your application code doesn’t depend on runtime-specific APIs:

Write Once

Write your business logic once using web standards

Run Everywhere

Deploy to any JavaScript runtime without changes

No Adapters

No need for runtime-specific adapters or polyfills

Future-Proof

Automatically works with new runtimes as they emerge

The Portability Principle

Remix achieves runtime portability by:
  1. Building on Web Standards - Using only APIs that exist across all runtimes
  2. Avoiding Node-specific APIs - No node:stream, node:crypto, or Buffer
  3. Testing Across Runtimes - Every package has demos for multiple runtimes
  4. Runtime-specific Entry Points - Minimal adapters when unavoidable (e.g., Node.js http.Server)
Over 90% of Remix code is runtime-agnostic. Only server bootstrapping code differs between runtimes.

Real-World Example: File Upload Parser

Here’s a complete file upload parser that runs identically across all runtimes:

The Core Logic (Runtime Agnostic)

import { parseMultipartRequest } from 'remix/multipart-parser'

async function handleUpload(request: Request): Promise<Response> {
  let files: Array<{ name: string; size: number }> = []
  
  // This code works everywhere - no runtime-specific APIs
  for await (let part of parseMultipartRequest(request)) {
    if (part.isFile) {
      files.push({
        name: part.filename,
        size: part.size,
      })
      
      // Save file (implementation varies by runtime)
      await saveFile(part.filename, part.bytes)
    }
  }
  
  return Response.json({ files })
}

Node.js Implementation

import * as http from 'node:http'
import { parseMultipartRequest } from 'remix/multipart-parser/node'
import { writeFile } from 'node:fs/promises'

async function saveFile(filename: string, bytes: Uint8Array) {
  await writeFile(`./uploads/${filename}`, bytes)
}

let server = http.createServer(async (req, res) => {
  if (req.method === 'POST') {
    try {
      let files: Array<{ name: string; size: number }> = []
      
      for await (let part of parseMultipartRequest(req)) {
        if (part.isFile) {
          files.push({ name: part.filename, size: part.size })
          await saveFile(part.filename, part.bytes)
        }
      }
      
      res.writeHead(200, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify({ files }))
    } catch (error) {
      res.writeHead(500)
      res.end('Internal Server Error')
    }
  }
})

server.listen(3000)

Bun Implementation

import { parseMultipartRequest } from 'remix/multipart-parser'

async function saveFile(filename: string, bytes: Uint8Array) {
  await Bun.write(`./uploads/${filename}`, bytes)
}

Bun.serve({
  port: 3000,
  async fetch(request) {
    if (request.method === 'POST') {
      let files: Array<{ name: string; size: number }> = []
      
      for await (let part of parseMultipartRequest(request)) {
        if (part.isFile) {
          files.push({ name: part.filename, size: part.size })
          await saveFile(part.filename, part.bytes)
        }
      }
      
      return Response.json({ files })
    }
    
    return new Response('Method Not Allowed', { status: 405 })
  },
})

Deno Implementation

import { parseMultipartRequest } from 'remix/multipart-parser'

async function saveFile(filename: string, bytes: Uint8Array) {
  await Deno.writeFile(`./uploads/${filename}`, bytes)
}

Deno.serve({ port: 3000 }, async (request) => {
  if (request.method === 'POST') {
    let files: Array<{ name: string; size: number }> = []
    
    for await (let part of parseMultipartRequest(request)) {
      if (part.isFile) {
        files.push({ name: part.filename, size: part.size })
        await saveFile(part.filename, part.bytes)
      }
    }
    
    return Response.json({ files })
  }
  
  return new Response('Method Not Allowed', { status: 405 })
})

Cloudflare Workers Implementation

import { parseMultipartRequest } from 'remix/multipart-parser'

interface Env {
  UPLOADS: R2Bucket
}

export default {
  async fetch(request, env): Promise<Response> {
    if (request.method === 'POST') {
      let files: Array<{ name: string; size: number }> = []
      
      for await (let part of parseMultipartRequest(request)) {
        if (part.isFile) {
          files.push({ name: part.filename, size: part.size })
          
          // Upload to R2 (Cloudflare's S3-compatible storage)
          await env.UPLOADS.put(part.filename, part.bytes, {
            httpMetadata: {
              contentType: part.mediaType,
            },
          })
        }
      }
      
      return Response.json({ files })
    }
    
    return new Response('Method Not Allowed', { status: 405 })
  },
} satisfies ExportedHandler<Env>
Notice how the core parsing logic is identical across all runtimes. Only the server setup and file storage differ.

Runtime-Specific Entry Points

Some packages provide runtime-specific entry points when necessary:

multipart-parser

// Works with: Bun, Deno, Cloudflare Workers, Node.js (with undici)
import { parseMultipartRequest } from 'remix/multipart-parser'

// Expects: Request with ReadableStream body
for await (let part of parseMultipartRequest(request)) {
  // ...
}
The Node-specific entry point exists because Node’s http.IncomingMessage uses Node.js streams instead of Web Streams. The parsing logic is identical - only the input adapter differs.

Building Portable Routers

The fetch-router package is completely runtime agnostic:
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'

// This router works everywhere
let routes = route({
  api: {
    users: '/api/users',
    posts: '/api/posts',
  },
})

let router = createRouter()

router.map(routes.api, {
  actions: {
    users: () => Response.json({ users: [] }),
    posts: () => Response.json({ posts: [] }),
  },
})

// Test with standard fetch
let response = await router.fetch('https://api.example.com/api/users')
Connect to any runtime:
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'

let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000)

Streaming Across Runtimes

Web Streams work identically everywhere:
import { openLazyFile } from 'remix/fs'

// This code works in any runtime
let file = openLazyFile('./video.mp4')

return new Response(file.stream(), {
  headers: {
    'Content-Type': 'video/mp4',
    'Content-Length': String(file.size),
    'Accept-Ranges': 'bytes',
  },
})
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'
import { openLazyFile } from 'remix/fs'

async function handler(request: Request) {
  let file = openLazyFile('./video.mp4')
  
  return new Response(file.stream(), {
    headers: {
      'Content-Type': 'video/mp4',
      'Content-Length': String(file.size),
    },
  })
}

http.createServer(createRequestListener(handler)).listen(3000)

Performance Characteristics

Each runtime has different performance characteristics, but Remix code runs efficiently on all of them:
  • Mature runtime with excellent ecosystem
  • Good performance for I/O-bound workloads
  • Native support for Web APIs since v16.5.0
  • Extremely fast startup times
  • Native TypeScript and JSX support
  • Optimized for web APIs (built on JavaScriptCore)
  • Best performance for many workloads
  • Secure by default (explicit permissions)
  • Native TypeScript support
  • Web standards-first design
  • Great for CLI tools and scripts
  • Global edge deployment
  • Instant cold starts
  • Scales to zero automatically
  • Best for API endpoints and middleware

Deployment Flexibility

Runtime portability gives you deployment flexibility:
// Same application code
import { createApp } from './app.ts'

let app = createApp()
Deploy to a VPS with Node.js or Bun:
# Node.js
node server.ts

# Bun
bun run server.ts

Testing Across Runtimes

Every Remix package includes demos for multiple runtimes:
# Package structure
packages/multipart-parser/
├── src/              # Runtime-agnostic source
├── demos/
   ├── node/         # Node.js demo
   ├── bun/          # Bun demo
   ├── deno/         # Deno demo
   └── cf-workers/  # Cloudflare Workers demo
All demos use identical parsing logic. Only the server setup differs.

Best Practices

Always prefer web standard APIs over runtime-specific ones. This ensures portability.
If you must use runtime-specific APIs, abstract them behind a common interface.
Test your application on at least two runtimes to catch portability issues early.
Don’t polyfill Node.js APIs. Use web standards instead.
Avoid runtime checks like if (typeof process !== 'undefined'). Design for standards instead.

Migration Strategy

Migrating from Node-specific code:
1

Replace Node Streams

Replace node:stream with Web Streams API:
// Before (Node-specific)
import { Readable } from 'node:stream'
let stream = Readable.from(data)

// After (Web Standard)
let stream = new ReadableStream({
  start(controller) {
    controller.enqueue(data)
    controller.close()
  },
})
2

Replace Buffer with Uint8Array

Replace Node.js Buffer with Uint8Array:
// Before (Node-specific)
let buffer = Buffer.from('hello')

// After (Web Standard)
let bytes = new TextEncoder().encode('hello')
3

Use Web Crypto

Replace node:crypto with Web Crypto API:
// Before (Node-specific)
import { createHash } from 'node:crypto'
let hash = createHash('sha256').update(data).digest('hex')

// After (Web Standard)
let hashBuffer = await crypto.subtle.digest('SHA-256', data)
let hashArray = Array.from(new Uint8Array(hashBuffer))
let hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
4

Test on Another Runtime

Run your tests on Bun or Deno to verify portability:
bun test
deno test --allow-net --allow-read

Next Steps

Web Standards

Deep dive into web standards used by Remix

Architecture

Learn about Remix’s package architecture

Deployment

Deploy Remix to different runtimes

Node Fetch Server

Connect Remix to Node.js

Build docs developers (and LLMs) love