R RenderComp
remotion lambda rendering typescript video-infrastructure

Remotion Render Costs: Lambda, Self-Hosted, and Cloud Run

By RenderComp Team Editorial policy

Rendering a Remotion composition is a deterministic, frame-by-frame computation. Every call to useCurrentFrame() returns an integer, every spring() or interpolate() output is a pure function of that integer, and the final video is nothing more than a sequence of images stitched into a container. That determinism is what makes Remotion composable, but it also means the cost of rendering is a direct function of your frame count, pixel dimensions, composition complexity, and the infrastructure that runs the computation.

Where other video tools hide the rendering process behind an opaque job queue, Remotion exposes it as code. That exposure cuts both ways: you can tune every variable that affects cost, but you have to understand those variables to make good decisions. The three primary surfaces (AWS Lambda via @remotion/lambda, self-hosted renderMedia(), and Google Cloud Run) have meaningfully different cost structures based on concurrency model, cold-start behavior, and how they allocate work across frames.

This article works through the concrete parameters that govern cost on each surface, the trade-offs that are non-obvious from the docs, and the patterns that prevent you from paying for compute you don’t need.


How Remotion Quantifies a Render Job

Before comparing surfaces, it helps to think in Remotion’s own units. A render job has three primary cost drivers:

  1. Frame count, determined by durationInFrames multiplied by the number of compositions.
  2. Frame complexity, including DOM depth, the number of <Video> elements, <OffthreadVideo> decoding, and shader effects.
  3. Output encoding settings: codec choice, image format per frame, and resolution.

The @remotion/renderer package exposes renderMedia(), which is the lowest-level rendering primitive. Everything else, Lambda and Cloud Run alike, wraps this function and adds a parallelism layer on top.

import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";

const bundleLocation = await bundle({
  entryPoint: "./src/index.ts",
  webpackOverride: (config) => config,
});

const composition = await selectComposition({
  serveUrl: bundleLocation,
  id: "MyComposition",
  inputProps: { title: "Q3 Results" },
});

await renderMedia({
  composition,
  serveUrl: bundleLocation,
  codec: "h264",
  outputLocation: "out/video.mp4",
  // imageFormat controls per-frame encoding before stitching
  imageFormat: "jpeg",
  jpegQuality: 80,
  onProgress: ({ progress, renderedFrames, encodedFrames }) => {
    console.log(`Rendered ${renderedFrames}/${composition.durationInFrames}`);
  },
});

imageFormat: "jpeg" is the single most impactful flag most developers overlook. PNG is lossless and significantly slower to encode per frame. On a 300-frame, 1080p composition, switching from PNG to JPEG at quality 80 can cut per-frame encoding time roughly in half, which translates directly to Lambda GB-seconds or EC2 wall-clock time. Use PNG only when you need alpha channel transparency in an intermediate step.


Lambda Chunk Parallelism

@remotion/lambda splits a render job into frame chunks, dispatches each chunk to a separate Lambda invocation, and then reassembles the results. The key parameter controlling this is framesPerLambda.

import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda/client";

const { renderId, bucketName } = await renderMediaOnLambda({
  region: "us-east-1",
  functionName: "remotion-render-4-0-0-mem2048mb-disk2048mb-240sec",
  serveUrl: "https://your-s3-serve-url",
  composition: "MyComposition",
  inputProps: { title: "Q3 Results" },
  codec: "h264",
  imageFormat: "jpeg",
  jpegQuality: 80,
  // How many frames each Lambda invocation renders
  framesPerLambda: 20,
  // Lambda-internal browser tab parallelism
  concurrencyPerLambda: 1,
  timeoutInMilliseconds: 120_000,
  maxRetries: 2,
  // Auto-delete render artifacts after N days
  deleteAfter: "1-day",
});

Tuning framesPerLambda

framesPerLambda is the lever that controls the concurrency fan-out. For a 300-frame composition:

  • framesPerLambda: 20 → 15 Lambda invocations running in parallel
  • framesPerLambda: 60 → 5 Lambda invocations running in parallel
  • framesPerLambda: 300 → 1 invocation (no parallelism; identical to self-hosted)

Smaller chunks mean more parallelism and faster wall-clock time, but each invocation carries a cold-start cost. Lambda cold starts for the Remotion function, which spins up a headless Chromium instance, are heavier than typical Node.js cold starts. In practice, values between 20 and 40 frames per Lambda function tend to hit the sweet spot for compositions under 10 minutes.

The floor is set by composition.fps. A chunk must contain at least one full second to avoid partial-second boundary artifacts in the final stitch. At 30 fps, framesPerLambda: 15 is the practical minimum.

concurrencyPerLambda: Parallelism Within a Single Invocation

Each Lambda invocation can open multiple Chromium tabs and render frames concurrently within its memory allocation. concurrencyPerLambda: 2 opens two tabs and renders two frames simultaneously inside the same function.

// Higher concurrency uses more memory within the Lambda function
// Match this to the function's memory allocation
const { renderId } = await renderMediaOnLambda({
  // ...
  concurrencyPerLambda: 2, // doubles throughput per invocation at ~same cost
});

Since Lambda bills by GB-second, doubling concurrency within a function does not double cost. You’re using the same allocated memory regardless of whether one or two tabs are active. The risk is OOM errors if your composition is memory-heavy. Keep concurrencyPerLambda at 1 for compositions with multiple <OffthreadVideo> elements or large asset sets.

Polling Render Progress

Lambda renders are asynchronous. getRenderProgress() returns the current state and is designed to be polled:

const poll = async (renderId: string, bucketName: string) => {
  while (true) {
    const progress = await getRenderProgress({
      renderId,
      bucketName,
      functionName: "remotion-render-4-0-0-mem2048mb-disk2048mb-240sec",
      region: "us-east-1",
    });

    if (progress.done) {
      console.log("Output at:", progress.outputFile);
      return progress;
    }

    if (progress.fatalErrorEncountered) {
      throw new Error(progress.errors[0]?.message ?? "Unknown render error");
    }

    // overallProgress is a 0–1 float
    console.log(`${Math.round(progress.overallProgress * 100)}% complete`);
    await new Promise((r) => setTimeout(r, 2000));
  }
};

progress.costs (available in newer Lambda package versions) returns the accumulated Lambda cost estimate for the current render job. This is useful for observability dashboards and for setting hard cutoffs in batch pipelines.


Self-Hosted Rendering and Browser Pools

Running renderMedia() on your own infrastructure (EC2, a container, or a dedicated render box) eliminates Lambda’s per-invocation overhead and cold-start unpredictability. The cost model shifts from pay-per-use to pay-per-uptime, which is favorable at high render volume and for long-running compositions where Lambda’s 15-minute invocation limit becomes a constraint.

The critical operational challenge is browser lifecycle management. Each renderMedia() call by default launches and destroys a Chromium instance. For burst workloads this is fine; for sustained throughput it wastes 1-3 seconds per render on browser startup.

import { openBrowser, renderMedia, selectComposition } from "@remotion/renderer";

// Open one browser per worker; reuse across renders
const browser = await openBrowser("chrome");

const renderWithReuse = async (compositionId: string, props: Record<string, unknown>) => {
  const serveUrl = "http://localhost:3000"; // or a bundled serve URL

  const composition = await selectComposition({
    serveUrl,
    id: compositionId,
    inputProps: props,
    // Pass the open browser instance to skip launch overhead
    puppeteerInstance: browser,
  });

  await renderMedia({
    composition,
    serveUrl,
    codec: "h264",
    outputLocation: `out/${compositionId}-${Date.now()}.mp4`,
    imageFormat: "jpeg",
    jpegQuality: 80,
    puppeteerInstance: browser, // reuse the same Chromium process
    concurrency: 4, // render 4 frames in parallel within this process
  });
};

// Render multiple compositions without restarting Chrome
await renderWithReuse("SalesSlide", { quarter: "Q3" });
await renderWithReuse("SalesSlide", { quarter: "Q4" });

await browser.close();

Sizing the Worker Pool

Self-hosted concurrency is governed by two separate levers: the concurrency option inside renderMedia(), which sets the number of frames rendered in parallel within a single render job (each in its own browser tab), and the number of worker processes consuming from your queue and calling renderMedia() concurrently.

Each browser tab in Remotion consumes roughly 150-400 MB of RAM depending on asset load. A composition with a <OffthreadVideo> element decoding a 1080p source will sit toward the upper end. On a machine with 8 GB RAM allocated to rendering, running 8 concurrent tabs is feasible for lightweight compositions; plan for 4 if decoding external video.

Measure your composition’s per-tab memory footprint before setting concurrency:

// Instrument a single-tab render and inspect process memory
const before = process.memoryUsage().heapUsed;
await renderMedia({ /* ... */ concurrency: 1, puppeteerInstance: browser });
const after = process.memoryUsage().heapUsed;
console.log(`Heap delta: ${Math.round((after - before) / 1024 / 1024)} MB`);

This is heap only; actual browser process memory is separate and larger. process.memoryUsage() gives you a floor, not a ceiling.


Cloud Run as a Middle Path

Google Cloud Run (@remotion/cloudrun) uses the same chunk-dispatch model as Lambda but runs on container instances rather than Lambda functions. The practical difference is that Cloud Run instances stay warm between requests within a scaling window, which reduces cold-start frequency for moderate-volume workloads compared to Lambda.

import { renderMediaOnCloudrun } from "@remotion/cloudrun/client";

const { renderId, bucketName } = await renderMediaOnCloudrun({
  cloudRunUrl: "https://your-cloud-run-service-url",
  serveUrl: "https://your-gcs-serve-url",
  composition: "MyComposition",
  inputProps: { title: "Q3 Results" },
  codec: "h264",
  region: "us-east1",
  privacy: "private",
});

The API surface mirrors Lambda closely, which makes it straightforward to abstract both behind a common interface if you need to hedge across providers.


Cross-Cutting Cost Levers

Several parameters affect cost regardless of rendering surface.

Codec Selection and Reassembly Cost

h264 is the lowest-complexity codec for most output targets. h265 produces smaller files but requires more CPU during encoding. On Lambda, that translates to longer invocation time and higher GB-seconds cost for the same composition. vp8 and vp9 are slower still.

For intermediate renders (compositing steps you’ll process further), consider prores or raw frames. ProRes is fast to encode and decode, making it cost-effective in multi-pass pipelines where you’re not delivering the intermediate to an end user.

// For intermediate steps: fast encode, high quality, large file
const intermediateCodec = "prores";

// For delivery: small file, universal playback
const deliveryCodec = "h264";

deleteAfter: Controlling S3 Artifact Cost

Lambda and Cloud Run write frame chunks and the final output to object storage. Without cleanup, these accumulate. The deleteAfter parameter sets an S3 lifecycle expiration on the render prefix:

await renderMediaOnLambda({
  // ...
  deleteAfter: "3-days", // "1-day" | "3-days" | "7-days" | "30-days"
});

For a high-volume pipeline, the storage cost of unconsumed render artifacts can exceed the Lambda compute cost over a month. Set deleteAfter to the minimum retention your downstream pipeline requires.

Composition Duration and durationInFrames Guards

The most common source of unexpectedly long (and expensive) renders is a composition whose duration is driven by dynamic data but has no upper bound guard:

import { useVideoConfig } from "remotion";

// In your composition:
const { durationInFrames, fps } = useVideoConfig();

// Guard: if your data pipeline sets this, validate at the call site
const MAX_DURATION_FRAMES = 30 * fps; // 30 seconds maximum

if (durationInFrames > MAX_DURATION_FRAMES) {
  throw new Error(
    `Composition duration ${durationInFrames} exceeds maximum ${MAX_DURATION_FRAMES}`
  );
}

Validate duration before dispatching to Lambda. A 10-minute accidental render at 1080p/30fps costs roughly 10× a 1-minute render, and catching this at the queue level rather than the billing level is substantially cheaper.


Choosing the Right Surface

ScenarioRecommended Surface
Burst rendering, unpredictable volumeLambda or Cloud Run
High sustained volume (100+ renders/day)Self-hosted with browser pool
Compositions > 15 minutesSelf-hosted (Lambda timeout constraint)
Strict latency SLA, warm instances neededCloud Run with min-instances > 0
Multi-pass pipeline with intermediate framesSelf-hosted with ProRes intermediate
Prototyping / developmentSelf-hosted local renderMedia()

Lambda’s managed scaling is a strong default for product features where render volume is tied to user activity. It requires no infrastructure maintenance and handles zero-to-burst without pre-warming. Self-hosted becomes compelling when your volume is high enough that EC2 spot instances are cheaper per render than Lambda GB-seconds, or when your compositions have characteristics that stress Lambda’s invocation model: very long durations, large in-memory asset sets, or tight latency requirements that can’t tolerate cold starts.


Wrapping Up

Remotion’s deterministic rendering model means every cost variable is observable and tunable through code. The practical takeaways:

  • Switch imageFormat to "jpeg" first. It’s the highest-leverage, lowest-risk change for reducing per-frame encoding time.
  • Tune framesPerLambda against your composition’s cold-start cost. For compositions under 5 minutes at 30fps, values between 20 and 40 balance parallelism against invocation overhead.
  • Reuse browser instances in self-hosted setups. Passing puppeteerInstance to renderMedia() eliminates 1-3 seconds of startup cost per job.
  • Set deleteAfter on Lambda renders unless your pipeline explicitly requires longer artifact retention.
  • Validate durationInFrames before dispatch. Unbounded composition durations are the most common source of runaway render costs.

The templates in the RenderComp catalog are built to be render-surface-agnostic. Their compositions expose durationInFrames as a configurable prop and avoid patterns (like synchronous network requests inside components) that cause differential behavior between local and Lambda execution. Whatever surface you deploy against, the render output should be byte-identical for the same input props.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →