Video Optimization for Websites and Apps

A practical guide to optimizing adaptive and progressive video streaming on websites and apps with examples

- CTO & Co-Founder

What is video optimization?

Video optimization is the set of techniques that reduce how long it takes to start playing and how many bytes a video consumes in total - without a visible drop in quality.

On a media-heavy site, video is almost always the single largest asset on the page, so getting this right has an outsized impact on load time, Core Web Vitals, bandwidth bills, and engagement. A hero video that takes six seconds to appear will lose visitors before your copy ever gets a chance to work, and it will do so most on exactly the slow mobile connections where you can least afford it.

Is there a standard tool to measure video optimization?

Unlike images, there isn't one "right" way to optimize video. The correct approach depends on the use case: a muted background loop, a click-to-play explainer, a product carousel, and a 20-minute tutorial each call for different trade-offs. A background loop can be compressed hard and stripped of audio; a tutorial has to stay legible while adapting to whatever connection the viewer is on.

This guide walks through every technique in that decision - from the universal ones you should always apply to the situational ones you should reach for deliberately - so you can pick the handful that fit the video in front of you.

The techniques in this guide themselves are tool-agnostic: everything here can be done with FFmpeg and a solution of your choice. However, we do present examples using ImageKit because it turns most of these steps into easy-to-use URL parameters and keeps the sample app short, but the underlying principles are what matter.

Reference code & video walkthrough
All the sample code in this guide lives in the imagekit-samples/video-optimization repository. If you'd rather watch a full walkthrough, see the companion video below.

Watch it as a video

If you prefer learning via video, this companion walkthrough covers the same content as the written guide, with a live demo of each optimization in action.

Basic principles of video optimization

Image optimization is a largely solved, standardized problem: tools like Lighthouse and WebPageTest audit your images and tell you exactly what to fix - compress, serve WebP/AVIF, resize, lazy-load.

As mentioned earlier, video has no such standard audit. The only thing Lighthouse flags is a slow Largest Contentful Paint when a video causes one, but it won't hand you a video-specific checklist. This is because the right optimization depends entirely on how and where the video is used and what kind of experience you, as a business, want to offer viewers.

However, what does carry over from images or Lighthouse is a small set of principles. They're less a checklist than a lens: every technique in this guide is an application of one of these four, and when you're unsure whether you have optimized video streaming or not, these are the questions to ask.

  • Load faster - get the first frame on screen as quickly as possible. Both the distance to the server and the choice of format decide how soon playback can begin.
  • Load lighter - send the fewest bytes that still look good. Compression, right format, resolution, and the audio track are the four dials, and their savings compound.
  • Adapt over time - Unlike images where the payload is downloaded exactly once, a video plays over seconds or minutes and is downloaded in small portions, so playback should track the network, the device, and the user's behavior as they change, rather than committing to one fixed payload up front.
  • Load as late as possible - Similar to lazy loading images, don't fetch a video the user isn't looking at. Bytes spent on an unseen video are bytes stolen from the content they're actually reading. We will see later in this guide how videos need some special handling for lazy loading given they play over a longer duration and are often paused and resumed.

Keep these in mind and each specific technique below reads less like a trick and more like a consequence.

The rest of this guide works through nine techniques, roughly in the order you'd apply them:

  1. Use a CDN for video delivery - serve every byte from an edge close to the user.
  2. Compress the video - pick a modern codec and a sensible bitrate.
  3. Resize the video to fit the player - stop shipping pixels the screen never shows.
  4. Remove the audio track - drop silent audio on background and decorative video.
  5. Set up adaptive bitrate streaming - switch renditions to match the network.
  6. Adapt the resolution to the device - cap the ABR ladder to the player size.
  7. Lazy load your videos - defer loading until a video nears the viewport.
  8. Control what gets preloaded - limit how much buffers before play.
  9. Pause videos when they're not being watched - stop streaming off-screen playback.

Example video for demonstration

Our baseline for the rest of the guide is the sample app: an article page with a hero video served as a single 44.4 MB MP4 straight from an S3 bucket through a plain <video> tag. It's deliberately unoptimized, and each section chips away at that number so you can see the effect of one change at a time.

The original video can be accessed from ImageKit using the URL below, and you can use it to follow along with the sample code in this guide.

https://ik.imagekit.io/ikmedia/driving-trip.mp4?tr=orig

1. Use a CDN for video delivery

The most fundamental optimization, and the one to get right before any other: never serve video straight from origin storage (S3, GCS, and the like). Put a CDN in front of it.

Without a CDN, a user on the US West Coast fetching from a bucket on the US East Coast pays the full cross-country round-trip on every byte - and so does the next viewer in their region, and the one after that.

With a CDN, the video is cached at an edge node close to each user: the first request populates that edge, and every subsequent viewer nearby is served from the cached copy. For a high-traffic page that's the difference between one slow origin fetch and thousands of fast edge hits, which shows up as lower latency and far less rebuffering.

A media CDN earns its place for reasons beyond geography, too. It offloads bandwidth from your origin, absorbs traffic spikes without you provisioning for them, and correctly handles the HTTP range requests that video streaming depends on - so a viewer can jump to the middle of a clip without first downloading everything before it.

In the sample app the baseline already sits behind CloudFront in front of S3 - a plain <video> tag pointed at the CDN, with no transformations yet:

<video controls autoPlay muted>
  <source
    src={`https://d1o2glsg6m692z.cloudfront.net/driving-trip.mp4`}
    type="video/mp4"
  />
  Your browser does not support the video tag.
</video>

At this stage nothing is optimized except the load time - the same bytes are simply delivered from an edge close to the user that makes them load faster. This is the bare minimum, not the goal - the sections that follow steadily reduce how many of those bytes are sent in the first place.

2. Compress the video

A CDN makes the same bytes arrive faster. Compression reduces how many bytes there are in the first place, and it's usually the single biggest win available.

Two factors control video size:

  • The right codec and container: The container (MP4, WebM) just packages the video, audio, and subtitle streams together - it isn't where the savings come from. The real compression comes from the codec, the encoder/decoder algorithm that decides how each frame, and the differences between consecutive frames, are stored. VP9 (typically in WebM container) and H.264/H.265 (in MP4 container) are the common choices. Newer codecs like AV1 produce 30–50% smaller files than H.264 at the same perceived quality, in exchange for more CPU to encode and decode. These are gradually gaining traction.

  • Video Bitrate: The amount of data spent per second of video (Kbps/Mbps). Every codec and resolution has a sensible bitrate range: drop below it and you get blocking artifacts, and sit above it and you're shipping details that consume bytes, but no one can see them.

Optimizing the right codec and bitrate can be done using FFmpeg. Each processed output is maintained as a separate encoded copy per variation.

ffmpeg -i input.mov -c:v libvpx-vp9 -b:v 1M -c:a libvorbis output.webm

You then list each encoded copy inside a single <video> tag using <source> elements. The browser picks the first type it can play, so put the smaller, newer format first and keep the widely supported MP4 as a fallback:

<video controls autoPlay muted>
  <source src="driving-trip.webm" type="video/webm" />
  <source src="driving-trip.mp4" type="video/mp4" />
</video>

The real cost of the manual route is maintenance: you now own several files per video and the logic to hand the right one to each browser.

The alternative is to let the video delivery server negotiate the format and codec (as a result, compression) per request. ImageKit does that automatically when streaming the same video to different devices. Using the ImageKit video URL instead of CloudFront returns WebM/VP9 codec to Chrome automatically, and MP4/H264 codec in other browsers, with no extra work on your part. The sample app switches to that URL and drops the <source> elements.

Here Video is the React component from @imagekit/react that wraps a <video> tag and handles the URL construction for you.

import { Video } from "@imagekit/react";

<Video
  urlEndpoint="https://ik.imagekit.io/ikmedia/"
  src="driving-trip.mp4"
  controls
/>

That one change, of swapping CloudFront for the ImageKit delivery URL, drops the hero video from 44.4 MB MP4 to 7.8 MB WebM (VP9) on Chrome, with no markup changes.

Compressing the file reduces the payload by ~82% without any visible loss in quality and without modifying the video itself. The savings compound with every other optimization in this guide.

3. Resize the video to fit the player placeholder

Just like images, there's no point sending a 1080p stream to a player that occupies a few hundred pixels on the screen. For example, if the mobile viewport shows a 450px x 400px player, it gets nothing from a full 1920 x 1080p source except a bigger download and more decoding work - the extra detail is thrown away at display time.

The savings here are steep because a video's byte count scales roughly with its pixel count. Halving both the dimensions - width and height - quarters the frame area, and the encoded size falls in step.

Scaling down a video is often the second-biggest win after codec choice, and it costs nothing in perceived quality because the pixels you drop were never going to be shown.

You can account for device pixel ratio when deciding on the video resolution, to ensure higher visual quality on the user's device. So a 2× retina display gets roughly double the CSS pixels.

Assuming we need a 450px wide video, but on a 2x display, we need a 900px wide video. With FFmpeg you can scale to a 900px width while keeping the aspect ratio - the -1 height lets the encoder compute it automatically. You can fold the codec and bitrate choices from the previous step into the same command.

ffmpeg -i input.mov -vf "scale=900:-1" -c:v libvpx-vp9 -b:v 1M -c:a libvorbis output.webm

The maintenance overhead is producing and storing a variant per breakpoint for each video and codec variation.

ImageKit allows resizing on delivery using URL-based video transformation parameters. This allows you to use one source video, and just vary the width parameter on the URL, without worrying about any other aspect of the infrastructure.

<Video
  urlEndpoint="https://ik.imagekit.io/ikmedia/"
  src="driving-trip.mp4"
  transformation={[{ width: 900 }]}
  controls
/>

In the sample app this brings the WebM from the 7.8 MB obtained after compression down to ~2.9 MB. This is roughly a 90% reduction from the original unoptimized video.

4. Remove the audio track

This is a more aggressive lever for optimization, and it applies only when the video genuinely doesn't need sound - most commonly muted background loops, GIF-style product animations, and ambient hero clips.

Even when the audio track is silent or the player is muted, the audio stream is still encoded and shipped on every request in the container format. If your use case doesn't require the audio, then it's pure waste.

Stripping it using ImageKit is a single additional transformation parameter, audioCodec: "none", which removes the audio track from the output. The code below adds that to the previous resizing transformation.

<Video
  urlEndpoint="https://ik.imagekit.io/ikmedia/"
  src="driving-trip.mp4"
  transformation={[{ width: 900, audioCodec: "none" }]}
  controls
/>

In the sample app removing audio takes the file from ~2.9 MB to ~2.6 MB - about 10%. Per video that reads as a rounding error, but it's 10% off every delivery of that asset, and across a catalog measured in terabytes of transfer it becomes a real line on the bill as well as a small latency win for the viewer. The one caveat is to be deliberate: reach for this on background and decorative video, never on anything a viewer might want to unmute.

Progressive vs adaptive bitrate streaming

Everything so far uses a single video file played through a standard <video> tag or the ImageKit Video component which is a wrapper on top of it. This is called progressive streaming.

How progressive streaming works

The browser is given a single file URL to play, and it fetches that file in chunks using HTTP range requests as playback proceeds (you'll see 206 Partial Content responses carrying Range/Content-Range headers), which is why a viewer can start watching before the whole file arrives. The player starts as soon as the first chunk arrives, and the rest of the file downloads as playback continues.

But the file it pulls never changes: resize the viewport, rotate the device, or drop from Wi-Fi to a weak cellular signal, and you're still streaming from the exact same URL. However, logic dictates that one file can't be right for every situation. If that encode is too heavy for the connection, playback stalls; if it's too light for the screen, it looks soft. This is where Adaptive Bitrate Streaming (ABR) comes in.

Adaptive bitrate streaming (ABR) - the approach behind YouTube and Netflix - removes that fixed commitment. The source is encoded into several resolutions (360p, 480p, 720p, 1080p…), each at an appropriate bitrate, and every rendition is sliced into short segments, typically six seconds long.

A manifest file lists every rendition and its segments like a playlist. The player starts on a low rendition so playback begins almost immediately, measures how quickly each segment arrives, and then chooses the rendition for the next segment accordingly - stepping up when there's bandwidth to spare and down before it runs out - so playback stays smooth as conditions shift underneath it. You would have seen this happen while streaming videos on YouTube or Netflix: the quality changes mid-playback as the network fluctuates.

That flexibility to adapt resolutions has a setup cost. Two things change when you adopt ABR -

  1. The video has to be packaged into segmented renditions using a standard streaming protocol - HLS (near-universal on the web) or DASH.
  2. You need a player that understands the manifest and performs the switching - HLS.js, Video.js, ExoPlayer, React Native Video, and the like. A plain <video> tag can neither parse a manifest nor change renditions on its own.

5. Set up adaptive bitrate streaming

Setting up the video for adaptive streaming the traditional way is real work: transcode each rendition, segment every one, generate the manifests, and host the output alongside your originals. You then have to set up a CDN to deliver these manifests and segments.

Video delivery platforms such as ImageKit can generate it on demand from a single original video. For instance, ImageKit produces the HLS manifest when you append a suffix to the video URL and list the renditions you want. As shown below we create the 360p, 480p, 720p, and 1080p variants of the video using the HLS protocol (m3u8 extension).

https://ik.imagekit.io/ikmedia/driving-trip.mp4/ik-master.m3u8?tr=sr-360_480_720_1080

That returns a master .m3u8 playlist referencing the 360p/480p/720p/1080p variants, each already segmented. From the front end the only real work is handing that manifest to an HLS player and attaching it to a <video> element:

import Hls from "hls.js";

const representations = "360_480_720_1080";
const manifestUrl =
  `https://ik.imagekit.io/ikmedia/driving-trip.mp4/ik-master.m3u8?tr=sr-${representations}`;

const hls = new Hls();
hls.loadSource(manifestUrl);
hls.attachMedia(videoRef.current);

On load, the player typically starts at 360p, then climbs to 720p/1080p as it measures the available bandwidth.

To test if the adaptive streaming is working properly, you can throttle the connection in Chrome DevTools (to Slow 4G, say) and you'll watch it hold, or step down to a lower rendition, rather than stall. That is the stream adapting to the network in real time. All of that bandwidth detection and switching logic lives in the player; the server simply serves whichever segment is requested next.

6. Adapt the resolution to the device for adaptive streaming

Left to its defaults, an HLS player treats bandwidth as the only constraint and will happily climb to 1080p whenever the connection allows - even inside a 227px-tall player where every pixel above 480p is discarded (on a 2x display) on the way to the screen. That's bytes and battery spent on detail the viewer physically cannot see.

The fix is to bound the ladder additionally by the display, not just the network. Most players expose a setting to cap the rendition to the rendered player size, plus a ceiling on device pixel ratio so a phone reporting a 3× or 4× display doesn't pull an unnecessarily large rendition:

const hls = new Hls({
  capLevelToPlayerSize: true,
  maxDevicePixelRatio: 2,
});

With those in place the player tops out at 480p for a placeholder that is 227px in height, instead of 1080p, and the total video loaded drops from ~11.3 MB to ~2.6 MB with no visible difference at that size.

Because ABR re-evaluates continuously, and the player is set to adjust the level using the player size, it cooperates with layout changes rather than fighting them. For example, when you rotate the phone to landscape, or expand the player, or go fullscreen mid-playback, the player size increases, and so the player steps up to the resolution the larger viewport now genuinely needs.

Therefore at all network and display conditions, the player is sending the fewest bytes that still look good, and the viewer sees the best quality that their device can show.

Do you really need adaptive bitrate streaming?

ABR is powerful, and ideally that is the only streaming method to go for. But it isn't free. You ship an extra player - HLS.js is roughly 1 MB of JavaScript - plus the packaging step and more moving parts to debug. For the right content that's an easy trade; for the wrong content it's overhead that makes the page slower rather than faster. Two questions settle it:

  • Is the video long enough to benefit? Adaptation only happens across segments, so a clip needs enough of them for network conditions to actually change mid-playback. As a rule of thumb, use adaptive streaming when there are more than a handful of segments, or roughly video duration is 20 seconds and up. A five-second product loop or a short animation has nothing to adapt to; serve it as a single, right-sized, well-encoded file through the plain <video> tag and skip the player entirely.
  • Do you actually want it to adapt? Adaptation means the quality is allowed to drop. For brand animations, feature explainers, or anything with fine text or a logo, a low rendition looks broken, and you'd rather never show it. In those cases pin a single correctly-encoded file at the right resolution; if you must handle a poor connection, fall back to a static image rather than a blurry frame.

7. Lazy load your videos

Videos are multi-megabyte objects, and just like lazy loading images, there's no reason to fetch one the user may never reach.

A video below the fold that starts loading as soon as the page loads, consumes bandwidth on content that might never be seen, and, worse, it competes with the critical CSS, JavaScript, and fonts the browser needs to render what the user is actually looking at, so it can slow down the visible part of the page as a side effect.

The fix is to defer loading until the video is about to enter the viewport, using an IntersectionObserver. A rootMargin triggers the load slightly before the element scrolls into view, so playback is ready by the time it arrives instead of starting on a blank frame. The sample app wraps this in a small useIntersectionObserver hook.

import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";

const { isIntersecting, elementRef } = useIntersectionObserver({
  threshold: 0.5,
  rootMargin: "100px",
});

// Only initialize the player once the container is near the viewport
useEffect(() => {
  if (isIntersecting && !isInitializedRef.current) {
    loadHLS();
  }
}, [isIntersecting]);

return <div ref={elementRef} className={className}>{/* video element */}</div>;

In the sample app, if the video loads below the fold, no requests fire for it until the user scrolls near it. Only when they scroll closer to the video does streaming begin.

The same idea applies to horizontal scrolls or product carousels as well. A video on the fifth slide of a product carousel, or behind an inactive tab, shouldn't load until the user actually navigates to it. Anywhere a video starts hidden, let intent, not page load, trigger the fetch.

8. Control what gets preloaded

Lazy loading decides when a video starts loading; preload control decides how much loads before the user presses play.

The two are easy to conflate, and removing autoplay alone doesn't solve it - a paused player left on its defaults can still quietly buffer several megabytes in the background.

For progressive streaming, the native preload attribute allows you to hint to the browser how much to fetch before the user presses play. The three options are:

  • preload="none" - fetch nothing until play is pressed. No data, and no first frame. Pair it with a poster image so the player isn't blank while it waits. This is the most bandwidth-frugal option and the right default whenever a play click is likely.
  • preload="metadata" - fetch just enough (usually a few hundred KB) to know the duration and show a first frame, then wait. This is the usual sweet spot: the player looks ready and reports its length without committing to the whole file.
  • preload="auto" - a hint that the browser may buffer aggressively. Reserve it for videos you're confident will be played.
<Video
  urlEndpoint="https://ik.imagekit.io/ikmedia/"
  src="driving-trip.mp4"
  preload="metadata"
  poster="https://ik.imagekit.io/ikmedia/driving-trip.mp4/ik-thumbnail.jpg?tr=w-900"
  controls
/>

Adaptive streaming, however, ignores preload entirely. When using a dedicated player for adaptive streaming, it is the player, not the browser, that decides what to fetch. In HLS.js, maxBufferLength (how many seconds to read ahead) and maxBufferSize (a byte ceiling) cap how much it pulls before playback:

const hls = new Hls({
  capLevelToPlayerSize: true,
  maxDevicePixelRatio: 2,
  maxBufferLength: 10,            // seconds to buffer ahead
  maxBufferSize: 1 * 1024 * 1024, // ~1 MB
});

With those set, the player fetches roughly the first segment and stops, instead of racing to download the whole video before the viewer has committed to watching. Tighten the numbers on pages with many videos; loosen them when smooth seeking matters more than the upfront saving.

9. Pause videos when they're not being watched

This is the highest-impact optimization for long videos, and the one most often missed. Once a user presses play and then scrolls away - or swipes to the next item in a carousel - most players keep the video playing, and therefore downloading the subsequent segments in the background.

On a 30-second clip that's a couple of wasted megabytes; on a 20-minute video the viewer abandoned after ten seconds, it's a large, pure-waste transfer that no one will ever watch, repeated for every visitor who does the same thing.

This optimization strategy is unique to videos, as unlike images, they load over time, and over time the user's behavior or how they are interacting with the video can change. The remedy is to pause playback the moment the video leaves the viewport, and resume it when it returns.

The implementation mirrors the lazy-load logic in reverse: watch the player with the same useIntersectionObserver hook and pause playback when it leaves the viewport, which stops the player from requesting more segments. When we pause it, remember where it was playing so you can resume from the same point when it scrolls back into view.

const { isIntersecting, elementRef } = useIntersectionObserver({
  threshold: 0.5,
  rootMargin: "100px",
});

// Pause when scrolled out of view; resume when it returns
useEffect(() => {
  const video = videoRef.current;
  if (!video || !autoPause) return;

  if (!isIntersecting && !video.paused) {
    wasPlayingRef.current = true;
    video.pause();
  } else if (isIntersecting && wasPlayingRef.current) {
    wasPlayingRef.current = false;
    video.play().catch(() => {});
  }
}, [isIntersecting, autoPause]);

Again, this matters for horizontal carousels as much as long-form scroll. Play a product video, swipe to the next image, and if you don't implement the pause strategy, the first video keeps streaming out of sight and you keep paying to deliver it.

On a media-heavy page, pausing off-screen playback is usually where the largest - and least visible - bandwidth savings come from.

Conclusion

Video optimization has no single Lighthouse audit because the right choices genuinely depend on your use case - but the four principles - load faster, load lighter, adapt over time, load as late as possible - map cleanly onto a concrete checklist:

  • Always serve video through a CDN; it's the floor every other optimization builds on.
  • Compress with a modern codec and a sensible bitrate, and let format negotiation hand each browser the best file it can decode.
  • Resize the video to the player, not to the largest source you happen to have.
  • Drop the audio track on silent or background video.
  • Use adaptive bitrate streaming for longer content that benefits from it, and cap the rendition to the player size - but skip ABR for short or quality-critical clips.
  • Lazy load videos, control preload and buffering, and pause playback the moment a video leaves the viewport.

Your project may not need all of these at once. The value is in matching each technique to the content in front of you. A background loop leans on compression, resizing, and audio removal; a long tutorial leans on ABR, preload control, and pausing. Applied with that judgment, they give users a fast, smooth streaming experience while keeping delivery costs predictable - the two goals that usually pull in opposite directions.

Implementing the above examples with ImageKit

Every optimization above is tool-agnostic, but if you'd rather not build and maintain the encoding and packaging pipeline yourself, ImageKit exposes most of it through the delivery URL. Connect your existing storage (S3, GCS, Azure Blob, or ImageKit's own) as an origin, point a URL endpoint at it, and the sections above map onto these features:

  • CDN delivery - videos are served from a CDN by default, so the "use a CDN" step is handled once your URL endpoint is live.
  • Automatic format & compression - the same URL returns VP9/AV1/H.264 based on what the requesting browser supports, compressed to a sensible quality; pin it with ?tr=q-<1–100> for manual control. See video optimization.
  • Resize on the fly - add ?tr=w-<width> (plus h-, crop modes, and so on) to size a video to its placeholder without pre-producing variants. See resize & crop.
  • Remove audio - ?tr=ac-none strips the audio track for background and decorative video.
  • Adaptive bitrate streaming - append /ik-master.m3u8?tr=sr-<renditions> to generate an HLS manifest with the listed renditions, then pair it with any HLS player. See adaptive bitrate streaming.
  • Posters & thumbnails - append /ik-thumbnail.jpg (with ?tr=so-<seconds> to pick the frame) to generate a poster from the video itself. See video thumbnails.

The complete, runnable versions of the HTML5Video, ImageKitVideo, and HLSVideoPlayer components used throughout this guide are in the imagekit-samples/video-optimization repository, and there's a full video walkthrough that builds them up step by step.

FAQs

What's the best video format for web use?

There's no perfect one-format-fits-all solution:

  • MP4 (H.264) is the universal baseline - every evergreen browser can decode it, but files are roughly 30–50% larger than newer codecs at the same perceived quality.
  • WebM (VP9) is a solid middle ground, supported in Chrome, Edge, Firefox, and Safari 17+, typically saving 25–35% versus H.264.
  • AV1 is the efficiency champ, usually 15–30% smaller than VP9 and up to ~50% smaller than H.264, but hardware decode support is still growing.

Best practice: encode at least a next-gen stream (AV1 or VP9) plus an MP4/H.264 fallback - or delegate format negotiation to a media platform that switches at the edge. Never publish raw .mov or other camera-native files on the web.

My page has an auto-playing background video. How can I optimize it?

Background loops start loading immediately, so treat them aggressively: keep them short and looped, mute them (required for autoplay anyway), remove the audio track entirely, and serve a low resolution and bitrate since users don't scrutinize ambiance. Use a poster for a fast first paint, and consider skipping the video altogether on mobile or slow connections in favor of a static image.

Can I use the HTML <video> tag to replace animated GIFs?

Yes - and you should. Converting a GIF to MP4/WebM can cut file size by 80–90% for the same visual. Mimic GIF behavior with autoplay muted loop playsinline (the playsinline attribute is important on iOS). Lazy-load these loops if they aren't immediately visible.

How does Data Saver mode affect videos?

When a user enables Data Saver, the browser sends a Save-Data: on header and Chrome may force preload="none" on videos. Honor it by not auto-playing and serving a smaller video or a static image.

We have a lot of user-generated video content. Any special considerations?

With UGC you don't control source quality - users can upload huge 4K files - so an automated pipeline is essential. Transcode uploads to standardized formats, resolutions, and bitrates, generate thumbnails, and cap duration or file size to manage storage and bandwidth. A service like ImageKit can handle this automatically.