Rails video streaming
A video served out of a Rails application usually fails in one of two ways: it plays but refuses to
seek, or it never starts at all and the <video> element shows a black box with no error anybody
can see. Both are the same bug seen from different sides, and the bug is about one HTTP header.
Everything below ran on a scratch Rails application on this laptop: Rails 8.1.4, rack 3.2.7,
puma 8.0.2 in single mode with 3 threads, Ruby 4.0.5, macOS 26.5.1 on an Apple M2 Max with 12 cores,
PostgreSQL on port 15432. The Active Storage source is quoted from activestorage 8.1.4;
app/controllers/concerns/active_storage/streaming.rb is byte identical in 8.1.3.1, which I
diffed before quoting it. The two test files were produced with ffmpeg 9.0.2: sample.mp4 is
20,125,099 bytes of 1280x720 test pattern, and big.mp4 is 116,951,566 bytes, deliberately just
over the 100 MiB line.
The only request a browser makes
Chrome asks for a video with one request, and that request is open ended. Here is the middleware I put in front of the app to see it, because the Rails log prints the path and not the Range header:
class LogRanges
def initialize(app) = @app = app
def call(env)
status, headers, body = @app.call(env)
if env["PATH_INFO"].start_with?("/rails/active_storage", "/stream")
File.open(Rails.root.join("log/ranges.log"), "a") do |f|
f.puts "%-6s %-24s -> %s %s" % [
env["REQUEST_METHOD"],
env["HTTP_RANGE"] || "(no Range header)",
status,
headers["content-range"] || headers["Content-Range"] || ""
]
end
end
[status, headers, body]
end
end
Rails.application.config.middleware.insert_before 0, LogRanges
The page is one <video> tag pointed at rails_storage_proxy_path, loaded by Chrome 153.0.8010.53
in headless mode with a seek to 100 seconds scripted on loadedmetadata:
$ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --disable-gpu \
--autoplay-policy=no-user-gesture-required --user-data-dir=/tmp/vs-chrome \
--virtual-time-budget=12000 --dump-dom "http://localhost:3147/watch/sample?seek=100"
GET bytes=0- -> 206 bytes 0-20125098/20125099
GET bytes=16711680- -> 206 bytes 16711680-20125098/20125099
Two things in those two lines set up the rest of the page. The browser never names an end offset, on the first request or on the seek. And the seek is not a small window around the target: it is byte 16,711,680 to the end of the file, 3.4 megabytes of a 20 megabyte file, fetched again because the first response is not in a cache the media element will reuse for a range.
send_file ignores the Range header
The first thing almost everyone writes is this controller, and it is wrong in a way that no test catches unless the test sends a Range header:
class NaiveController < ApplicationController
def show
video = Video.find(params[:id])
send_file ActiveStorage::Blob.service.path_for(video.file.key),
type: video.file.content_type, disposition: "inline"
end
end
test "send_file in your own controller ignores the Range header" do
get "/naive/#{@video.id}", headers: { "Range" => "bytes=0-1023" }
assert_equal 200, response.status
assert_nil response.headers["Content-Range"]
assert_equal SAMPLE_SIZE, response.body.bytesize
end
That test passes. A request for the first kilobyte gets 200 and all 20,125,099 bytes. There is no
Accept-Ranges header on the response either, so a browser that asks this endpoint for a video will
download the whole thing before it shows a frame and will refuse to let the viewer drag the
scrubber, because nothing ever told it that seeking was possible.
The proxy route answers 416 to the request a browser actually makes
ActiveStorage::Blobs::ProxyController#show does handle ranges, on line 29, and hands anything with
a Range header to send_blob_byte_range_data. That method has two limits in it, both added as
security fixes in Rails 8.1.2.1:
return head(:range_not_satisfiable) unless ranges_valid?(ranges)
return head(:range_not_satisfiable) if ranges.length > ActiveStorage.streaming_max_ranges
def ranges_valid?(ranges)
return false if ranges.blank? || ranges.all?(&:blank?)
ranges.sum { |range| range.end - range.begin } < ActiveStorage.streaming_chunk_max_size
end
streaming_chunk_max_size is 100.megabytes at active_storage.rb:357, closing CVE-2026-33174.
streaming_max_ranges is 1 at active_storage.rb:381, closing CVE-2026-33658. Both defaults are
right for the files Active Storage was designed around and both are exactly wrong for video.
The boundary is sharp and it is off by one from what the constant reads like, because the sum is of
range.end - range.begin rather than of range lengths:
test "the largest single range the proxy will serve is exactly 100 MiB" do
assert_equal 104_857_600, ActiveStorage.streaming_chunk_max_size
get rails_storage_proxy_path(@big.file), headers: { "Range" => "bytes=0-104857599" }
assert_equal 206, response.status
assert_equal 104_857_600, response.body.bytesize
get rails_storage_proxy_path(@big.file), headers: { "Range" => "bytes=0-104857600" }
assert_equal 416, response.status
end
Now put that next to the header Chrome sends. An open ended bytes=0- on a file larger than
104,857,600 bytes is a range of the whole file, so it fails ranges_valid?, so the response to the
only request the browser made is 416. Loading the 116,951,566 byte MP4 through
rails_storage_proxy_path in headless Chrome produced one log line and one error:
GET bytes=0- -> 416
<p id="state">ERROR code=4 msg=PipelineStatus::DEMUXER_ERROR_COULD_NOT_OPEN: FFmpegDemuxer: open context failed
ffmpeg's own HTTP client fails on the same URL with a message worth knowing by sight, since it is the one that shows up in a transcoding job rather than a browser:
$ ffprobe -v error -show_entries format=duration,size -of default=nw=1 "http://127.0.0.1:3147/rails/active_storage/blobs/proxy/.../big.mp4"
http://127.0.0.1:3147/rails/active_storage/blobs/proxy/...: Server returned 4XX Client Error, but not one of 40{0,1,3,4}
The 416 also carries no Content-Range: bytes */116951566, which RFC 9110 section 15.5.17 says a
416 ought to include so the client learns the real length and can retry. head :range_not_satisfiable
sets no such header, so a client that would have recovered has nothing to recover from.
The redirect route has no size cap and a five minute fuse
rails_storage_redirect_path is the default resolution for a blob (resolve_model_to_route is
:rails_storage_redirect), and it goes somewhere completely different: a 302 to
ActiveStorage::DiskController#show, which hands the file to Rack::Files through
ActiveStorage::FileServer#serve_file. Rack does its own range handling, with no 100 MiB opinion
and no single-range opinion. The same file that 416s through the proxy plays:
GET bytes=0- -> 302
GET bytes=0- -> 206 bytes 0-116951565/116951566
<p id="state">loadedmetadata duration=5 videoWidth=1280
What that route has instead is an expiry. ActiveStorage.service_urls_expire_in is 5 minutes, and
the disk URL is a signed key with exp baked into it. A viewer who loads a 40 minute video, pauses
for a coffee and then drags the scrubber sends a range request to a URL that stopped existing:
test "a disk service URL stops working five minutes after it was minted" do
assert_equal 5.minutes, ActiveStorage.service_urls_expire_in
get rails_storage_redirect_path(@video.file)
location = response.headers["Location"]
travel 6.minutes do
get location, headers: { "Range" => "bytes=1000-1999" }
assert_equal 404, response.status
end
end
decode_verified_key returns nil on the expired message and DiskController#show answers
head :not_found. Not 410, not a redirect back through the signed permanent URL: a 404 on a file
that is sitting on the disk. Raising config.active_storage.service_urls_expire_in past the longest
video you host is the fix, and its cost is that the URL, which is unauthenticated by construction,
stays valid and shareable for exactly that long.
The multipart body labelled video/mp4
One more difference between the two routes, and it is a bug rather than a trade-off.
Rack::Files answers Range: bytes=0-99,500-599 with a real multipart body, and then
ActiveStorage::FileServer#serve_file overwrites the content type Rack just set:
response.headers["Content-Type"] = content_type || DEFAULT_SEND_FILE_TYPE
test "a multipart byteranges body from the disk controller is labelled video/mp4" do
get rails_storage_redirect_path(@video.file)
get response.headers["Location"], headers: { "Range" => "bytes=0-99,500-599" }
assert_equal "video/mp4", response.headers["Content-Type"]
assert_match(/--AaB03x/, response.body)
end
The body starts \r\n--AaB03x\r\ncontent-type: text/plain\r\ncontent-range: bytes 0-99/20125099
and the response says it is an MP4. AaB03x is hardcoded at rack/files.rb:23. No browser I know
of asks for two ranges of a video, so this has never bitten anyone here, but a download manager does
and it would get 369 bytes of multipart envelope with an MP4 label on it.
Every video Active Storage serves is an attachment
ActiveStorage::Streaming::DEFAULT_BLOB_STREAMING_DISPOSITION is "inline", and it never wins,
because line 48 reads blob.forced_disposition_for_serving || disposition || DEFAULT_....
forced_disposition_for_serving returns :attachment for any content type that is not in
ActiveStorage.content_types_allowed_inline, and that list at engine.rb:65 is nine image types
and application/pdf. There is no video in it.
test "video is served as an attachment because it is not in content_types_allowed_inline" do
refute_includes ActiveStorage.content_types_allowed_inline, "video/mp4"
assert_equal :attachment, @video.file.blob.forced_disposition_for_serving
get rails_storage_proxy_path(@video.file), headers: { "Range" => "bytes=0-1023" }
assert_match(/^attachment/, response.headers["Content-Disposition"])
end
A <video> element does not look at Content-Disposition, so playback is unaffected. Open the same
URL in a tab and the browser saves the file instead of playing it. Adding video/mp4 to
config.active_storage.content_types_allowed_inline changes that, and the reason the default list
is short is that inline means the browser renders whatever you stored, which is why text/html is
on the content_types_to_serve_as_binary list right next to it.
What it costs to send bytes from a Puma thread
The proxy buffers. blob.download_chunk(range) on the disk service is file.seek then
file.read range.size, a String in the process, which send_data then copies into the response.
Puma's RSS around one request for the 111.5 MiB file, with the size cap raised so the request could
succeed at all:
rss_before_kb=90528
rss_after_kb=320368
229,840 KB of resident memory for one viewer of one video. That is the cost the CVE-2026-33174
default is protecting you from, and raising streaming_chunk_max_size buys back playback by giving
that protection up: ten simultaneous viewers of a 100 MiB file is 2 GB.
Throughput is the part I got wrong before measuring. I expected Rack::Files on the disk route to
beat the proxy, since it is the one that streams rather than buffering. Three consecutive runs of
/usr/sbin/ab -n 300 -c 10 -H "Range: bytes=0-1048575" against each, over loopback, against the
production environment on a 3 thread Puma:
proxy run1 Requests per second: 607.90 [#/sec] (mean)
disk run1 Requests per second: 465.21 [#/sec] (mean)
proxy run2 Requests per second: 723.47 [#/sec] (mean)
disk run2 Requests per second: 219.63 [#/sec] (mean)
proxy run3 Requests per second: 535.64 [#/sec] (mean)
disk run3 Requests per second: 219.79 [#/sec] (mean)
The buffering route is consistently the faster one for a 1 MiB range on this machine, which says that per-request speed is not the reason to move video off Rails. This is the reason:
baseline (no load):
/watch 0.010945s
/watch 0.002723s
/watch 0.004758s
starting 3 slow viewers at 200 kB/s...
under load:
/watch 20.004984s
/watch 20.009553s
/watch 20.008825s
Three curl --limit-rate 200k clients pulling the 20 MiB file, and the ordinary HTML page stopped
answering. Those are curl --max-time 20 giving up, not response times. A video byte stream holds
its thread for as long as the viewer's connection takes, so the concurrency limit of the whole
application becomes the number of people watching. Running the same three slow clients against the
disk route instead produced the same result, code=000 at the 15 second limit: Rack::Files is
still inside Puma. Redirect mode is not the fix. Getting the bytes out of the Ruby process is.
The window that did not work
Since the proxy is the route you want when the video is behind a login, my first idea was to keep it
and rewrite the range. An authenticated controller that includes ActiveStorage::Streaming and
clamps any open ended request into a fixed window, so nothing ever hits ranges_valid?:
class StreamsController < ApplicationController
include ActiveStorage::Streaming
WINDOW = 32.megabytes
def show
video = Video.find(params[:id])
blob = video.file.blob
range = request.headers["Range"]
if range.blank?
send_blob_stream blob, disposition: "inline"
else
send_blob_byte_range_data blob, clamp(range, blob.byte_size), disposition: "inline"
end
end
private
def clamp(header, size)
ranges = Rack::Utils.get_byte_ranges(header, size)
return header if ranges.nil? || ranges.length != 1
first = ranges.first
last = [ first.begin + WINDOW - 1, first.end, size - 1 ].min
"bytes=#{first.begin}-#{last}"
end
end
It looks right and it half works. ffprobe reads the file through it correctly, duration 5.000000
and size 116951566, where the stock proxy gave the 4XX error above. Headless Chrome reports
loadedmetadata duration=5 videoWidth=1280 on the file that failed to demux before. Then I decoded
the whole thing through it, ffmpeg -v error -i http://127.0.0.1:3147/stream/3 -f null -, and the
range log says what is actually happening:
GET bytes=0- -> 206 bytes 0-33554431/116951566
GET bytes=33609033- -> 206 bytes 33609033-67163464/116951566
GET bytes=67877825- -> 206 bytes 67877825-101432256/116951566
GET bytes=102149503- -> 206 bytes 102149503-116951565/116951566
Every reopen starts past the end of the window it was given. 54,601 bytes are never requested after the first window, then 714,360, then 717,246, and the decoder says so:
[h264 @ 0x77b061180] Error splitting the input into NAL units.
[in#0/mov,mp4,m4a,3gp,3g2,mj2 @ 0x77ac1c000] Error during demuxing: Input/output error
Dropping the window to 4 MiB does not help, it just produces 25 requests with the same holes, the first of them 591,970 bytes. Truncating a range the client did not ask to have truncated is legal HTTP and it is not transparent to every client, so the clamp went in the bin. I am not claiming Chrome would have the same holes; I could not drive real playback far enough in headless mode to find out, which is its own reason not to ship this.
What I would do
For a video that anyone may watch, do not serve the bytes from Rails. Put the blob on object
storage, keep redirect mode, and set service_urls_expire_in above the length of the longest video
you host so the scrubber does not 404 after a pause. The cost is an unauthenticated URL that stays
live for that window, and you should decide that number knowing it is the whole authorization model.
For a video behind a login, keep proxy mode and raise the cap in an initializer, with a number you have multiplied by your worker count first:
Rails.application.config.active_storage.streaming_chunk_max_size = 512.megabytes
That is the change that made the 116,951,566 byte file play, and the RSS numbers above are its
price: one request, 229,840 KB. Pair it with a WEB_CONCURRENCY you can afford at that multiple and
a separate Puma for media if the same processes serve your HTML, because the 20 second timeouts
above are what three slow viewers do otherwise.
What would change my mind on the first half: a CDN in front of proxy mode would make the public case
work with authorization intact, and it nearly does, except that the proxy sets no-cache on every
206 and max-age=3155695200, public, immutable only on the 200. Every seek by every viewer is
therefore an origin request today. If that changes, proxy mode behind a CDN is the better answer
than redirect mode and I would switch.
What this page does not cover
The S3, GCS and Azure services, which have their own download_chunk and which I could not run
here: every measurement above is the disk service. HLS and DASH, which change the shape of the
problem into many small files and are worth their own page. Transcoding, video_preview_arguments
and poster frames. Safari, which was not tested at all. Live streaming, which shares a keyword with
this and nothing else. And the windowing controller above stops at loadedmetadata as evidence:
I did not get real playback or a real seek out of headless Chrome, so treat that section as a dead
end I am reporting rather than an approach I am recommending against in every client.
Comments
No comments yet. Be the first.