LaunchKit
← All posts
· 13 min read · by The LaunchKit team · 0 views

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.

#rails #active-storage

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.