DEV Community

nareshipme
nareshipme

Posted on

How We Fixed ESM-Only Package Crashes in a CJS Node.js Worker (Without Rewriting Everything)

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 tsc targeting CommonJS
  • Both share the same source, with the worker having its own tsconfig.json pointing to worker/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()
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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({ ... });
}
Enter fullscreen mode Exit fullscreen mode

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({ ... });
}
Enter fullscreen mode Exit fullscreen mode

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({ ... });
}
Enter fullscreen mode Exit fullscreen mode
// 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({ ... });
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Railway Inngest worker receives the render job, makes a POST /api/render HTTP call to Vercel
  2. Vercel API route (/api/render) runs inside a proper Vercel environment, creates the Sandbox, renders, uploads to Vercel Blob, returns the URL
  3. 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");
  }
}
Enter fullscreen mode Exit fullscreen mode

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({ ... });
}
Enter fullscreen mode Exit fullscreen mode

Takeaways

  1. ESM-only packages crash CJS workers at startup, not at call time. If your worker is compiled to CJS, any top-level import of an ESM-only package will fail immediately.

  2. import() is the escape hatch. Dynamic imports work in both CJS and ESM. Move ESM-only imports inside the functions that need them.

  3. 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.

  4. import type is 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)