Benchmarks

CPU-bound

1 route

100 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.

Framework + Server Req/s Mean latency p50 p90 p99 Failed requests
Raindeer + LowLoop 17134.66 2.918 ms 3 ms 3 ms 4 ms 0
Raindeer + Falcon 14499.04 3.449 ms 3 ms 4 ms 8 ms 0
Roda + Falcon 11469.40 4.359 ms 4 ms 5 ms 5 ms 0
Hanami + Falcon 6619.51 7.553 ms 7 ms 8 ms 9 ms 0
Sinatra + Falcon 5691.24 8.785 ms 9 ms 9 ms 10 ms 0
Rails + Falcon 2709.77 18.452 ms 18 ms 20 ms 22 ms 0

*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. Currently this increases requests per second by ~10,000 and decreases latency by ~1ms. 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