Back to blog
August 13, 2026Sergei Solod9 min read

How I Made My H.264 Videos Several Times Smaller Without an Obvious Quality Drop

I kept H.264 and rebuilt the encoding profile around CRF 28, x264 veryslow, 720p-class limits, useful frame rates, and a conservative decode envelope. The biggest lesson was economic: encoding is paid once, bandwidth is paid on every view.

H.264FFmpegVideo CompressionWeb Performancex264

The result looked like the kind of improvement that normally requires a newer codec: my video files became several times smaller, while ordinary playback still looked normal and I could not see an obvious quality drop at normal viewing sizes.

But I had not switched to AV1, HEVC, or VP9. I was still using H.264 inside MP4.

What changed was everything around the codec. I rebuilt the encoding policy for one specific workload: short illustrated and animated clips, a mobile-heavy audience, bandwidth as the main recurring cost, and almost no concern about how long a one-time offline encode takes.

The baseline I settled on was deliberately conservative for playback and deliberately expensive for encoding: H.264 Main Profile @ Level 3.1, avc1, 8-bit yuv420p, CRF 28, x264 veryslow, a 720p-class resolution ceiling, useful frame rates normally no higher than 30 fps, bounded references and B-frames, and faststart for progressive MP4 delivery.

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 playback recommendations also include 1280×720 at 30 fps as an HD H.264 configuration. 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.

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, fixed-bitrate thinking, cheap encoder settings, 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.

The result is narrower and more useful than that: 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.

The rule I use now

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.