How Remix builds on web standards for maximum interoperability and portability
Remix is built entirely on web standards. This means Remix packages work seamlessly across any JavaScript runtime that implements standard web APIs - no runtime-specific code required.
Remix uses Web Streams instead of Node.js streams:
// ✅ Web Streams API (works everywhere)import { parseMultipartRequest } from 'remix/multipart-parser'async function handleUpload(request: Request) { // Request.body is a ReadableStream for await (let part of parseMultipartRequest(request)) { if (part.isFile) { // Stream file content without buffering let stream = part.stream() // Returns ReadableStream await saveToStorage(stream) } }}// ❌ Node.js streams (runtime-specific)// import { Readable } from 'node:stream'
Streaming Response
Custom Stream
import { openLazyFile } from 'remix/fs'// Stream large files efficientlylet file = openLazyFile('./video.mp4')return new Response(file.stream(), { headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(file.size), },})
// Create custom streaming responseslet stream = new ReadableStream({ async start(controller) { for (let i = 0; i < 10; i++) { controller.enqueue( new TextEncoder().encode(`Chunk ${i}\n`), ) await new Promise(resolve => setTimeout(resolve, 1000)) } controller.close() },})return new Response(stream, { headers: { 'Content-Type': 'text/plain' },})
Remix uses standard Uint8Array instead of Node.js Buffer:
import { parseMultipartRequest } from 'remix/multipart-parser'async function handleUpload(request: Request) { for await (let part of parseMultipartRequest(request)) { if (part.isFile) { // part.bytes is a Uint8Array (works everywhere) let bytes: Uint8Array = part.bytes // Convert to ArrayBuffer if needed let buffer: ArrayBuffer = part.arrayBuffer console.log(`Received ${bytes.byteLength} bytes`) } }}
Uint8Array is a standard JavaScript typed array that works in all environments. Node.js Buffer is a Node-specific subclass of Uint8Array.
import { createRouter } from 'remix/fetch-router'import { formData } from 'remix/form-data-middleware'let router = createRouter({ middleware: [formData()],})router.post('/contact', ({ get }) => { // FormData is parsed by middleware let form = get(FormData) let name = form.get('name') as string let email = form.get('email') as string let message = form.get('message') as string // File uploads are File objects let avatar = form.get('avatar') as File return Response.json({ success: true })})
import { type LazyContent, LazyFile } from 'remix/lazy-file'// LazyFile implements the File interface// but accepts a LazyContent sourcelet content: LazyContent = { byteLength: 100000, stream(start, end) { return new ReadableStream({/* ... */}) },}let lazyFile = new LazyFile(content, 'data.bin', { type: 'application/octet-stream',})// Still a valid File - can convert to standard Filelet standardFile = await lazyFile.toFile()
.toFile() and .toBlob() read the entire file into memory. Only use these for non-streaming APIs like FormData. Always prefer .stream() when possible.
Here’s the same file upload functionality implemented across different runtimes using identical Remix code:
Node.js
Bun
Deno
Cloudflare Workers
import * as http from 'node:http'import { parseMultipartRequest } from 'remix/multipart-parser/node'let server = http.createServer(async (req, res) => { for await (let part of parseMultipartRequest(req)) { if (part.isFile) { console.log(`File: ${part.filename} (${part.size} bytes)`) } }})server.listen(3000)
import { parseMultipartRequest } from 'remix/multipart-parser'Bun.serve({ port: 3000, async fetch(request) { for await (let part of parseMultipartRequest(request)) { if (part.isFile) { console.log(`File: ${part.filename} (${part.size} bytes)`) } } return new Response('OK') },})
import { parseMultipartRequest } from 'remix/multipart-parser'Deno.serve({ port: 3000 }, async (request) => { for await (let part of parseMultipartRequest(request)) { if (part.isFile) { console.log(`File: ${part.filename} (${part.size} bytes)`) } } return new Response('OK')})
import { parseMultipartRequest } from 'remix/multipart-parser'export default { async fetch(request, env) { for await (let part of parseMultipartRequest(request)) { if (part.isFile) { // Upload to R2 await env.BUCKET.put( part.filename, part.bytes, ) } } return new Response('OK') },}
Notice how the core parsing logic is identical across all runtimes. Only the server setup differs based on each runtime’s API.