# Compress video for WhatsApp under 16 MB

Canonical: https://rendobar.com/blog/compress-video-for-whatsapp/
Author: Abdelrahman Essawy
Published: 2026-09-27
Updated: 2026-09-27

---

## Key takeaways

- WhatsApp caps every photo, video and voice message at 16 MB, and the Business Cloud API applies the same ceiling to MP4 with only H.264 video and AAC audio allowed.
- For Business API sends, encode H.264 Main without B-frames and put the index at the front of the file. That is Meta's own recommendation for the widest playback.
- Keep a portrait video portrait. Scale by width, not by height, and let FFmpeg apply the phone's rotation flag once instead of adding a transpose on top.
- A size-capped job fits the 16 MB limit on its own, but it does not choose the H.264 profile. Check the profile with ffprobe before you hand the file to the Cloud API.

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](https://durian.blender.org/) (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:

```bash
# 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](/blog/yuv420p-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:

```bash
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:

```bash
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](/blog/compress-video-for-discord/) overshot
it. A preset that fits one video is luck, and the next phone clip may not fit.
[The target-size guide](/blog/compress-video-target-size/) 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:

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);`}
  curl={`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"
    }
  }'`}
/>

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 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](/ffmpeg/).

In an automation, such as an [n8n video processing
workflow](/blog/n8n-video-processing-node/) 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.
