Cómo servir video y audio desde el storage (range requests)

Para que el navegador reproduzca y adelante un video desde el storage, el servidor debe responder al encabezado Range con 206 Partial Content y anunciar Accept-Ranges: bytes. Sin eso, el video se descarga entero y adelantar falla.

Actualizado el 6 min de lectura

Objetivos de aprendizaje

Después de leer este artículo podrás:

  • Entender cómo Range, 206 y Content-Range permiten adelantar el video
  • Probar con curl si tu storage o servidor acepta range requests
  • Corregir los errores comunes: moov al final del MP4, 200 en lugar de 206 y caché
En esta página

Subes un MP4 de 800 MB al storage, pones la URL en una etiqueta <video> y le das play. Funciona. Entonces alguien arrastra la barra al minuto 40 y el video se congela, vuelve a empezar desde cero o descarga todo antes de reproducir. Casi nunca el problema está en el archivo: es que el servidor no está respondiendo a range requests.

Los detalles del protocolo que siguen se basan en la RFC 9110 y en la documentación de MDN.

NavegadorStorageGET Range: bytes=0-206 Content-Range: bytes 0-…/838860800GET Range: bytes=524288000-206 Content-Range: bytes 524288000-…el usuario avanzaclase.mp4solo se descargan los tramos pedidos
Cada salto se vuelve un nuevo GET con Range; el storage responde 206 solo con el tramo pedido, sin descargar el archivo entero.

¿Qué es una range request?#

Es una petición HTTP que pide solo un trozo del archivo, medido en bytes. El cliente envía el encabezado Range y el servidor devuelve solo ese fragmento:

http
GET /videos/clase.mp4 HTTP/1.1
Host: ejemplo.com
Range: bytes=1048576-2097151
http
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: video/mp4
Content-Length: 1048576
Content-Range: bytes 1048576-2097151/838860800

Los cuatro elementos que importan:

  • Range: bytes=inicio-fin pide desde el byte inicio hasta fin, ambos incluidos. bytes=0-1023 son los primeros 1024 bytes. bytes=1000- llega hasta el final. bytes=-500 pide los últimos 500 bytes.
  • 206 Partial Content indica que el cuerpo es solo una parte del archivo.
  • Content-Range: bytes 1048576-2097151/838860800 dice qué fragmento llegó y el tamaño total.
  • Accept-Ranges: bytes anuncia que el servidor acepta rangos. Si el encabezado no aparece, el cliente no puede contar con ese soporte.

Si el servidor ignora el Range, responde 200 OK con el archivo entero. El navegador lo acepta, pero pierde la capacidad de adelantar sin descargarlo todo.

¿Por qué el video y el audio dependen de esto?#

Las etiquetas <video> y <audio> no descargan el archivo completo para reproducirlo después. El navegador pide el inicio, averigua la duración y el formato, y reproduce mientras descarga. Cuando el usuario adelanta, calcula el byte correspondiente y lanza una nueva petición con Range.

El atributo preload controla cuánto se descarga antes de reproducir: none no descarga nada, metadata descarga solo lo necesario (duración y dimensiones) y auto permite descargar el archivo entero. Es una pista, y el navegador puede ignorarla. En páginas con muchos videos, preload="metadata" evita descargar decenas de archivos que nadie va a reproducir:

html
<video controls preload="metadata" width="720" src="https://ejemplo.com/videos/clase.mp4"></video>

Sin soporte de rangos, tienes el escenario del principio: una barra de progreso que no responde, un inicio lento y mucho más consumo de ancho de banda del necesario.

¿Cómo pruebo si el servidor acepta rangos?#

Pide los primeros bytes con curl y mira solo los encabezados:

bash
curl -s -o /dev/null -D - -H "Range: bytes=0-99" https://ejemplo.com/videos/clase.mp4

Una respuesta correcta tiene este formato:

http
HTTP/2 206
accept-ranges: bytes
content-length: 100
content-range: bytes 0-99/838860800
content-type: video/mp4

Revisa tres cosas: el estado es 206 (no 200), el content-length es 100 y el content-range trae el total. Si llega 200 con el content-length del archivo entero, el servidor ignoró el Range.

La API de S3 también trabaja con rangos: GetObject acepta el parámetro Range y devuelve solo el fragmento. En Python, con boto3:

python
import os

import boto3

s3 = boto3.client(
    "s3",
    endpoint_url=os.environ.get("S3_ENDPOINT"),  # omítelo en AWS S3
    aws_access_key_id=os.environ["S3_ACCESS_KEY"],
    aws_secret_access_key=os.environ["S3_SECRET_KEY"],
)

r = s3.get_object(Bucket="mi-bucket", Key="videos/clase.mp4", Range="bytes=0-99")
print(r["ResponseMetadata"]["HTTPStatusCode"])  # 206
print(r["ContentRange"])                        # bytes 0-99/838860800
print(len(r["Body"].read()))                    # 100

¿Cómo sirvo video desde un bucket privado?#

Si el archivo no es público, genera una URL prefirmada y úsala en el src. Por defecto, la URL prefirmada de S3 firma solo el encabezado host, y el Range queda fuera de la firma, así que el navegador puede pedir cualquier fragmento con la misma URL:

python
url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "mi-bucket", "Key": "videos/clase.mp4"},
    ExpiresIn=6 * 3600,
)

Elige el ExpiresIn pensando en cuánto puede durar la reproducción, no solo en la carga de la página: si la URL caduca a mitad del video, el siguiente salto falla con 403. Genera la URL cuando el usuario abre el reproductor, no al compilar el sitio.

¿Cómo sirvo rangos desde mi propio servidor?#

Si el archivo está en el disco de tu aplicación (o lo sirves a través de un proxy propio), tienes que implementar el Range tú mismo. Este servidor Node.js no usa dependencias y cubre las formas inicio-fin, inicio- y -sufijo:

js
import { createReadStream, statSync } from "node:fs";
import { createServer } from "node:http";

const FILE = "video.mp4";

createServer((req, res) => {
  const { size } = statSync(FILE);
  const base = { "Content-Type": "video/mp4", "Accept-Ranges": "bytes" };
  const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");

  if (!m || (m[1] === "" && m[2] === "")) {
    res.writeHead(200, { ...base, "Content-Length": size });
    return createReadStream(FILE).pipe(res);
  }

  let start, end;
  if (m[1] === "") {
    start = Math.max(size - Number(m[2]), 0); // bytes=-500: últimos 500
    end = size - 1;
  } else {
    start = Number(m[1]);
    end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1);
  }

  if (start >= size || start > end) {
    res.writeHead(416, { "Content-Range": `bytes */${size}` });
    return res.end();
  }

  res.writeHead(206, {
    ...base,
    "Content-Range": `bytes ${start}-${end}/${size}`,
    "Content-Length": end - start + 1,
  });
  createReadStream(FILE, { start, end }).pipe(res);
}).listen(3000);

Pruébalo con el curl de la sección anterior apuntando a http://localhost:3000. El ejemplo ignora los rangos múltiples (bytes=0-50, 100-150) y responde el archivo entero, algo que la especificación permite. En producción, prefiere servir el archivo desde el storage o desde un servidor web que ya lo implemente, en lugar de mantener este código.

¿Qué errores debo evitar?#

  • moov al final del MP4. En un MP4, el índice del video vive en el átomo moov. Si está al final, el navegador tiene que buscarlo antes de reproducir. Muévelo al inicio sin recodificar: ffmpeg -i entrada.mp4 -c copy -movflags +faststart salida.mp4. La documentación de ffmpeg advierte que este paso hace una segunda lectura y no funciona con salida fragmentada.
  • Recibir 200 en lugar de 206. Alguna capa en el camino (proxy, middleware de compresión, función serverless) está descartando el Range. Prueba con curl directo contra el storage y luego contra la URL final para encontrar dónde se pierde.
  • Content-Type incorrecto. Un MP4 servido como application/octet-stream puede descargarse en vez de reproducirse. Define video/mp4, video/webm o audio/mpeg en el objeto.
  • Comprimir el video. No apliques gzip ni brotli a un MP4: el archivo ya está comprimido, la ganancia es mínima y una capa de compresión en el camino puede desactivar o romper los rangos.
  • URL prefirmada demasiado corta. Caduca a mitad de la reproducción y adelantar falla.
  • Reemplazar un archivo con la misma URL. Si el objeto cambia mientras el navegador usa fragmentos en caché, las piezas no encajan. Usa un nombre nuevo por versión (por ejemplo clase-v2.mp4) en lugar de sobrescribir.
  • Ancho de banda sin control. Cada salto genera más peticiones. En storages que cobran la salida, un video popular se convierte en un costo; lee sobre las tarifas de egress antes de elegir dónde alojarlo.

¿Cuándo usar HLS o DASH en lugar de un MP4?#

Un MP4 con rangos resuelve videos cortos y medianos con una sola calidad. Si necesitas cambiar de resolución según la conexión del usuario, o transmitir en vivo, el camino es HLS o DASH: el video se divide en segmentos pequeños y una playlist indica cuáles descargar. Los segmentos son archivos comunes, y el object storage cumple bien ese papel. Para la mayoría de los sitios, empezar con un MP4 y faststart es suficiente.

Cómo hacerlo en Square Cloud#

El Blob Storage de Square Cloud es un object storage con API compatible con S3, y las descargas responden a range requests con 206, así que el reproductor adelanta el video sin descargar el archivo entero. Los archivos públicos se sirven desde una CDN global en el edge, y cada objeto acepta ajustes como Cache-Control y la elección entre descarga y visualización inline (útil cuando alguien abre la URL del video directamente en el navegador).

Para archivos privados, el código boto3 de arriba funciona con el endpoint https://s3-blob.squarecloud.app, la región auto, el bucket private y las claves de la pestaña "Acceso S3" del panel: la URL prefirmada SigV4 puede durar hasta 7 días. Desde el panel también tienes el enlace temporal de hasta 24 horas y el enlace para compartir de hasta 30 días, que se puede revocar. Los archivos pueden pesar hasta 10 GB, la descarga no tiene cobro de ancho de banda y todos los planes incluyen una cuota de 5 GB a 250 GB.

Antes de publicar, ejecuta la prueba curl -H "Range: bytes=0-99" contra la URL de tu objeto y confirma el 206 y el Accept-Ranges. Es la forma más rápida de validar el comportamiento con tu propio archivo.

Conoce Blob Storage

Blob Storage

Object storage compatible con S3, sin tarifas de salida

Más información

Preguntas frecuentes

¿Necesito un servidor de streaming para reproducir video en mi sitio?

Para un archivo MP4 o WebM común, no. La etiqueta video reproduce el archivo por HTTP siempre que el servidor o el storage responda a range requests. El streaming adaptativo (HLS o DASH) solo hace falta si quieres cambiar de calidad según la red.

¿Qué significa el error 416 Range Not Satisfiable?

El servidor recibió un Range que no cabe en el archivo, por ejemplo un byte inicial mayor que el tamaño del objeto. Responde 416 con Content-Range: bytes */tamaño. Suele indicar que el archivo cambió o que el cliente calculó mal el offset.

¿Por qué el video solo empieza después de descargar casi todo?

En un MP4, los metadatos están en el átomo moov. Si está al final del archivo, el navegador tiene que buscarlo antes de reproducir. Reprocesa con ffmpeg -movflags +faststart para moverlo al inicio, sin recodificar.

¿Funciona con una URL prefirmada?

Sí. Range es un encabezado de la petición y no forma parte de la firma de una URL prefirmada de S3. El navegador puede enviar Range con normalidad, y la URL solo necesita durar lo que dure la reproducción.

Siguiente artículo¿Cuánto cuesta el object storage? Almacenamiento, solicitudes y salidaEl costo del object storage suma tres líneas: GB almacenados, solicitudes y salida de datos. Mira cómo se cobra cada línea, una cuenta completa y los costos ocultos.

Más en Storage

Ver los 15 artículos