Back to blog
August 13, 2026Sergei Solod12 min read

How I Cut a Production Video from ~280 MB to ~50 MB with H.264

One concrete production video fell from roughly 280 MB to roughly 50 MB after I rebuilt the H.264 policy around CRF 28, x264 veryslow, a 720p-class ceiling, useful frame rates, and a conservative decoder contract. An earlier stage of the same optimization had already reduced the file from about 350 MB to 238 MB, while a wider audit showed that multi-megabit H.264 files were common in the old library.

H.264FFmpegx264Video CompressionWeb PerformanceMedia Optimization

The number that finally made this optimization concrete was simple: one production video I tracked went from roughly 280 MB to 50 MB after the new H.264 policy. That is about 5.6× smaller, saving roughly 230 MB or 82% of the original file size.

I did not get that result by moving delivery to AV1, HEVC or VP9. The production output stayed H.264 in MP4. What changed was the policy around the codec: fewer unnecessary pixels, fewer unnecessary temporal samples, a much less conservative quality target, more encoder work up front, and a deliberately bounded decoder contract.

This was a very specific workload: short illustrated and animated material, roughly 80% mobile traffic, bandwidth as a recurring cost, and offline encoding where time is cheap compared with repeatedly serving oversized files.

The old and new policies were roughly:

older policy
H.264 Main @ Level 4.0
CRF 19
preset slow
up to 1920x1080 / 1080x1920
30 fps
refs = 3
B-frames = 3
GOP ≈ 2 seconds
VBV ≈ 10M / 20M

new policy
H.264 Main @ Level 3.1
CRF 28
preset veryslow
720p-class ceiling, no upscale
useful CFR, normally <= 30 fps
refs = 4
B-frames = 5
GOP ≈ 5 seconds
VBV ≈ 4M / 8M
yuv420p / avc1 / faststart

The measured result: ~280 MB became ~50 MB

I have several production measurements, but they are not all the same experiment. Keeping those categories separate matters more than finding the biggest-looking percentage.

MeasurementBeforeAfterReduction
Concrete same-video example~280 MB~50 MB~5.6× smaller / ~82% less
Earlier stage of the same file~350 MB~238 MB~1.47× smaller / 32% less

The first row is the cleanest evidence for the headline. It is one concrete video before and after the newer policy: roughly 280 MB became roughly 50 MB. I do not have a preserved bitrate/duration line for that exact pair in the recovered logs, so I do not invent one. The size change itself is enough: this particular file became about 5.6 times smaller.

The ~350→238 MB case was an earlier optimization of the same file at that stage of the pipeline. The resulting video was about 1264×720, 30 fps, roughly 500 seconds, no audio and around 3.8 Mbps. The arithmetic matches the observed size: 3.8 Mbps for about 500 seconds is roughly 238 MB. That was already a 32% saving from ~350 MB, but it was still far too expensive for my bandwidth target.

The old library also showed that oversized H.264 was not one weird outlier. In one audit I had 238 production videos totaling 6.37 GB: 117 H.264 and 121 AV1. Of those, 101 were at least 20 MB and 34 were at least 50 MB. Some of the larger H.264 files looked like this:

SizeDurationAverage bitrate
121.0 MB4:253.83 Mbps
101.2 MB5:192.659 Mbps
92.78 MB4:442.735 Mbps
90.10 MB3:553.206 Mbps
89.74 MB4:412.676 Mbps

Those files are different content, so the table is context rather than an A/B test. What it does show is that the older 3.8 Mbps example was not an isolated accident: roughly 2.6–3.8 Mbps genuinely occurred across several large H.264 files in the old library.

The important optimization was not an FFmpeg flag

The biggest change was how I thought about cost.

Encoding happens once. Delivery happens every time somebody requests the file.

For real-time video, spending much more CPU to save a little bitrate can be a bad trade. My files are encoded offline and then served repeatedly. In that model, saving ten minutes during encoding can be economically meaningless if the faster encode makes every future request larger.

That is why -preset veryslow makes sense for me. I am willing to spend CPU once if x264 can use it to find a more efficient representation. The browser never repeats the encoder's search; it only decodes the finished bitstream.

The rule became simple: spend computation on the one-time step, and be stingy with bytes on the recurring step.

Why I stayed with H.264 instead of chasing the newest codec

I am not claiming H.264 is the most compression-efficient codec available. It is not. Newer codecs can be attractive when a delivery system can keep several renditions and choose the best one for each client.

My constraint was different: one URL, one file, one codec, and as little playback drama as practical across a mobile-heavy audience.

For that job, H.264 in MP4 is still a very safe baseline. Apple currently tells web developers to use H.264-encoded MP4 files for static video in Safari. Android's current documentation lists H.264 in MP4 and requires a Main Profile decoder on Android 6.0 and later; its H.264 playback recommendations also list 1280×720 at 30 fps for HD, while noting that HD is not available on every device. See Android's supported media formats.

That does not mean modern devices are limited to Main Profile or Level 3.1. Apple's HLS guidance, for example, generally prefers High Profile over Main or Baseline. I chose Main@3.1 because I wanted a deliberately modest decoder envelope for a single static MP4, not because Apple requires it.

I stopped encoding pixels that did not need to exist

Resolution was one of the biggest levers. My ceiling became roughly 1280×720 for landscape, 720×1280 for portrait, and about 960×960 for square or mixed-orientation material.

The more important rule is: never upscale just to reach the ceiling.

If a source is 900×600, turning it into 1280×720 does not recover detail. It only creates more samples for the encoder to describe. A 1920×1080 source may be reduced to the 720p class, while a 900×600 source can stay around 900×600. The ceiling is a maximum, not a target.

This sounds simple, but removing unnecessary pixels can matter more than many obscure encoder tweaks.

That ceiling was a deliberate compromise rather than an arbitrary round number. A 1920×1080 frame contains 2,073,600 pixels; 1280×720 contains 921,600. Moving from 1080p to 720p therefore removes about 55.6% of the spatial samples before the encoder even starts making compression decisions.

I considered making 540p the universal default as well. But 960×540 contains only 518,400 pixels: 43.75% fewer than 1280×720, leaving 56.25% of the 720p samples. For illustrated material, those samples describe thin outlines, eyes, hair, fingers, faces and other sharp boundaries. If I still need fewer bytes, I would rather test a slightly higher CRF before blindly discarding another 43.75% of the spatial information. Quantization can be adjusted later; detail removed by downscaling is already gone.

So 720p-class is my default ceiling, not a claim that 540p is bad. A measured 540p rendition can still win for a particular asset. I simply do not make that irreversible spatial cut globally without evidence.

I stopped paying for frames the source did not really have

Frame rate is another multiplier. If an animation contains roughly 16 useful visual states per second, storing it as 30 or 60 fps does not automatically create better motion. It can mostly create repeated or synthesized temporal samples that still have to be represented.

My policy is to preserve the useful cadence of the source and normally stay at or below 30 fps. For this kind of material, 12, 15, 16, 18, 20, 24, 25, or 30 fps can all be reasonable when they actually describe the source.

I also prefer a clean constant frame rate in the generated output. VFR is not inherently broken; CFR simply makes timestamps, frame counts, duration checks, seeking, and later validation easier in my pipeline.

The general principle is more useful than any one FPS value: do not pay bandwidth for temporal information that the source does not contain.

CRF 28 is a workload choice, not a magic number

I did not want every clip forced toward the same target bitrate. A nearly static illustration and a scene with complex movement do not need the same number of bits to look acceptable.

So I use x264's CRF mode and, for this bandwidth-first illustrated workload, settled around -crf 28. FFmpeg documents CRF in libx264 as constant-quality rate control; see the FFmpeg codec documentation.

CRF 28 is intentionally aggressive. I would not copy it blindly to film grain, noisy camera footage, tiny screen text, or a workload where fidelity matters more than bandwidth.

I also do not have a universal perceptual score proving that CRF 28 is transparent. What I can say from my own workload is narrower: the resulting files became dramatically smaller and still looked normal to me during ordinary playback. That is a practical observation, not a claim that CRF 28 is visually lossless.

Veryslow is expensive for the encoder, not automatically for the decoder

My preset is -preset veryslow. A slower preset gives x264 more opportunity to search for efficient prediction and coding decisions. The price is encoder CPU and time.

The important distinction is that encoder effort and decoder complexity are not the same thing.

I can let x264 work very hard while separately constraining the finished stream. My conservative output contract is:

H.264 Main Profile
Level 3.1
8-bit yuv420p
avc1
refs = 4
B-frames = 5
B-pyramid = normal
open GOP = disabled

FFmpeg exposes CRF, presets, tuning, profile restrictions, reference frames, and B-frames separately. That is exactly how I think about them: let the encoder search hard, but keep the playback side ordinary.

GOP and VBV are guardrails, not the main quality control

For these short progressive clips I use a maximum GOP of roughly five seconds: about -g 150 at 30 fps, -g 120 at 24 fps, or -g 80 at 16 fps.

This is a workload choice, not a universal rule. Adaptive streaming has different constraints; Apple's HLS authoring guidance, for example, recommends IDRs every two seconds. I do not copy that HLS rule blindly into short static progressive MP4 files.

I also use approximately:

-maxrate:v 4M
-bufsize:v 8M

Those values are a ceiling against unusual bitrate spikes. They do not mean “encode everything at 4 Mbps.” CRF remains responsible for normal rate allocation, so easy clips are still free to become very small.

I made the MP4 container boring too

I explicitly use avc1. Apple's current HLS documentation recommends sample formats such as avc1 rather than avc3. That is not why my files became smaller, but it matches the goal of producing conventional H.264-in-MP4 output.

I also use -movflags +faststart. FFmpeg's format documentation says faststart moves the MP4 moov index to the beginning of the file. Android's HTTP streaming requirements likewise say that for MPEG-4, moov must precede mdat after ftyp.

ftyp
moov
mdat

Faststart does not improve compression. It makes progressive HTTP playback less awkward.

For ordinary SDR output I use 8-bit yuv420p and signal BT.709 with limited/video range. If a clip has no audio, I do not manufacture an audio track. For this illustrated content I also use -tune animation; I treat that as a content-specific choice, not part of the universal compatibility contract.

The core FFmpeg profile

For a 30 fps illustrated source, the central part of the command looks approximately like this:

ffmpeg -i input \
  -c:v libx264 \
  -preset veryslow \
  -tune animation \
  -crf 28 \
  -profile:v main \
  -level:v 3.1 \
  -pix_fmt yuv420p \
  -tag:v avc1 \
  -refs 4 \
  -bf 5 \
  -g 150 \
  -maxrate:v 4M \
  -bufsize:v 8M \
  -x264-params "open-gop=0:b-pyramid=normal:nal-hrd=none" \
  -color_range tv \
  -color_primaries bt709 \
  -color_trc bt709 \
  -colorspace bt709 \
  -an \
  -movflags +faststart \
  output.mp4

The scaling and frame-rate stages are deliberately not hard-coded here. A 900×600 source should not be enlarged just because the ceiling is 1280×720, and a naturally low-frame-rate animation should not be forced to 30 fps just because the example uses -g 150.

The command is an implementation of the policy, not the policy itself.

Why the files became several times smaller

There was no miracle flag.

The reduction came from stacking several decisions that removed different kinds of waste: unnecessary pixels, unnecessary frames, an overly conservative quality target, encoder settings that favored speed over efficiency, overly frequent keyframes, and streams I did not need.

That is why saying “this file is H.264” tells you surprisingly little about its size. Two H.264 encodes of the same source can differ dramatically because the codec name does not describe resolution, frame rate, rate control, preset, GOP structure, profile, or source preparation.

In my case, changing those surrounding decisions mattered more than changing the codec.

What the result does not prove

I did not isolate every setting in a controlled experiment, so I cannot honestly assign an exact percentage of the savings to veryslow, CRF 28, resolution reduction, or frame-rate reduction individually.

I also cannot claim that every CRF 28 output is perceptually transparent. “No obvious quality drop” is my observation for this illustrated workload at normal viewing sizes, not a scientific guarantee for arbitrary video.

And I am not arguing that one H.264 file is the right architecture for every site. Multiple renditions, adaptive streaming, HDR, 4K, and codec negotiation change the trade-offs.

What I can actually claim is narrower: for a bandwidth-first, mobile-heavy library of short illustrated and animated clips, where encoding time is cheap and predictable playback matters, this profile made my files several times smaller while still looking normal in ordinary playback.

What this changed in my optimization strategy

I used to think about video optimization mainly as an encoder-settings problem. Now I think about it as a lifetime-cost problem.

The encoder may run once. The bytes may cross the network thousands or millions of times.

That changes what “expensive” means.

I am happy to spend CPU once. I am much less willing to send pixels created by upscaling, frames that add no useful motion, or bitrate the content does not need on every future request.

The codec stayed boring: H.264 in MP4. The optimization happened around it.

For this workload, the lesson is clearer than any individual FFmpeg flag: optimize the cost you pay repeatedly, not the cost you pay once.