A Remotion video that looks right in the Studio and wrong in the final file almost always has one of two causes. Either something moves or changes on a clock of its own instead of following the frame number, or the renderer takes a picture of a frame before an image, a font or a piece of data has arrived. The Remotion docs name exactly these two causes for flicker and choppiness in a Remotion render: a multi-threading issue, and a renderer that never waits for late data or assets (Remotion flickering page). This guide walks through the mistakes behind them, one section each, and ends with a check that compares two renders and can fail.

The idea the renderer depends on

In Remotion a component receives the current frame as a number plus an empty canvas, and everything on screen is meant to follow from that number (the core idea). The frame number comes from useCurrentFrame(), which is zero-indexed, so the first frame is 0 (useCurrentFrame).

The Studio preview plays the video forward in one browser tab, in real time, so a lot of sloppy code looks fine there. A render works differently. The docs explain that the renderer opens several tabs to speed things up, that the tabs share no state, and that any animation running independently of useCurrentFrame() therefore breaks (flickering page). The same page gives four tests for a component: it shows the same picture whenever it is called with the same frame, it does not depend on frames arriving in order, it does not animate while the video is paused, and it does not rely on randomness except through random(). The sections below are those tests applied to real code.

Mistake one: motion on a clock of its own

A CSS transition that fires when a state flag flips, a CSS keyframe animation, a setTimeout, a setInterval, a requestAnimationFrame loop, and a counter in state that a timer advances all share one trait. Their value depends on how much real time has passed in one particular tab. In the Studio that is close to what you want. In a render, one tab may draw a late frame first and another tab an early one, and neither knows what the other did.

Here is the shape that goes wrong, and the repaired one:

// Reads fine in the preview, drifts in a render
const Title = ({ on }) => (
  <h1 style={{ opacity: on ? 1 : 0, transition: "opacity 1s" }}>Hello</h1>
);

// A pure function of the frame
import { useCurrentFrame, interpolate } from "remotion";
const Title = () => {
  const frame = useCurrentFrame();
  const opacity = interpolate(frame, [0, 24], [0, 1]);
  return <h1 style={{ opacity }}>Hello</h1>;
};

The second version gives the same opacity for a given frame whichever tab asks, and in whatever order. Adding a clamp option to interpolate would only freeze the value after its last input; that is a separate question from render stability, so a clamped call is not a defect.

There is a shortcut. The docs mention that if frames are safe to draw in order, the --concurrency=1 flag removes the multi-tab problem and fixes many cases, but the render becomes much slower, correct animation timing is still not guaranteed, and it blocks rendering through Remotion Lambda (flickering page). Treat it as a way to buy time, not as the fix.

Mistake two: Math.random in a component

Math.random() returns something new on every call, and every tab calls it for itself. The Remotion docs say this directly: a render uses several threads and opens the page several times, so the values from Math.random() differ across threads and frames disagree (random). The replacement is random(seed), which accepts a number or a string and gives back one fixed value per seed. A particle field whose positions come from seeded calls is identical in every tab and on every run.

The docs add that Math.random() inside a Remotion project produces an ESLint warning, because it often causes rendering bugs, and that random(null) is the explicit way to ask for a truly random number (random). A call to random(null) in a component is therefore a decision somebody made on purpose, and worth a second look in a review. Math.random() in a script that runs outside any component, such as one that picks an output file name, is not a problem for the video.

The same reasoning covers other code that runs once per tab at module level, such as a Date.now() stored in a constant. Every tab evaluates its own copy at load, so each draws from a different starting point. That is a consequence of the tabs sharing no state, not a sentence from the docs.

Mistake three: assets the renderer does not wait for

The docs say the renderer needs a signal while an asset is still arriving, otherwise it captures the loading state, and they name the components that give that signal: <Img>, <Video>, <Audio>, <OffthreadVideo>, <Gif> and <IFrame> (flickering page). The <Img> page says the component holds the frame back until the picture has loaded, and that remote addresses work as with an ordinary <img> tag (Img). A bare <img> tag gives the renderer no such signal, so a frame can be captured with an empty slot where the logo should be. The same page lists background-image and mask-image as properties to avoid, for the same reason.

Files in the public/ folder have one more rule. They are turned into a URL with staticFile(), and the docs give reasons for not passing a string like "/logo.png": the URL survives a deployment under a sub-path of a domain, and the same code runs across frameworks (staticFile).

Mistake four: data fetched with no render delay

A fetch inside useEffect that stores its result in state is the usual way to load data in React. In a render, the frame can be drawn while the state is still empty. The fix is delayRender() before the request and continueRender() when the data is in. After delayRender() is called, the render waits for continueRender(), and if that does not come within the timeout (30 seconds by default, documented on the page) the render fails with an error (delayRender). The page also says to place delayRender() calls inside components and not as top-level statements, because a top-level call can block a render of a different composition (delayRender). That is the module-level trap again, this time with a documented consequence.

Data that does not change per frame can also be loaded once before rendering starts with calculateMetadata(), which is listed among the Remotion APIs, though this guide did not test it.

Mistake five: fonts used before they are ready

A text frame drawn before its font has loaded shows the fallback font, and the width of the text changes, which is how a layout shifts between renders. The fonts page describes the supported routes: @remotion/google-fonts with loadFont(), a Google Fonts CSS import (Remotion waits for it automatically in recent versions), @remotion/fonts for a file in public/ (loaded through staticFile()), and the browser FontFace API, in which case you wrap the load in your own delayRender() and continueRender() pair (fonts). Any measurement of text should wait until the font is ready, a point the flickering page also makes.

A check that can fail: render twice and compare

Reading code finds these mistakes only if you know them. A render finds them too, if you compare. The still command renders one frame to an image file, and --frame picks which frame (still).

npx remotion still MyComp out/a.png --frame=30
npx remotion still MyComp out/b.png --frame=30
cmp out/a.png out/b.png && echo identical

Run it for a few frames spread across the video, including the very first and the very last, and on the final machine and settings. If the two files differ, something in that frame reads the wall clock, uses chance or needs an asset that arrived late.

The check only means something if it is able to fail. Put Math.random() into one component on purpose, or a setTimeout that changes a value, run the same commands, and confirm that cmp reports a difference. If it still says identical, the frame you picked does not show the changing part, so choose another one. A pair of identical images from a composition that never had a chance to differ proves nothing.

Two caveats. Identical stills show that those frames are stable on that machine; they say nothing about a missing image if both runs lose it the same way, so look at the picture once with your own eyes. And a video file is encoded, so compare the stills, not the bytes of two encoded files.

When this does not apply

Only compositions rendered with the Remotion command line or server rendering are in view, as the documentation pages cited above describe them on the day they were read. APIs change between versions (the docs mention features by version number), so confirm against the version you use. We did not run a render for this guide; the example code illustrates documented rules and is not a measurement. Other causes of a different render exist, including encoder settings, codecs, a different browser build on another machine, and heavy video components, and none of them is covered here. A render that is wrong in a way these checks cannot explain deserves its own investigation, starting with a lower concurrency to see whether the problem moves. Nondeterminism is a general cause of unreliable tests too, and our write-up on flaky tests covers the same family of problems in ordinary code.

A reviewer for the code

Our remotion-code-review skill takes a Remotion composition you paste in and lists the defects covered above, one line per finding, naming a short code, where it sits, why it hurts and what to write instead. The categories are time-driven motion, unseeded randomness, unwaited media tags, data loaded with no render hold, and public/ assets given as bare paths. Look-alikes that are safe stay off the list, and an empty list is a fixed sentence. The skill never runs a render, so the two-stills check remains a job for your pipeline.

Read this post as Markdown: /blog/remotion-render-differs-from-preview.md · Atom feed.