Remotion Render Times: What Actually Drives Render Speed
By RenderComp Team Editorial policy
Remotion is not a video encoder in the traditional sense. When you run npx remotion render, you are not handing a timeline to ffmpeg and letting it crunch frames. You are asking headless Chromium to seek to a precise timestamp, execute your React component, and capture a screenshot, then repeat that process for every frame in the composition. That framing changes everything about how you should reason over render time.
The practical consequence is that render duration is almost entirely a product of three multiplied quantities: how many frames exist, how many pixels are in each frame, and how much work your React tree does per frame. These interact non-linearly. Doubling fps doubles frame count. Doubling both width and height quadruples pixel count. And a single expensive CSS filter or un-memoized calculation can multiply per-frame cost across thousands of frames. Understanding each lever independently lets you target the right one when a render is slow.
This article goes one level below the CLI docs: the actual mechanics of what happens inside each rendered frame, why certain React patterns are costlier than they look, and how the concurrency model shapes wall-clock time.
How Remotion Renders a Frame
The renderer launches N headless Chromium instances in parallel, where N is controlled by --concurrency. Each instance loads your bundle, receives a frame index, and goes through a deterministic cycle: set a CSS variable (--remotion-frame) on the root, trigger a React re-render, wait for any delayRender gates to resolve, then capture a PNG screenshot. That PNG is handed off to ffmpeg for encoding.
The critical architectural fact: each frame render is stateless from the browser’s perspective. There is no persistence between frames. useState always returns its initial value. useRef.current set during frame 40 is gone by frame 41. useMemo with an empty dependency array recomputes on every frame because Remotion does not pause-and-resume the React tree — it tears it down and rebuilds it for each frame index.
This is fundamentally different from how you might use these hooks in a normal React app, and it is the most common source of performance surprises.
How Frame Count Scales Render Time
Total frames is the simplest cost driver to calculate:
// Total frames = durationInSeconds × fps
// Both values come from your composition config and are available at runtime:
const { fps, durationInFrames } = useVideoConfig();
// 30fps, 10 seconds → 300 frames
// 30fps, 60 seconds → 1,800 frames
// 60fps, 60 seconds → 3,600 frames
// 60fps at 2× duration = 4× the frames of 30fps at 1× duration
When you switch from 30 fps to 60 fps on a 60-second composition, you go from 1,800 frames to 3,600. With identical per-frame cost and concurrency, wall-clock render time doubles. The math is obvious in isolation; it becomes a problem when fps bumps are requested late in a project without accounting for the render budget.
The --every-nth-frame flag lets you sanity-check a composition without a full render:
# Renders frames 0, 5, 10, 15 … — output is 1/5th the duration
npx remotion render --every-nth-frame 5 src/index.ts MyComp preview.mp4
This is not a quality mode; the output is a shortened, choppy clip. Its only job is catching layout or data bugs early before committing to a full render.
Resolution and Pixel Arithmetic
Resolutions are often named by height (720p, 1080p, 4K) but what Chromium and your GPU actually operate on is pixel count, the product of width × height.
| Label | Dimensions | Pixel count | Ratio vs 720p |
|---|---|---|---|
| 720p | 1280 × 720 | 921,600 | 1× |
| 1080p | 1920 × 1080 | 2,073,600 | 2.25× |
| 1440p | 2560 × 1440 | 3,686,400 | 4× |
| 4K | 3840 × 2160 | 8,294,400 | 9× |
Going from 720p to 4K does not make the render 2× slower; it introduces 9× as many pixels. When per-pixel work (compositing, CSS filters, screenshot capture) scales linearly, a 4K render of the same composition takes roughly nine times as long per frame as its 720p equivalent. In practice, Chromium’s compositing pipeline has fixed overheads that make the ratio somewhat better than 9×, but the ceiling is in that neighbourhood.
For compositions that will eventually deliver at 4K, it’s often productive to develop and test at 1080p and upscale as a final step, or to structure the composition so assets load at native resolution only in the final render pass.
Remotion exposes composition dimensions at runtime through useVideoConfig:
import { useVideoConfig, AbsoluteFill } from 'remotion';
export const ResolutionAware: React.FC = () => {
const { width, height, fps, durationInFrames } = useVideoConfig();
// Scale a font size proportionally to composition width
// so the same component works at 720p and 4K
const baseFontSize = width / 32; // 40px at 1280px, 120px at 3840px
return (
<AbsoluteFill style={{ fontSize: baseFontSize }}>
{/* content */}
</AbsoluteFill>
);
};
This pattern avoids hard-coded pixel values that look right at one resolution and wrong at another.
Per-Frame React Cost
Frame count and resolution scale render time predictably. Per-frame React cost does not. Here are the patterns that matter most.
CSS Filters Force Compositing
CSS filters (especially blur(), drop-shadow(), and backdrop-filter) require Chrome to composite affected layers separately before combining them. A 20px Gaussian blur over a 1920×1080 image is not free; it recomputes on every frame the element is visible.
import { useCurrentFrame, interpolate, AbsoluteFill, staticFile } from 'remotion';
// Problematic: blur recomposites on every frame even when the value is static
export const BlurredBackground: React.FC = () => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill>
<img
src={staticFile('bg.jpg')}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
filter: 'blur(20px)', // recomposited every single frame
opacity,
}}
/>
</AbsoluteFill>
);
};
// Better: separate the static blurred layer (always opacity 1) from
// an unblurred overlay that you animate. Chromium can potentially
// cache the composited blur layer across frames when it detects no change.
export const BlurredBackgroundOptimized: React.FC = () => {
const frame = useCurrentFrame();
const overlayOpacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill>
{/* Static blur — no animation on this element */}
<img
src={staticFile('bg.jpg')}
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'blur(20px)' }}
/>
{/* Overlay with the actual animation */}
<AbsoluteFill style={{ opacity: overlayOpacity }}>
{/* content */}
</AbsoluteFill>
</AbsoluteFill>
);
};
Video Elements: <OffthreadVideo> vs <Video>
The HTML <video> element requires Chromium to seek a media decoder to the correct timestamp on every frame render. This involves the browser’s internal media pipeline and can introduce timing uncertainty. <OffthreadVideo> bypasses the HTML video element entirely: it extracts the frame at the target timestamp using ffmpeg on a separate thread and injects it as an image. The result is faster and more deterministic during rendering.
import { OffthreadVideo, AbsoluteFill, staticFile } from 'remotion';
// Preferred for rendering: frame extraction happens off the main thread
export const FootageComposition: React.FC = () => (
<AbsoluteFill>
<OffthreadVideo
src={staticFile('footage.mp4')}
style={{ width: '100%', height: '100%' }}
/>
</AbsoluteFill>
);
<OffthreadVideo> has one trade-off: it shows a black frame during local preview scrubbing because frame extraction is asynchronous. If this interrupts your workflow, use <Video> for development and switch to <OffthreadVideo> for rendering. In production render pipelines, <OffthreadVideo> is the correct choice.
Expensive Calculations That useMemo Cannot Save Across Frames
Because each frame is a fresh React tree, useMemo with an empty dependency array does not cache a value across frame 1 and frame 2 — it only prevents redundant work within a single frame if the component renders more than once (due to context propagation, for instance).
import { useCurrentFrame, spring, useVideoConfig } from 'remotion';
import { useMemo } from 'react';
// This dataset processing runs on EVERY frame — useMemo([]) does not
// persist the result across frame renders.
export const DataVizComp: React.FC<{ data: DataPoint[] }> = ({ data }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// useMemo([]) is only useful if this component re-renders multiple times
// within the same frame (e.g., context updates). It does NOT cache across frames.
const processed = useMemo(() => {
return data
.filter(d => d.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 10);
}, []); // runs fresh every frame in render mode
const scale = spring({ frame, fps, config: { stiffness: 100, damping: 20 } });
return (
<AbsoluteFill>
{processed.map((d, i) => (
<Bar key={d.id} value={d.value} index={i} scale={scale} />
))}
</AbsoluteFill>
);
};
If data is large and the processing is expensive, the cost hits every frame. Move that processing outside the component and pass the result in as props computed once at bundle load time, or precompute it in calculateMetadata and inject it through inputProps.
Controlling Concurrency
The --concurrency flag controls how many Chromium instances run in parallel. Each instance processes frames sequentially; the frames are distributed across instances in order.
// Programmatic render with explicit concurrency
import { renderMedia, selectComposition } from '@remotion/renderer';
const composition = await selectComposition({
serveUrl,
id: 'MyComp',
});
await renderMedia({
composition,
serveUrl,
codec: 'h264',
outputLocation: 'out/render.mp4',
concurrency: 8,
// Wall-clock time ≈ (durationInFrames / concurrency) × avgFrameTime
// For 1,800 frames at concurrency 8: each instance handles ~225 frames
// Overhead: Chromium instance startup + ffmpeg stitching
});
The default concurrency is derived from your machine’s logical core count. Going above that count starts competing with the OS scheduler and typically doesn’t help; you get context-switching overhead with no additional throughput. Going above Math.floor(cpuCount / 2) is worth experimenting with on machines where each Chromium instance is mostly idle (GPU-bound compositions or async asset fetches), but it’s rarely a guaranteed win.
Measuring Per-Frame Cost in Your Composition
Suspicion about which part of a composition is expensive is not enough; instrument it. This diagnostic wrapper logs frame render time from Chrome’s perspective:
import { useCurrentFrame } from 'remotion';
import { useRef, useEffect } from 'react';
export const FrameTimingWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const frame = useCurrentFrame();
// startRef is set at render-start and read at effect-run-time (after commit)
// Both happen within the same frame lifecycle in render mode.
const startRef = useRef(performance.now());
// Reset on each render (each frame triggers a fresh render)
startRef.current = performance.now();
useEffect(() => {
const elapsed = performance.now() - startRef.current;
// Pass --log verbose to see this output during render
console.log(`[frame ${String(frame).padStart(4, '0')}] ${elapsed.toFixed(1)}ms`);
});
return <>{children}</>;
};
Run the render with --log verbose and pipe to grep '\[frame' to get a per-frame timing log. Frames that spike relative to neighbours are where the expensive work lives.
Remotion Lambda for Large Renders
When local concurrency falls short, Remotion Lambda distributes the render across many short-lived Lambda functions. The composition is split into chunks; each function renders its chunk independently and writes encoded frames to S3. A final stitching invocation combines them.
import { renderMediaOnLambda, getRenderProgress } from '@remotion/lambda/client';
const { renderId, bucketName } = await renderMediaOnLambda({
region: 'us-east-1',
functionName: 'remotion-render-4-0-286-mem2048mb-disk2048mb-240sec',
serveUrl: 'https://your-site.s3.us-east-1.amazonaws.com/sites/bundle',
composition: 'MyComp',
inputProps: {},
codec: 'h264',
framesPerLambda: 20,
// For a 600-frame composition: 30 Lambda functions run in parallel
// Wall-clock time shrinks from (600 × frameTime) to (20 × frameTime)
// plus stitching overhead (~10-20 seconds for H.264)
});
// Poll until done
let progress = await getRenderProgress({ renderId, bucketName, region: 'us-east-1', functionName });
while (!progress.done) {
await new Promise(r => setTimeout(r, 3000));
progress = await getRenderProgress({ renderId, bucketName, region: 'us-east-1', functionName });
console.log(`${(progress.overallProgress * 100).toFixed(0)}%`);
}
framesPerLambda is the key knob. Smaller values increase parallelism but also increase the number of Lambda invocations, S3 writes, and stitching complexity. Lambda functions have a maximum execution duration (up to 15 minutes depending on configuration), so for extremely complex frames you may need smaller chunks to avoid timeouts. Start with 20-40 frames per function and adjust based on measured function duration from CloudWatch.
Wrapping Up
The render pipeline runs deterministic, frame-indexed React renders in headless Chromium. Three factors govern wall-clock time:
- Frame count scales linearly with fps and duration. Know your fps × duration before committing to a composition config.
- Resolution scales with pixel count, not dimension label. 4K is 9× the pixels of 720p, not 2×.
- Per-frame cost is where React patterns matter most.
useMemodoes not cache across frames; expensive work must be lifted outside the render cycle. Use<OffthreadVideo>instead of<Video>. Treat CSSblur()andbackdrop-filteras expensive compositing operations, not free styling. - Concurrency divides frame work across CPU cores locally. Adding cores helps until you saturate the machine; beyond that, Remotion Lambda’s chunk-based parallelism is the next lever.
These are the same trade-offs you’ll encounter in production-grade compositions, including templates in the RenderComp catalog, where render times are part of the delivery spec rather than an afterthought. Model your cost before you animate, and the render at the end won’t surprise you.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →