Compress video for Discord under 20 MB, every time
Get a video under Discord's 20 MB free upload limit with the two-pass bitrate formula, see why one CRF guess misses, and cap the size in one API call.
To get a video under Discord’s free upload limit, which Discord’s help center puts at 20 MB since August 2026 (up from 10 MB), run a two-pass encode aimed at about 19 MB. The video bitrate is the byte budget in kilobits, divided by the duration, minus the audio bitrate. A CRF value cannot promise a size, so on a real clip it either overshoots the limit or wastes a chunk of the budget.
Discord’s page also lists 50 MB for Nitro Basic and up to 500 MB for Nitro, and notes it is experimenting with larger limits for some users. Everything below uses the free tier’s number. I count 20 MB as 20,000,000 bytes, the smaller of the two readings, so a file under it passes whichever one Discord uses.
The test clip
I cut 45 seconds of Sintel (Blender Foundation, CC BY 3.0) starting at 4:40, the rooftop and city sequence, and re-encoded it at CRF 17 to stand in for a screen or phone recording nobody has compressed yet: 1920x818 H.264 High at 24 fps with 192 kbit/s stereo AAC, 47,738,725 bytes. That is more than twice the limit, so every method below has real work to do. The local encodes ran on FFmpeg 8.0 with x264 core 165.
The two-pass formula
Leave about 5% headroom and aim for 19,000,000 bytes. The arithmetic is the whole trick:
# 19,000,000 bytes * 8 = 152,000 kbit, over 45.0 s = 3377 kbit/s in total# minus 128 kbit/s of audio = 3249 kbit/s for videodur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 input.mp4)vk=$(awk -v d="$dur" 'BEGIN { printf "%d", 19000000 * 8 / 1000 / d - 128 }')
# Same -preset in both passes. On Windows use NUL instead of /dev/null.ffmpeg -y -i input.mp4 -c:v libx264 -preset medium -b:v ${vk}k -pass 1 -an -f null /dev/nullffmpeg -i input.mp4 -c:v libx264 -preset medium -b:v ${vk}k -pass 2 \ -c:a aac -b:a 128k -movflags +faststart discord.mp4The first pass reads the whole clip to learn where the hard scenes are. The second spends the budget there. -movflags +faststart moves the index to the front of the file so the embed can start playing before the download finishes.
On the Sintel clip the result was 19,040,572 bytes. That is about 40 KB over what I aimed for and still under the limit, which is exactly why the headroom exists. Aim at 20,000,000 and a drift that size puts the file over.
Why a single CRF guess misses
CRF holds quality steady and lets the size float, so the size you get depends on the content. The same clip at three CRF values:
| Encode (x264 medium, 128 kbit/s AAC) | Bytes | Under 20,000,000? |
|---|---|---|
| CRF 23 | 22,554,025 | No |
| CRF 26 | 16,108,501 | Yes, with 3.9 MB unused |
| CRF 28 | 13,028,201 | Yes, with 7.0 MB unused |
| Two-pass at 3249 kbit/s | 19,040,572 | Yes |
CRF 23, the x264 default, produced a file Discord would refuse. CRF 26 fits but leaves a fifth of the budget on the table, and the next clip you feed it will land somewhere else again. Finding the right CRF for a given clip means encoding it several times, which the target-size guide scripts as a bisection. For a hard ceiling, bitrate is the right tool, and CRF against bitrate measures that trade in more detail.
Where the DIY route breaks
Two-pass is a good answer for one file on your own machine. It gets awkward when the videos arrive from users or a bot:
- Duration has to be probed first, and a broken duration in the container gives you a nonsense bitrate.
- Two-pass lands near the target, not under it. Somebody has to check the output size and re-run with a lower bitrate when it lands over.
- A clip that is already under 20 MB should not be re-encoded at all, since that costs time and a generation of quality for nothing.
- Long clips need a lower resolution, not only a lower bitrate, and the formula does not tell you when you have crossed that line.
One call with a byte ceiling
Rendobar’s compress.target job takes the limit itself as the target. With { maxBytes } it encodes to that budget, runs one corrective pass if the first encode lands over, and scores the result against the source with VMAF. If the file still cannot fit, status comes back infeasible_size so your code sees the miss. A video already under the cap is returned untouched. This is the request I ran on the Sintel clip:
import { createClient } from "@rendobar/sdk";
const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
const job = await rb.jobs.run({type: "compress.target",inputs: { source: "https://cdn.rendobar.com/assets/examples/sintel-discord-45s.mp4" },// Discord's free limit, read as the smaller 20,000,000 bytes.params: { target: { maxBytes: 20000000 }, for: "web" },});
// output.data is typed unknown in the SDK. The job reports its own result.const d = job.output.data as { status: string; codec: string; achievedScore: number; outputBytes: number };
// "ok" means the file is under the cap. "infeasible_size" means it could not fit.console.log(d.status, d.codec, d.outputBytes, d.achievedScore);Install with npm i @rendobar/sdk. jobs.run() submits and waits, so it returns the finished job in one call.
curl -X POST https://api.rendobar.com/jobs \-H "Authorization: Bearer $RENDOBAR_API_KEY" \-H "Content-Type: application/json" \-d '{ "type": "compress.target", "inputs": { "source": "https://cdn.rendobar.com/assets/examples/sintel-discord-45s.mp4" }, "params": { "target": { "maxBytes": 20000000 }, "for": "web" }}'Returns immediately with a job id. Poll GET /jobs/{id} or register a webhook rather than blocking on the request.
Job job_a2a34750ada64082 returned H.264 at 19,399,054 bytes with status ok, 2.46 times smaller than the source, and scored VMAF 97.2 against it. It picked H.264 by itself, which Discord’s page lists alongside HEVC and AV1 as a video codec it accepts, and kept the index at the front of the file. The job took 74 seconds and cost $0.05.

Two-pass on my laptop cost nothing and landed closer to the byte target, so for a one-off clip you are sending to a friend, the command above is enough. The job earns its keep when the upload happens in code: a bot that clips highlights, a pipeline posting renders to a channel, anything where nobody is around to check the size and try again. The same job sits beside Rendobar’s FFmpeg API when you would rather send your own two-pass command. And re-encoding is not always the win it looks like, which the compressor that refuses shows on a file that was already efficient.
WhatsApp has a lower ceiling and different rules about what plays, covered in compressing video for WhatsApp. For Discord, I would budget from 19 MB, run two-pass by hand for a single file, and hand the ceiling to compress.target the moment the uploads are automated.
Frequently asked questions
How do I fit a longer video under Discord's 20 MB limit?
Lower the resolution along with the bitrate. The budget is fixed, so a 5 minute clip at 19 MB leaves under 400 kbit/s for video after 128 kbit/s of audio, spread across every 1080p pixel. Scale to 720p or 480p with -vf scale=-2:720, or pass maxResolution to compress.target, so the same budget covers fewer pixels.
