When you mix a CommonJS Node.js worker with modern ESM-only packages, you get a crash that looks harmless but is actually an architectural problem. Here's how we hit it, diagnosed it, and fixed it without converting our entire codebase.
The Setup
Our stack:
- A Next.js 15 app on Vercel (supports ESM natively)
- A Railway worker — a separate Express + Inngest process compiled with
tsctargeting CommonJS - Both share the same source, with the worker having its own
tsconfig.jsonpointing toworker/src/
We recently migrated Remotion video rendering to Vercel Sandbox VMs. That meant importing @remotion/vercel, @vercel/blob, and @vercel/sandbox in our render pipeline.
All three packages are ESM-only. They ship no CJS bundle. If you require() them — directly or via TypeScript's static import compiled to CJS — Node.js throws:
Error [ERR_REQUIRE_ESM]: require() of ES Module
node_modules/@remotion/vercel/dist/index.js not supported.
Instead change the require of index.js to a dynamic import()
Our Railway worker was CJS. So the moment we added those imports at the top of the file, it crashed at startup — before handling a single job.
Why Static Imports Break CJS
TypeScript compiles import { createSandbox } from "@remotion/vercel" to:
// compiled output (CJS)
const remotion_vercel_1 = require("@remotion/vercel");
Node.js evaluates this synchronously at module load time. Since @remotion/vercel is ESM, the synchronous require() fails immediately.
This happens even if the code path that actually calls createSandbox is never reached in the Railway worker. The crash is at import time, not call time.
Fix: Dynamic import() at Call Site
The fix is to replace top-level static imports with dynamic import() calls inside the functions that actually need them. Dynamic imports are asynchronous and work in both CJS and ESM environments.
Before (crashes on Railway):
import { del } from "@vercel/blob";
import {
addBundleToSandbox,
createSandbox,
renderMediaOnVercel,
uploadToVercelBlob,
} from "@remotion/vercel";
import { restoreSnapshot } from "./restore-snapshot";
export async function renderWithRemotion(opts: RenderOpts) {
// ...
const sandbox = await createSandbox({ ... });
}
After (works in both CJS and ESM):
// No top-level ESM imports
export async function renderWithRemotion(opts: RenderOpts) {
// Dynamic imports — @remotion/vercel and @vercel/blob are ESM-only packages.
// Using dynamic import() avoids CJS require() failures on Railway worker.
const { addBundleToSandbox, createSandbox, renderMediaOnVercel, uploadToVercelBlob } =
await import("@remotion/vercel");
const { del } = await import("@vercel/blob");
const { restoreSnapshot } = await import("./restore-snapshot");
// ...
const sandbox = await createSandbox({ ... });
}
Same for restore-snapshot.ts which uses @vercel/blob and @vercel/sandbox:
// Before: static imports at top
import { get } from "@vercel/blob";
import { Sandbox } from "@vercel/sandbox";
export async function restoreSnapshot() {
const blob = await get(snapshotBlobKey, { ... });
return Sandbox.create({ ... });
}
// After: dynamic imports inside the function
export async function restoreSnapshot() {
const { get } = await import("@vercel/blob");
const { Sandbox } = await import("@vercel/sandbox");
const blob = await get(snapshotBlobKey, { ... });
return Sandbox.create({ ... });
}
But Railway Still Couldn't Call @remotion/vercel Directly
The dynamic import fix let the worker start without crashing. But we realized Railway couldn't actually render using @remotion/vercel even with dynamic imports — for a different reason.
@remotion/vercel internally uses @rspack/binding, a native Node.js addon. Vercel Sandbox VMs have the right environment for this. A Railway Docker container does not. The binding isn't available there.
So we split the responsibility:
-
Railway Inngest worker receives the render job, makes a
POST /api/renderHTTP call to Vercel -
Vercel API route (
/api/render) runs inside a proper Vercel environment, creates the Sandbox, renders, uploads to Vercel Blob, returns the URL - Railway worker downloads the rendered video from Blob, uploads to R2, cleans up
// worker/src/inngest-lib/render-clip.ts
export async function renderWithRemotion(opts: RenderOpts): Promise<void> {
// Call the Vercel render API — Railway can't run @remotion/vercel natively
const renderApiUrl = process.env.RENDER_API_URL || "https://yourapp.vercel.app/api/render";
const renderApiSecret = process.env.RENDER_API_SECRET;
const res = await fetch(renderApiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${renderApiSecret}`,
},
body: JSON.stringify(inputProps),
signal: AbortSignal.timeout(10 * 60 * 1000), // 10 min
});
const { url: blobUrl } = await res.json();
// Download from Vercel Blob, write to disk
const videoRes = await fetch(blobUrl);
await fs.writeFile(outputPath, Buffer.from(await videoRes.arrayBuffer()));
// Clean up temp blob (non-fatal if it fails — will expire)
try {
const { del } = await import("@vercel/blob");
await del(blobUrl, { token: process.env.BLOB_READ_WRITE_TOKEN });
} catch {
console.warn("[remotion] Failed to delete temp blob, will expire");
}
}
The TypeScript Typing Gotcha
One side effect of dynamic imports: you lose the inferred return type of restoreSnapshot(). Before, it was Promise<InstanceType<typeof Sandbox>>. After, TypeScript infers Promise<any> because the Sandbox type isn't visible at the top level.
The pragmatic fix: drop the explicit return type annotation and let TypeScript widen it. The callers don't inspect the sandbox object directly — they pass it to @remotion/vercel functions that accept any anyway.
If you need the type, you can import it separately as a type-only import (these are erased at compile time and don't trigger the CJS issue):
import type { Sandbox } from "@vercel/sandbox"; // type-only, safe in CJS
export async function restoreSnapshot(): Promise<Sandbox> {
const { Sandbox: SandboxClass } = await import("@vercel/sandbox"); // runtime import
return SandboxClass.create({ ... });
}
Takeaways
ESM-only packages crash CJS workers at startup, not at call time. If your worker is compiled to CJS, any top-level
importof an ESM-only package will fail immediately.import()is the escape hatch. Dynamic imports work in both CJS and ESM. Move ESM-only imports inside the functions that need them.Dynamic imports don't fix native binary requirements. If a package needs a platform-specific native addon (like
@rspack/binding), you need to run it in the right environment regardless of import style. In that case, HTTP delegation — having the worker call a Vercel API route that runs in the correct environment — is a clean solution.import typeis safe anywhere. Type-only imports are erased by TypeScript and never reach the Node.js module loader. You can use them freely in CJS code.
The full pattern — CJS worker → HTTP call → ESM-native Vercel route — let us keep Railway for job orchestration (where it excels: long-running workers, native binaries, Docker) and Vercel for rendering (where it excels: Sandbox VMs, ESM-native environment, Remotion integration).
Top comments (0)