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.

Share

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

RouteLimitFormat rules
Media message in the app16 MBPlays inline in the chat
Document in the app2 GBAny file. Arrives as a download, not an inline player
Cloud API media message16 MBMP4 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:

Terminal window
# 15,000,000 bytes * 8 / 30.02 s = 3997 kbit/s, minus 128 kbit/s of audio
dur=$(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/null
ffmpeg -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.mp4

Each 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:

Terminal window
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=0

Keeping 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:

Terminal window
ffprobe -v error -select_streams v:0 -show_entries stream_side_data=rotation -of csv=p=0 phone.mp4

When 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:

job.ts
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.

terminal
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.

Two crops of the same frame of the vertical Sintel clip at native resolution, showing strands of red hair against a snowy background. Left is the source, right is the output under 16 MB. Hair strands and the snow texture look the same in both.
The same 360x360 crop, 10 seconds in, at full resolution. Left: the vertical source. Right: the output of job_bca805d8824b4f6f, under WhatsApp's 16 MB limit.

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

Tags #whatsapp#compression#ffmpeg#h264#vertical-video
All posts
Share
  1. Animated captions API, TikTok styles in one call Guides for the video API
  2. Compress video for Discord under 20 MB, every time Guides for the video API
  3. FFmpeg vs video API, one edit rendered three ways Guides for the video API