Compress video for WhatsApp under 16 MB
Get a vertical phone video under WhatsApp's 16 MB media limit as H.264 and AAC, keep it 9:16, and meet the Business API's stricter codec rules.
WhatsApp’s help center caps every photo, video and voice message at 16 MB, and anything larger gets a prompt to trim it before it sends. To fit a phone video under that, encode it as H.264 with AAC audio at a bitrate worked out from the duration, keep it at its 9:16 shape, and move the file index to the front. If the file goes out through the WhatsApp Business Cloud API, Meta also asks for H.264 Main without B-frames or Baseline, which is the one setting a quick compress usually gets wrong.
The limits that apply
| Route | Limit | Format rules |
|---|---|---|
| Media message in the app | 16 MB | Plays inline in the chat |
| Document in the app | 2 GB | Any file. Arrives as a download, not an inline player |
| Cloud API media message | 16 MB | MP4 or 3GPP, H.264 video and AAC audio only, one audio stream or none |
MP4 with H.264 and AAC is the one combination all three routes accept, so it is the one to produce. I read 16 MB as 16,000,000 bytes, the smaller reading, and aim for 15,000,000 to leave room for drift.
The test clip
Phone video is tall, so the test clip is tall. I took 30 seconds of Sintel (Blender Foundation, CC BY 3.0) from the snow sequence at 0:40, cropped a 9:16 window out of the widescreen frame, and scaled it to 1080x1920, the size a phone records portrait video at. At CRF 17 that came to 34,956,587 bytes of H.264 High with 192 kbit/s AAC. The crop is upscaled, so the picture is softer than a real phone recording, and softer video compresses more easily than handheld footage with noise in it.
A command that fits and plays
Two-pass at a computed bitrate, with the profile pinned for the Cloud API:
# 15,000,000 bytes * 8 / 30.02 s = 3997 kbit/s, minus 128 kbit/s of audiodur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 phone.mp4)vk=$(awk -v d="$dur" 'BEGIN { printf "%d", 15000000 * 8 / 1000 / d - 128 }')
# On Windows use NUL instead of /dev/null.ffmpeg -y -i phone.mp4 -c:v libx264 -preset medium -profile:v main -bf 0 \ -pix_fmt yuv420p -b:v ${vk}k -pass 1 -an -f null /dev/nullffmpeg -i phone.mp4 -c:v libx264 -preset medium -profile:v main -bf 0 \ -pix_fmt yuv420p -b:v ${vk}k -pass 2 \ -c:a aac -b:a 128k -ac 2 -movflags +faststart whatsapp.mp4Each flag answers one of the Cloud API’s rules. -profile:v main -bf 0 gives Main with no B-frames. -pix_fmt yuv420p converts a 4:4:4 or 10-bit source first, because Main only takes 8-bit 4:2:0 and x264 stops with an error otherwise. The chroma subsampling cost shows what that conversion does to the bytes. -ac 2 folds a multichannel track into one stereo stream. -movflags +faststart puts the moov index before the media data, which Meta’s media page asks for.
On the vertical Sintel clip this produced 15,160,034 bytes of H.264 Main with no B-frames, and ffprobe confirms it:
ffprobe -v error -select_streams v:0 \ -show_entries stream=profile,has_b_frames,width,height -of compact whatsapp.mp4# stream|profile=Main|width=1080|height=1920|has_b_frames=0Keeping 9:16 intact
Two habits from landscape video break portrait files.
The first is scaling by height. scale=-2:720 means “720 lines tall”, which turns a 1080x1920 phone video into 406x720, far smaller than intended. For a tall video, scale by width: scale=720:-2 gives 720x1280. If the budget forces a smaller frame, that is the line to change.
The second is rotation. Many phones store portrait video as landscape pixels with a rotation flag in the display matrix. Check for one:
ffprobe -v error -select_streams v:0 -show_entries stream_side_data=rotation -of csv=p=0 phone.mp4When FFmpeg re-encodes, it applies that flag by default (-autorotate is on) and writes real portrait pixels. Adding a transpose filter on top rotates the video a second time and it arrives sideways. Leave the rotation to FFmpeg.
CRF lands wherever the content puts it
Every clip spends bits differently, and a fixed CRF follows the content, not the limit. At CRF 23 this soft vertical clip came in at 15,867,122 bytes, with about 130 KB to spare under 16,000,000. The same CRF on the widescreen clip in the guide to Discord’s upload limit overshot it. A preset that fits one video is luck, and the next phone clip may not fit. The target-size guide covers the general case, including searching CRF against a size.
One call with a 16 MB ceiling
When the videos come from users or a workflow, the size check and the retry are the parts people forget. Rendobar’s compress.target takes the limit as its target, encodes to it, scores the result against the source with VMAF, and reports infeasible_size instead of returning a file over the cap. This is the request I ran on the vertical 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-vertical-30s.mp4" },params: { target: { maxBytes: 16000000 }, // WhatsApp's media limit, read as 16,000,000 bytes codec: "h264", compatibility: "legacy", for: "web",},});
// output.data is typed unknown in the SDK. The job reports its own result.const d = job.output.data as { status: string; outputBytes: number; achievedScore: number };console.log(d.status, 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-vertical-30s.mp4" }, "params": { "target": { "maxBytes": 16000000 }, "codec": "h264", "compatibility": "legacy", "for": "web" }}'Returns immediately with a job id. Poll GET /jobs/{id} or register a webhook rather than blocking on the request.
Job job_bca805d8824b4f6f returned 15,603,244 bytes with status ok, still 1080x1920, with stereo AAC and the index at the front. It scored VMAF 97.5 against the source, took 71 seconds and cost $0.04.

The profile is where the job and the Cloud API part ways. ffprobe on the output shows H.264 High with B-frames, even with compatibility: "legacy", and the job has no profile setting. So for files a person sends from the app, the job’s output fits the limit. For files going out through the Cloud API, where Meta recommends Main without B-frames or Baseline, run the two-pass command above, either locally or unchanged as an ffmpeg job on Rendobar’s FFmpeg API.
In an automation, such as an n8n video processing workflow that posts to a WhatsApp number, I would run that pinned Main-profile command every time. It is the only version of this encode that meets all three routes in the table at once, and the size check afterwards is one stat call.
Frequently asked questions
How long a video can I send on WhatsApp?
There is no fixed duration. The ceiling is 16 MB, which WhatsApp's help center says comes to about 90 seconds to 3 minutes of video on most phones. A longer clip needs a lower bitrate and usually a lower resolution, or it goes as a document, where the limit is 2 GB but the recipient gets a file to download instead of a video that plays in the chat.
Sources
- WhatsApp Help Center: video too long, the 16 MB media limit
- WhatsApp Blog: file sharing up to 2 GB (May 2022)
- Meta for Developers: WhatsApp Cloud API supported media
- FFmpeg documentation: -autorotate and display matrix
- FFmpeg wiki: H.264 encoding guide, profiles and two-pass
- Sintel, Blender Foundation (CC BY 3.0), source of the test clip
