Performance Comparison: Running Ruby on Rails 7 vs Python (Django) Under Heavy Concurrency Benchmarks
Benchmarking Environment Setup
To conduct a fair performance comparison between Ruby on Rails 7 and Python/Django under heavy concurrency, a consistent and controlled environment is paramount. We’ll utilize a cloud-based virtual machine with specific hardware resources and deploy both applications using production-grade web servers and process managers.
Our chosen environment is an AWS EC2 `m5.xlarge` instance, providing 4 vCPUs and 16 GiB of RAM. This offers a reasonable balance of compute and memory for simulating moderate to heavy load. The operating system is Ubuntu 22.04 LTS.
For serving the Rails application, we’ll use Puma, the default web server in Rails 7, configured with a multi-threaded approach. For Django, we’ll employ Gunicorn, a popular Python WSGI HTTP Server, also configured for multi-worker concurrency.
Database for both benchmarks will be PostgreSQL 14, running on a separate RDS instance to avoid I/O contention on the application server. This isolates application-level performance from database bottlenecks.
Application Under Test: Simple API Endpoint
To focus purely on request handling and processing overhead, we’ll create a minimal API endpoint for each framework. This endpoint will perform a simple database query without complex business logic or heavy computation. The goal is to simulate a common read-heavy API scenario.
Rails 7 Application (Minimal API)
We’ll generate a new Rails 7 application and add a simple `Post` model with a `title` and `body` attribute. The API endpoint will fetch a single post by its ID.
1. Project Generation and Model
rails new rails_benchmark --api cd rails_benchmark rails generate model Post title:string body:text rails db:create db:migrate
2. API Controller
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController << ApplicationController
def show
@post = Post.find(params[:id])
render json: @post, status: :ok
rescue ActiveRecord::RecordNotFound
render json: { error: "Post not found" }, status: :not_found
end
end
end
end
3. API Routes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :posts, only: [:show]
end
end
end
4. Seeding Data
# db/seeds.rb
100.times do |i|
Post.create!(title: "Post #{i}", body: "This is the body of post #{i}.")
end
rails db:seed
Python/Django Application (Minimal API)
Similarly, we’ll create a Django project with a simple `Post` model and an API view.
1. Project and App Generation
django-admin startproject django_benchmark cd django_benchmark python manage.py startapp api
2. Model Definition
# api/models.py
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=255)
body = models.TextField()
def __str__(self):
return self.title
3. Admin Registration (for easy data management)
# api/admin.py from django.contrib import admin from .models import Post admin.site.register(Post)
4. Migrations and Seeding
python manage.py makemigrations api python manage.py migrate
For seeding, we’ll use a custom management command.
# api/management/commands/seed_posts.py
from django.core.management.base import BaseCommand
from api.models import Post
class Command(BaseCommand):
help = 'Seeds the database with sample posts'
def handle(self, *args, **options):
if Post.objects.exists():
self.stdout.write(self.style.WARNING('Posts already exist. Skipping seeding.'))
return
num_posts = 100
for i in range(num_posts):
Post.objects.create(title=f'Post {i}', body=f'This is the body of post {i}.')
self.stdout.write(self.stdout.style.SUCCESS(f'Successfully seeded {num_posts} posts.'))
python manage.py seed_posts
5. API View
# api/views.py
from django.http import JsonResponse, Http404
from .models import Post
def get_post(request, post_id):
try:
post = Post.objects.get(pk=post_id)
return JsonResponse({
'id': post.id,
'title': post.title,
'body': post.body
})
except Post.DoesNotExist:
raise Http404("Post does not exist")
6. URL Configuration
# api/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('posts//', views.get_post, name='get_post'),
]
# django_benchmark/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('api.urls')),
]
Production Deployment Configuration
Proper configuration of the web server and process manager is critical for achieving high concurrency. We’ll tune parameters for both Puma (Rails) and Gunicorn (Django).
Rails 7 with Puma
Puma’s concurrency model is based on threads and workers. For a CPU-bound application, a balance between workers and threads is key. We’ll aim for a configuration that leverages the 4 vCPUs of our instance.
1. Puma Configuration (`config/puma.rb`)
# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY") { 2 }) # Number of worker processes
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS") { 5 }) # Max threads per worker
threads threads_count, threads_count # Min and Max threads per worker
preload_app!
environment ENV.fetch("RAILS_ENV") { "production" }
plugin :tmp_restart
# Allow Puma to be restarted by `rails restart` command.
plugin :restart
on_worker_boot do
ActiveRecord::Base.establish_connection if defined?(ActiveRecord::Base)
end
In this setup, we’re using 2 workers and 5 threads per worker. This gives us a total of 10 concurrent request handlers (2 workers * 5 threads). The `preload_app!` directive helps to load the application code once per worker, reducing startup time for subsequent requests.
2. Environment Variables and Startup Command
export RAILS_ENV=production export WEB_CONCURRENCY=2 export RAILS_MAX_THREADS=5 bundle exec puma -C config/puma.rb
Python/Django with Gunicorn
Gunicorn uses a worker process model. We’ll configure it to use multiple worker processes, each potentially with its own threads (though for simplicity and common practice, we’ll focus on worker processes first).
1. Gunicorn Configuration (Command Line)
gunicorn --workers 4 --threads 2 --bind 0.0.0.0:8000 django_benchmark.wsgi:application
Here, we’re using 4 worker processes. The `–threads 2` option enables multi-threading within each worker. This results in 8 concurrent request handlers (4 workers * 2 threads). The optimal number of workers is often recommended to be `(2 * number_of_cores) + 1`. For our 4 vCPUs, this would suggest around 9 workers. However, we’ll start with 4 workers and 2 threads to keep it comparable to the Rails configuration’s total handler count.
Benchmarking Tool and Methodology
We’ll use `wrk`, a modern HTTP benchmarking tool, known for its high performance and ease of use. It can generate significant load and measure latency and throughput accurately.
1. Installing `wrk`
# On Ubuntu/Debian sudo apt update sudo apt install wrk # Or compile from source for latest version # git clone https://github.com/wg/wrk.git # cd wrk # make # sudo cp wrk /usr/local/bin/
2. Benchmark Script
We’ll run benchmarks with varying numbers of concurrent connections and requests per connection to simulate different load scenarios. The target URL will be a specific post ID, e.g., `http://your_server_ip/api/v1/posts/1` for Rails or `http://your_server_ip/api/posts/1` for Django.
Scenario 1: Moderate Concurrency (100 connections, 10 requests each)
# For Rails wrk -t4 -c100 -d30s -R10 http://your_server_ip/api/v1/posts/1 # For Django wrk -t4 -c100 -d30s -R10 http://your_server_ip/api/posts/1
Scenario 2: High Concurrency (500 connections, 10 requests each)
# For Rails wrk -t4 -c500 -d30s -R10 http://your_server_ip/api/v1/posts/1 # For Django wrk -t4 -c500 -d30s -R10 http://your_server_ip/api/posts/1
Scenario 3: Very High Concurrency (1000 connections, 10 requests each)
# For Rails wrk -t4 -c1000 -d30s -R10 http://your_server_ip/api/v1/posts/1 # For Django wrk -t4 -c1000 -d30s -R10 http://your_server_ip/api/posts/1
We use `-t4` to utilize 4 threads for `wrk` itself, ensuring `wrk` isn’t the bottleneck. The duration `-d30s` is set to 30 seconds, and `-R10` means 10 requests per connection. We’ll record the Requests/sec and Latency (Avg, Max, 99th percentile).
Benchmark Results and Analysis
After running the benchmarks, we’ll analyze the output from `wrk`. The key metrics to compare are:
- Requests/sec (RPS): Higher is better, indicating more requests processed per unit of time.
- Latency (Avg, 99th percentile): Lower is better. High 99th percentile latency indicates that a small fraction of requests are experiencing significant delays, which is critical for user experience.
(Note: Actual benchmark results will vary based on exact environment, OS tuning, and specific application code. The following is a hypothetical representation of typical outcomes.)
Hypothetical Results Summary
Scenario 1: Moderate Concurrency (100 connections)
Rails 7:
Requests/sec: 12,500
Latency (Avg): 7.8 ms
Latency (99th percentile): 25 ms
Django:
Requests/sec: 14,000
Latency (Avg): 7.0 ms
Latency (99th percentile): 22 ms
Scenario 2: High Concurrency (500 connections)
Rails 7:
Requests/sec: 10,000
Latency (Avg): 48 ms
Latency (99th percentile): 150 ms
Django:
Requests/sec: 11,500
Latency (Avg): 42 ms
Latency (99th percentile): 130 ms
Scenario 3: Very High Concurrency (1000 connections)
Rails 7:
Requests/sec: 7,500
Latency (Avg): 120 ms
Latency (99th percentile): 400 ms
Django:
Requests/sec: 9,000
Latency (Avg): 100 ms
Latency (99th percentile): 350 ms
Analysis of Results
In this specific, simplified benchmark scenario:
- Django consistently shows slightly higher throughput (Requests/sec) and lower latency across all concurrency levels. This is often attributable to Python’s C-based extensions and potentially more efficient memory management for I/O-bound tasks in its standard libraries and WSGI servers.
- As concurrency increases, both frameworks experience a degradation in performance, which is expected. However, Django appears to handle the increased load with a slightly gentler performance curve.
- The 99th percentile latency is a critical indicator. While both frameworks show an increase, Django’s values remain marginally better, suggesting a more consistent experience for the majority of users under stress.
It’s crucial to note that these results are for a *very* basic read operation. Real-world applications involve more complex logic, object-relational mapping (ORM) overhead, serialization, and potentially external API calls. The performance characteristics can shift significantly based on these factors.
Further Optimization and Considerations
The configurations used are a starting point. Significant performance gains can be achieved through further tuning:
Rails 7 Optimizations
- Database Connection Pooling: Ensure `pool` size in `config/database.yml` is adequate for the number of threads/workers.
- Caching: Implement fragment caching, page caching, or low-level cache stores (e.g., Redis) for frequently accessed data.
- Background Jobs: Offload non-critical tasks (emailing, image processing) to background job processors like Sidekiq or Delayed Job.
- Puma Tuning: Experiment with different worker/thread counts. Consider using `preload_app!` with `fork_workers` for better memory utilization if applicable.
- JIT Compilation: Ruby 3.x has a JIT compiler. Ensure it’s enabled and consider its impact.
Django Optimizations
- Database Connection Pooling: Use libraries like `django-db-connection-pool` or configure Gunicorn workers to manage connections efficiently.
- Caching: Django’s built-in caching framework (Memcached, Redis) is highly effective.
- Asynchronous Views: For I/O-bound operations, consider using Django’s async views with an ASGI server like Uvicorn.
- Gunicorn Tuning: Experiment with worker types (sync, gevent, eventlet) and worker/thread counts.
- Serialization: Optimize Django REST Framework serializers if used.
General Considerations
- Database Performance: Indexing, query optimization, and database server tuning are critical for both frameworks.
- Network Latency: Ensure the application server and database are geographically close.
- Load Balancer: In a production environment, a load balancer (e.g., Nginx, HAProxy, AWS ELB) is essential for distributing traffic and managing application instances.
- Profiling: Use profiling tools (e.g., `ruby-prof`, `stackprof` for Rails; `cProfile` for Django) to identify bottlenecks within the application code itself.
The choice between Rails and Django often extends beyond raw performance benchmarks. Factors like developer productivity, ecosystem maturity, team expertise, and specific project requirements play a significant role. However, for raw, I/O-bound request handling under heavy concurrency, this benchmark suggests Python/Django may hold a slight edge, though Rails 7 is highly competitive and can be optimized to achieve excellent performance.