206 Partial Content — Video Streaming Buffering Issues
Belirtiler
- Video or audio playback stutters and the seek bar (scrubbing to different positions) does not work — clicking on the progress bar causes buffering
- Safari specifically refuses to play audio or video, displaying a broken media player, while Chrome and Firefox play the same file without issues
- The server returns `HTTP/1.1 200 OK` for media requests instead of `206 Partial Content`, and the response does not include `Accept-Ranges: bytes`
- Large file downloads cannot be resumed after a network interruption — the download restarts from the beginning instead of from where it stopped
- A Nginx proxy is stripping the `Range` header before it reaches the application server, so the backend always receives an unranged request
- Safari specifically refuses to play audio or video, displaying a broken media player, while Chrome and Firefox play the same file without issues
- The server returns `HTTP/1.1 200 OK` for media requests instead of `206 Partial Content`, and the response does not include `Accept-Ranges: bytes`
- Large file downloads cannot be resumed after a network interruption — the download restarts from the beginning instead of from where it stopped
- A Nginx proxy is stripping the `Range` header before it reaches the application server, so the backend always receives an unranged request
Kök Nedenler
- Server or CDN not supporting HTTP Range requests — the server ignores the `Range: bytes=X-Y` header and returns the full file with `200 OK` instead of `206`
- Nginx `proxy_pass` configuration stripping the `Range` header from requests to the upstream application server — Nginx may serve it from cache without ranges
- Application view serving files through a Python/Node.js response that reads the entire file and returns it without implementing Range header parsing
- S3 or GCS presigned URL expiring mid-stream during a large file download — the client's next Range request uses an expired URL and gets 403 or 401
- `Accept-Ranges: none` header explicitly sent by the server, telling the browser that range requests are not supported and disabling seeking
Tanı
**Step 1 — Check if the server advertises Range request support**
```bash
# Check Accept-Ranges header
curl -I https://example.com/video/clip.mp4
# Good: Accept-Ranges: bytes (supports range requests)
# Bad: Accept-Ranges: none (explicitly disabled)
# Bad: No Accept-Ranges header (may or may not support ranges)
```
**Step 2 — Test a Range request explicitly**
```bash
# Request bytes 0-1023 (first 1KB)
curl -v -H 'Range: bytes=0-1023' https://example.com/video/clip.mp4
# Good:
# < HTTP/1.1 206 Partial Content
# < Content-Range: bytes 0-1023/15423390
# < Content-Length: 1024
# Bad: Returns 200 with entire file — Range header was ignored
# < HTTP/1.1 200 OK
# < Content-Length: 15423390
```
**Step 3 — Test Safari-specific behavior**
Safari requires Range request support for ANY audio/video playback, including the initial load:
```bash
# Simulate Safari's initial media request (bytes=0-1)
curl -v -H 'Range: bytes=0-1' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X)...' \
https://example.com/audio/track.mp3
# Must return 206 — if 200, Safari will refuse to play
```
**Step 4 — Check if Nginx is stripping the Range header**
```bash
# Check if Range is reaching the upstream application:
# Add debug logging to Nginx config temporarily:
# log_format range_debug '$http_range $upstream_http_accept_ranges';
# access_log /var/log/nginx/range.log range_debug;
# Or test the backend directly:
curl -v -H 'Range: bytes=0-1023' http://127.0.0.1:8000/video/clip.mp4
# If backend returns 206 but proxy returns 200 → Nginx is stripping Range
```
**Step 5 — Verify S3 presigned URL expiry for streaming**
```python
import boto3
s3 = boto3.client('s3')
# Check current presigned URL expiry:
url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'my-bucket', 'Key': 'video.mp4'},
ExpiresIn=3600, # Must exceed the video duration + buffer time
)
# For 2-hour videos, set ExpiresIn > 7200
```
```bash
# Check Accept-Ranges header
curl -I https://example.com/video/clip.mp4
# Good: Accept-Ranges: bytes (supports range requests)
# Bad: Accept-Ranges: none (explicitly disabled)
# Bad: No Accept-Ranges header (may or may not support ranges)
```
**Step 2 — Test a Range request explicitly**
```bash
# Request bytes 0-1023 (first 1KB)
curl -v -H 'Range: bytes=0-1023' https://example.com/video/clip.mp4
# Good:
# < HTTP/1.1 206 Partial Content
# < Content-Range: bytes 0-1023/15423390
# < Content-Length: 1024
# Bad: Returns 200 with entire file — Range header was ignored
# < HTTP/1.1 200 OK
# < Content-Length: 15423390
```
**Step 3 — Test Safari-specific behavior**
Safari requires Range request support for ANY audio/video playback, including the initial load:
```bash
# Simulate Safari's initial media request (bytes=0-1)
curl -v -H 'Range: bytes=0-1' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X)...' \
https://example.com/audio/track.mp3
# Must return 206 — if 200, Safari will refuse to play
```
**Step 4 — Check if Nginx is stripping the Range header**
```bash
# Check if Range is reaching the upstream application:
# Add debug logging to Nginx config temporarily:
# log_format range_debug '$http_range $upstream_http_accept_ranges';
# access_log /var/log/nginx/range.log range_debug;
# Or test the backend directly:
curl -v -H 'Range: bytes=0-1023' http://127.0.0.1:8000/video/clip.mp4
# If backend returns 206 but proxy returns 200 → Nginx is stripping Range
```
**Step 5 — Verify S3 presigned URL expiry for streaming**
```python
import boto3
s3 = boto3.client('s3')
# Check current presigned URL expiry:
url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'my-bucket', 'Key': 'video.mp4'},
ExpiresIn=3600, # Must exceed the video duration + buffer time
)
# For 2-hour videos, set ExpiresIn > 7200
```
Çözüm
**Fix 1: Serve static files through Nginx directly (best for performance)**
```nginx
# Nginx handles Range requests natively for static files
server {
location /media/ {
alias /var/www/media/;
# Nginx automatically:
# - Adds Accept-Ranges: bytes header
# - Returns 206 for Range requests
# - Returns 416 for unsatisfiable ranges
add_header Cache-Control 'public, max-age=3600';
}
}
```
**Fix 2: Implement Range request handling in Django**
```python
import os
from django.http import StreamingHttpResponse, HttpResponse
def serve_video(request, filename: str) -> HttpResponse:
filepath = f'/var/www/media/{filename}'
file_size = os.path.getsize(filepath)
range_header = request.META.get('HTTP_RANGE', '').strip()
if not range_header:
# No range — return full file with Accept-Ranges header
response = StreamingHttpResponse(
open(filepath, 'rb'), content_type='video/mp4'
)
response['Accept-Ranges'] = 'bytes'
response['Content-Length'] = file_size
return response
# Parse Range: bytes=start-end
range_spec = range_header.replace('bytes=', '')
start_str, end_str = range_spec.split('-')
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
length = end - start + 1
def file_iterator(path, offset, chunk_size=8192):
with open(path, 'rb') as f:
f.seek(offset)
remaining = length
while remaining > 0:
data = f.read(min(chunk_size, remaining))
if not data:
break
remaining -= len(data)
yield data
response = StreamingHttpResponse(
file_iterator(filepath, start),
status=206,
content_type='video/mp4',
)
response['Content-Range'] = f'bytes {start}-{end}/{file_size}'
response['Accept-Ranges'] = 'bytes'
response['Content-Length'] = length
return response
```
**Fix 3: Use S3 with longer-lived presigned URLs for streaming**
```python
import boto3
def get_video_url(key: str, video_duration_seconds: int) -> str:
s3 = boto3.client('s3', region_name='us-east-1')
# Expiry = duration + 30 min buffer for startup and seeking
expiry = video_duration_seconds + 1800
return s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'my-videos', 'Key': key},
ExpiresIn=expiry,
)
# S3 natively supports Range requests — no extra config needed
```
```nginx
# Nginx handles Range requests natively for static files
server {
location /media/ {
alias /var/www/media/;
# Nginx automatically:
# - Adds Accept-Ranges: bytes header
# - Returns 206 for Range requests
# - Returns 416 for unsatisfiable ranges
add_header Cache-Control 'public, max-age=3600';
}
}
```
**Fix 2: Implement Range request handling in Django**
```python
import os
from django.http import StreamingHttpResponse, HttpResponse
def serve_video(request, filename: str) -> HttpResponse:
filepath = f'/var/www/media/{filename}'
file_size = os.path.getsize(filepath)
range_header = request.META.get('HTTP_RANGE', '').strip()
if not range_header:
# No range — return full file with Accept-Ranges header
response = StreamingHttpResponse(
open(filepath, 'rb'), content_type='video/mp4'
)
response['Accept-Ranges'] = 'bytes'
response['Content-Length'] = file_size
return response
# Parse Range: bytes=start-end
range_spec = range_header.replace('bytes=', '')
start_str, end_str = range_spec.split('-')
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
length = end - start + 1
def file_iterator(path, offset, chunk_size=8192):
with open(path, 'rb') as f:
f.seek(offset)
remaining = length
while remaining > 0:
data = f.read(min(chunk_size, remaining))
if not data:
break
remaining -= len(data)
yield data
response = StreamingHttpResponse(
file_iterator(filepath, start),
status=206,
content_type='video/mp4',
)
response['Content-Range'] = f'bytes {start}-{end}/{file_size}'
response['Accept-Ranges'] = 'bytes'
response['Content-Length'] = length
return response
```
**Fix 3: Use S3 with longer-lived presigned URLs for streaming**
```python
import boto3
def get_video_url(key: str, video_duration_seconds: int) -> str:
s3 = boto3.client('s3', region_name='us-east-1')
# Expiry = duration + 30 min buffer for startup and seeking
expiry = video_duration_seconds + 1800
return s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'my-videos', 'Key': key},
ExpiresIn=expiry,
)
# S3 natively supports Range requests — no extra config needed
```
Önleme
- **Serve media files from Nginx or a CDN/object store, not through Django views** — application servers are not optimized for large file serving and may not implement Range requests; Nginx and S3 handle them natively
- **Always test video and audio playback on Safari** — Safari's strict requirement for Range request support catches missing implementation early, whereas Chrome and Firefox are more permissive
- **Validate `Accept-Ranges: bytes` in your API test suite** for any endpoint that serves media files — a missing header is a reliable indicator that range requests are not supported
- **Configure CDN byte-range caching** explicitly — most CDNs require `origin_pull_range_enabled = true` or equivalent to properly cache partial content responses and serve subsequent range requests from the cache
- **Always test video and audio playback on Safari** — Safari's strict requirement for Range request support catches missing implementation early, whereas Chrome and Firefox are more permissive
- **Validate `Accept-Ranges: bytes` in your API test suite** for any endpoint that serves media files — a missing header is a reliable indicator that range requests are not supported
- **Configure CDN byte-range caching** explicitly — most CDNs require `origin_pull_range_enabled = true` or equivalent to properly cache partial content responses and serve subsequent range requests from the cache