ruby 106 lines · 3 tabs

Streaming Multipart File Uploads to Disk in Sinatra Without Buffering

Shared by codesnips Jul 2026
3 tabs
require 'sinatra/base'
require_relative 'upload_store'

class UploadApp < Sinatra::Base
  MAX_BYTES = 50 * 1024 * 1024

  configure do
    set :store, UploadStore.new(File.expand_path('uploads', __dir__))
  end

  post '/uploads' do
    part = params[:file]
    halt 400, 'missing file field' unless part.is_a?(Hash) && part[:tempfile]

    tempfile = part[:tempfile]
    if tempfile.size > MAX_BYTES
      halt 413, 'file too large'
    end

    begin
      stored = settings.store.store(tempfile, part[:filename], limit: MAX_BYTES)
    rescue UploadStore::TooLarge
      halt 413, 'file too large'
    rescue UploadStore::InvalidName
      halt 422, 'invalid filename'
    end

    status 201
    content_type :json
    { name: stored.name, bytes: stored.bytes }.to_json
  end
end
3 files · ruby Explain with highlit

This snippet shows how to accept multipart file uploads in Sinatra and write them to disk in fixed-size chunks instead of loading the whole body into memory. Buffering large uploads is a common source of RSS spikes and OOM kills under concurrency, so the goal is to keep a bounded amount of data resident at any moment regardless of upload size.

The app.rb tab defines the Sinatra application. When a multipart/form-data request arrives, Rack has already parsed the field into a hash where :tempfile is a Tempfile that Rack spooled the part into. Rather than calling params[:file][:tempfile].read (which materializes the entire file as a String), the route hands the tempfile to UploadStore#store, which copies it in bounded chunks. The route validates the presence of the part, guards against path traversal, and rejects files whose declared size exceeds MAX_BYTES before any writing begins.

The UploadStore tab is the heart of the pattern. Its store method opens the destination with File.open(..., 'wb') and loops with IO.copy_stream-style semantics implemented manually via read(CHUNK) so the enforced byte ceiling can be applied mid-stream. Writing goes to a temporary .part file first and is atomically renamed with File.rename only after the full copy succeeds; a partial upload therefore never appears under its final name, and a failed transfer is cleaned up in an ensure block. safe_name collapses the client-supplied filename to its basename and strips anything but a small allowlist of characters, which is what actually stops ../../etc/passwd style attacks — the controller never trusts the original name.

The spec/upload_spec.rb tab uses Rack::Test to drive the route with Rack::Test::UploadedFile, asserting both the success path and that an oversized upload is rejected with 413. Testing through Rack rather than calling the store directly proves the multipart wiring and the size guard behave together.

The main trade-off is that chunked copying is slightly more code than a one-liner read, but it makes memory usage independent of file size and gives a natural place to enforce limits, checksums, or virus scanning. This approach is worth reaching for whenever uploads can be large or the server runs many workers on constrained memory.


Related snips

Share this code

Here's the card — post it anywhere.

Streaming Multipart File Uploads to Disk in Sinatra Without Buffering — share card
Link copied