Benchmarks

CPU-bound

1000 routes

Configuration:

Note

Requests Per Second: This CPU-bound metric is a relatively small slice of time in comparison to the typical IO-bound tasks of web applications, such as waiting for the database.

Server GET / (req/s) 1 segment (req/s) 2 segments (req/s) 3 segments (req/s) Mean latency (3 segments)
Roda + Falcon 7,948.17 3,038.29 7,006.14 7,220.58 6.925 ms
Raindeer + LowLoop 4,927.88 3,905.35 3,510.91 3,551.81 14.077 ms
Raindeer + Falcon 5,228.65 3,606.68 3,333.89 3,148.49 15.881 ms
Hanami + Falcon 1,845.39 1,906.14 1,960.15 1,956.06 25.562 ms
Sinatra + Falcon 3,921.91 1,163.09 571.56 515.66 96.964 ms
Rails + Falcon 965.99 861.15 821.44 831.45 60.136 ms

*LowLoop is the default server for Raindeer

See: raindeer-benchmarks repo

IO-bound

Routing becomes a much less share of the time when it comes to IO, so we'll just benchmark with many routes.

100 routes

Integrations

These integrations are experimental and don't suport the full Raindeer API such as Interval yet.

Falcon

Iodine

If you really need CPU-bound speed then you can boot Raindeer via Iodine. Compared to LowLoop, this currently increases requests per second by ~10-16% and decreases latency by ~14%. It involves a Rack adapter (for now) and of course, the absence of LowLoop with its Matrix, Interval and Async related features.

Iodine Boot:

require 'iodine'
require_relative 'rack_adapter'

Iodine.threads = 1
Iodine.workers = 1

port = ENV.fetch('PORT', '4133')
address = ENV.fetch('BIND', '127.0.0.1')

Iodine.listen(service: :http, handler: RackAdapter.new, port:, address:)
Iodine.start

Rack Adapter:

require 'bundler/setup'
require 'rack'
require 'raindeer/boot'

Low::Events::RequestEvent.define do |observers|
  observers << Providers['rain.router']
end

class RackAdapter
  HEADER_NAME_CACHE = Hash.new { |cache, key| cache[key] = key.delete_prefix('HTTP_').tr('_', '-').downcase.freeze }

  def call(env)
    http_request = build_request(env)
    response = Low::Events::RequestEvent.take(request: http_request).response
    build_rack_response(response)
  end

  private

  BODY_METHODS = %w[POST PUT PATCH].freeze

  def build_request(env)
    body = env['rack.input']&.read if BODY_METHODS.include?(env['REQUEST_METHOD'])
    body = nil if body.nil? || body.empty?

    headers = Protocol::HTTP::Headers.new(
      env.filter_map do |key, value|
        next unless key.start_with?('HTTP_')

        [HEADER_NAME_CACHE[key], value]
      end
    )

    query = env['QUERY_STRING']
    path = query.nil? || query.empty? ? env['PATH_INFO'] : "#{env['PATH_INFO']}?#{query}"
    authority = env['HTTP_HOST'] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"

    Protocol::HTTP::Request.new(
      env['rack.url_scheme'],
      authority,
      env['REQUEST_METHOD'],
      path,
      'HTTP/1.1',
      headers,
      body
    )
  end

  def build_rack_response(response)
    body_data = response.body.respond_to?(:file) ? File.binread(response.body.file.path) : (response.body.read || '')

    rack_headers = {}
    response.headers.fields.each { |key, value| rack_headers[key.to_s] = value.to_s }
    rack_headers['content-length'] ||= body_data.bytesize.to_s

    [response.status, rack_headers, [body_data]]
  end
end