FFmpeg Audio Out of Sync Isn't the Codec. It's the Frame Rate

The clip plays fine in QuickTime. You run it through FFmpeg, and by the two-minute mark the audio is a quarter second ahead of the picture. Nudging -itsoffset fixes the first ten seconds and breaks the rest, which is the tell that you're chasing the wrong variable.
Quick answer: FFmpeg audio out of sync after a transcode is almost always a variable-frame-rate source (iPhone, OBS, QuickTime screen recording, iMovie export) meeting a fixed-ron the output. Detect it by comparingr_frame_rateandavg_frame_rateinffprobe; if they differ by more than about 1%, the source is VFR. Re-encode with-fps_mode cfr -r 30 -af aresample=async=1000:first_pts=0rather than-ralone, and reserve-itsoffsetfor a fixed offset that never grows.
The drift isn't an encoder bug, it's a timestamp mismatch
Conventional wisdom says drift means the audio encode went wrong, so you raise the bitrate, force -ar 48000, or re-mux to MKV and hope. Those changes sometimes shift the symptom, which is why the advice keeps circulating on VideoHelp and the ffmpeg-user list. But the cause isn't the audio codec. Your source video has no fixed frame interval, and your command told FFmpeg to pretend it does.
A variable-frame-rate file stores a real timestamp per frame. A screen recorder emits a frame when the screen changes, so a static slide might hold for 400 ms and a scroll burst might run at 60 fps. An iPhone drops its capture rate in dim light. iMovie exports carry the same irregularity forward, which the ffmpeg-user thread on iMovie A/V sync problems describes.
Audio has no such freedom. It's a continuous stream of samples at a fixed rate, so it always plays back at real time. When the video track's reconstructed duration stops matching wall clock, the two slide apart, and the gap grows linearly. That's the signature: a constant offset is a mux problem, a growing offset is a frame-rate problem.
The -r flag makes this worse in two specific ways. Placed before -i, it's an input option that discards the source timestamps and reassigns them at a fixed cadence, so a clip that averaged 23.48 fps gets replayed as if it were 30 fps and runs short by more than 20%. Placed after, -r does drop and duplicate frames to reach a constant grid, but silently, and when the source has multi-second capture gaps the reconstruction rarely lands where the audio does.
Detect VFR before you transcode, not after
Two ffprobe fields tell you whether a file is variable frame rate, and the check takes under a second because it only reads the header.
ffprobe -v error -select_streams v:0 \
-show_entries stream=r_frame_rate,avg_frame_rate,duration \
-of default=noprint_wrappers=1 input.mp4
A QuickTime screen recording comes back like this:
r_frame_rate=600/1
avg_frame_rate=1409/60
duration=63.216667
r_frame_rate is FFmpeg's guess at the smallest constant rate that every timestamp in the file fits into. avg_frame_rate is total frames divided by duration. On a genuine constant-frame-rate file both read 30000/1001 and you move on. Here the first says 600 and the second says 23.48, so the container is holding fractional-frame timings that no fixed grid describes.
Now probe the audio stream on the same file:
ffprobe -v error -select_streams a:0 \
-show_entries stream=duration -of csv=p=0 input.mp4
If that returns 63.104000 against the video's 63.216667, you already have 112 ms of mismatch before FFmpeg touches anything.
For a definitive answer, run the vfrdet filter, which measures actual frame-duration variance instead of inferring it:
ffmpeg -i input.mp4 -vf vfrdet -an -f null -
The last line of stderr reports something like VFR:0.755100 (1120/363). A value of 0.000000 means every frame interval was identical. Anything above zero is variable, and the two counts are variable-interval frames against constant ones. This is the check worth running at ingest, alongside the header validation you'd do for moov atom problems on uploaded video.
The fix chain: normalize the video grid, then repair the audio
Three flags do the work. Force a constant video grid against the real timestamps, resample the audio to fill or trim gaps, and only then consider a manual offset.
Force CFR with -fps_mode, not with -r alone
-fps_mode cfr tells FFmpeg to build a constant-rate output by duplicating or dropping frames relative to the source's actual presentation timestamps, so the output's wall-clock duration matches the input's. FFmpeg 5.1 (August 2022) added -fps_mode as the per-stream replacement for the global -vsync. On older builds, -vsync cfr does the same thing.
ffmpeg -i input.mp4 \
-fps_mode cfr -r 30 \
-c:v libx264 -crf 20 -preset veryfast -pix_fmt yuv420p \
-c:a aac -b:a 128k -ar 48000 \
-af aresample=async=1000:first_pts=0 \
-movflags +faststart output.mp4
Keep -r 30 after -i. It sets the target grid; -fps_mode cfr sets the policy for hitting it. Without -r, CFR mode falls back to the source's guessed r_frame_rate, which on that screen recording would be 600 fps and would produce a file roughly 25 times larger than needed.
Repair gapped audio with aresample=async
aresample=async=1000 lets FFmpeg stretch or compress the audio by up to 1000 samples per second to track the video timeline, and first_pts=0 pads the head with silence when the audio starts late instead of yanking it forward. Screen recorders and browser capture tools drop audio packets when the source app stalls. Without async resampling FFmpeg writes the surviving samples back to back, so the audio finishes early by exactly the length of the dropped gaps.
The old -async 1 option maps to this filter internally and is deprecated. Write the filter directly so the behavior is visible.
Use -itsoffset only when the offset never grows
-itsoffset shifts an entire input's timestamps by a fixed amount, the right tool for a constant lag such as a camera whose microphone runs through a separate interface. It's the wrong tool for VFR drift, because one constant shift can't correct an error that accumulates.
ffmpeg -i in.mp4 -itsoffset 0.35 -i in.mp4 \
-map 0:v -map 1:a -c copy -movflags +faststart out.mp4
That opens the same file twice, delays the second copy by 350 ms, and takes video from the first and audio from the second, no re-encode. Measure the offset at 10 seconds and again at the end of the clip first. Same number both times means a fixed offset, so -itsoffset is right. A number that has doubled sends you back to -fps_mode. The same measure-before-you-shift logic applies to subtitle delay with -itsoffset.
Concatenating VFR clips multiplies the error
Drift that's tolerable in one clip becomes obvious in a stitched video, because every join adds its own error and nothing resets it. Each VFR clip carries a small mismatch between its video and audio stream durations, often 20 to 60 ms. Join eight of them with the concat demuxer and -c copy, and the audio in the final clip can land 400 ms off, well past the roughly 100 ms threshold where viewers notice lip sync problems.
The fix is not a better concat command. Normalize every clip to the same frame rate, sample rate, and timebase first, then concatenate them with -c copy. That's the same normalize-then-copy discipline covered in FFmpeg concat with different resolutions, extended to the time axis instead of the pixel grid.
Common pitfalls
Five command mistakes cause most sync complaints, in rough order of how often they show up:
- Putting
-r 30before-i. This reinterprets the source instead of conforming it, and it's the fastest way to produce growing drift. - Combining
-c:v copywith-fps_mode cfr. Stream copy passes packets through untouched, so the fps mode is ignored with no warning and the VFR timestamps survive into your output. - Trusting the container
durationfield. Probe the video and audio stream durations separately; the container reports the longer of the two and hides the mismatch. - Adding
-shortestbefore diagnosing. It truncates the output to the shorter stream and makes a rate problem look like a trim problem. - Reaching for
-itsoffsetfirst. If the correction changes depending on where you measure, the offset isn't fixed and no single value will hold.
Manual chain versus one API call
The command above works, and if you control the source files you should just run it. It gets harder with other people's uploads, where every clip arrives from a different capture app with its own frame-rate mode and audio gaps, and a sync bug shows up as a support ticket three days later.
| FFmpeg on your own box | FFmpeg Micro | |
|---|---|---|
| VFR detection | Run `ffprobe`/`vfrdet` per upload, branch on the result | Normalized to CFR on ingest |
| Encoder | Install, version-pin, and watch for `-vsync` deprecations | Managed, no binaries to install |
| Long jobs | Hold a connection or build a queue yourself | Submit a job, poll or take a webhook, download |
| Concat chains | Normalize each clip yourself before joining | Downstream steps inherit a constant grid |
| Where it runs | Your server | Code, n8n, Make, Zapier, or an AI agent over MCP |
FFmpeg Micro normalizes uploads to constant frame rate on ingest, so a caption burn, concat, or resize later in the chain starts from a file whose video and audio timelines already agree. The API docs show the job submit and webhook shape, and the free tier covers a few real problem clips before you decide.
Not every case belongs on an API. If you're fixing one clip on your laptop, the -fps_mode cfr command is thirty seconds of work. If your sync problem is a hardware capture chain with a genuine fixed audio delay, fix it at the source instead of correcting it on every render. And if what you need is a timeline you can scrub and nudge by hand, that's a job for an editor, not a media API.
FAQ
Why does my iPhone video play fine in QuickTime but go out of sync after FFmpeg?
QuickTime honors the per-frame timestamps in a variable-frame-rate file and paces playback to them, so the drift never appears. FFmpeg preserves that pacing only if you tell it to. A fixed -r on the output rebuilds the video on a constant grid, and any error in that reconstruction shows up as audio drift.
Does -vsync cfr still work in current FFmpeg builds?
-vsync cfr still works but is deprecated as of FFmpeg 5.1 (August 2022), which introduced -fps_mode as the per-stream replacement. Write -fps_mode cfr on anything modern, and keep -vsync cfr only for pinned older builds.
How do I tell a fixed audio offset from real drift?
Measure the audio-to-video gap twice in the same file, near the start and near the end. A fixed offset produces the same number at both points and is correctable with -itsoffset; drift produces a larger number at the end and needs -fps_mode cfr plus audio resampling.
Can I fix audio sync without re-encoding the video?
Re-muxing with -itsoffset and -c copy fixes a constant offset without re-encoding, and it takes seconds even on a long file. Variable-frame-rate drift can't be fixed by a copy, because correcting it means rebuilding the video timestamps, which needs a real encode.
What does aresample=async=1000 actually do to my audio?
The aresample filter with async=1000 lets FFmpeg add or remove up to 1000 samples per second to keep the audio timeline aligned with the video timeline, filling capture gaps with silence rather than sliding the remaining samples earlier. At 48 kHz that's a maximum correction of about 2%, inaudible on speech.
If you'd rather not run this check on every upload, point the file at FFmpeg Micro and let ingest normalize the frame rate before your captions, concat, or resize step ever sees it. Sign up free and run one of your problem clips through it.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Fix FFmpeg's "height not divisible by 2" Error on User Uploads
FFmpeg's "height not divisible by 2" error is a yuv420p chroma constraint, not a broken file. Three fixes (scale=-2, trunc, pad) with measured output sizes.

How to Convert GIF to MP4 with FFmpeg (and Cut Page Weight 90%)
Convert GIF to MP4 with FFmpeg in one command. The three flags that make it play everywhere, the autoplay markup that replaces the GIF, and how to batch it.

Fix "moov atom not found" on Uploaded Videos in Your Pipeline
"moov atom not found" almost never means a corrupt video. It means a truncated upload or a Range request that missed the trailing moov box. Check and fix it.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free