Let’s talk about making your Ruby applications fast. Not just a little faster, but capable of handling thousands more requests without needing bigger servers. Ruby 3.0 and the versions that followed brought real, substantial changes under the hood. I want to show you seven practical ways to use these changes. These are patterns I’ve applied to help systems scale, and they focus on doing more with less—less memory, less waiting, and less complexity.
We often think of pattern matching as a tool for writing cleaner code. In Ruby 3, it becomes a tool for writing faster, more direct code. Before, dissecting a complex data structure required multiple lines of conditional checks and variable assignments. Now, you can declare what you’re looking for right in the control flow.
Look at this common task: processing different types of events from a message queue. The old way involves a chain of if and elsif statements, checking keys and assigning variables. The new way is declarative.
def process_event(event)
case event
in {type: 'order_created', order_id:, customer_id:, amount:}
# The variables order_id, customer_id, and amount are already extracted and ready.
create_order(order_id, customer_id, amount)
in {type: 'payment_received', order_id:, transaction_id:, payment_method: 'credit_card'}
process_credit_card_payment(order_id, transaction_id)
else
log_unknown_event(event)
end
end
See what happened? The structure of the data and the logic for handling it are fused. You bind variables directly from the hash if the pattern matches. This is more than just pretty syntax. It reduces the steps the interpreter needs to take. It’s a single, optimized check instead of a series of independent ones. For a high-volume system processing millions of events, this reduction in overhead adds up. You can match arrays, too, which is excellent for batch operations.
products.each do |product|
case product
in [id, name, price, *categories] if price > 100
# Immediately identify premium products
cache_premium_product(id, categories)
end
end
The beauty is in the clarity and the performance. You write what you mean, and Ruby executes it efficiently.
For years, a significant limit in Ruby was the Global Interpreter Lock (GIL). It meant that even with threads, your code couldn’t truly run two CPU-intensive tasks in parallel on multiple cores. Ruby 3.0 introduced Ractors (short for Ruby Actors) to solve this. Think of a Ractor as an isolated parallel worker. It has its own space and cannot directly share objects with other Ractors, which prevents tricky concurrency bugs.
This is a game-changer for CPU-heavy work. Imagine you need to process a massive array of numbers with a complex calculation.
def process_in_parallel(data, worker_count: 4)
# Split the data into chunks
chunks = data.each_slice((data.size / worker_count.to_f).ceil).to_a
# Create a Ractor for each chunk
ractors = chunks.map.with_index do |chunk, i|
Ractor.new(chunk, name: "worker-#{i}") do |data_chunk|
data_chunk.map { |n| Math.sqrt(n) * Math.log(n + 1) }
end
end
# Collect and combine the results
ractors.flat_map(&:take)
end
# This can truly use multiple CPU cores.
result = process_in_parallel((1..1_000_000).to_a, worker_count: 4)
The key is that the work must be CPU-bound, not I/O-bound. Sending HTTP requests in Ractors isn’t the best use case because they spend most of their time waiting. For that, we have a better pattern. But for calculations, data transformation, or image processing, Ractors let you tap into the full power of your server’s CPUs. Remember, communication between Ractors uses message passing (Ractor.yield, Ractor.take), which keeps things safe and simple.
While Ractors handle CPU work, we still have to deal with I/O. Making HTTP calls, querying databases, reading files—these operations make your program wait. Traditional threading helps, but fiber schedulers in Ruby 3.0 provide a more elegant model. They allow you to write code that looks sequential but performs many I/O operations concurrently.
The async gem is a fantastic implementation of this. It lets you use Async blocks and await keywords.
require 'async'
require 'httparty'
def fetch_multiple_pages(urls)
Async do
urls.map do |url|
Async do
# HTTParty.get will yield the fiber, allowing others to run.
HTTParty.get(url, timeout: 5)
end
end.map(&:wait) # Wait for all the async tasks to finish
end
end
# All URLs are fetched concurrently, not one after the other.
pages = fetch_multiple_pages([
'https://api.example.com/users',
'https://api.example.com/posts'
])
The code is clean. There are no explicit callbacks or promise chains. You write it as if you’re doing things one after the other, but the scheduler switches fibers whenever one hits a blocking I/O operation. This pattern is perfect for microservices that call several external APIs to compose a response. Your application’s throughput can increase dramatically because it’s not sitting idle waiting for a single slow network response.
Managing memory is critical for throughput. More memory used often means more time spent on garbage collection, which pauses your application. Ruby 3.0 continues to improve how strings are handled. The magic comment # frozen_string_literal: true is your best friend. When enabled, every string literal in that file is frozen, meaning it’s immutable and Ruby can safely reuse the same object in memory.
# frozen_string_literal: true
def process_items(items)
items.map do |item|
# Without frozen_string_literal, a new "default" string is created for each item.
{ name: item.name, status: "default" }
end
end
In the code above, with the comment enabled, the string "default" is allocated in memory once and reused for every item in the loop. Without it, a new string object is created for every single item, only to be immediately thrown away for the garbage collector. In a loop over ten thousand items, that’s ten thousand unnecessary objects.
You can also deduplicate strings dynamically with the unary minus operator.
user_input = gets.chomp
deduplicated_string = -user_input # If an identical string exists, Ruby will reuse it.
For hash keys, always use symbols. They are immutable and faster for lookups.
# Good: Symbols are interned and reused.
{ user_id: 1234, action: 'login' }
# Less efficient: String keys create new objects.
{ 'user_id' => 1234, 'action' => 'login' }
Small habits like these reduce the workload on Ruby’s memory manager, leading to smoother performance under load.
Ruby’s Just-In-Time (JIT) compiler, and particularly YJIT in newer versions, can provide substantial speed-ups for hot paths in your code—those methods that are called thousands of times. The JIT compiler translates your Ruby code into machine code at runtime, which the CPU can execute much faster.
You don’t need to write your code differently, but you can help the JIT do its best work. First, ensure it’s enabled in production. For Ruby 3.3+ with YJIT, it’s often as simple as an environment variable.
RUBY_YJIT_ENABLE=1
Second, think about “warming up” your application. The JIT compiles methods after they’ve been called a certain number of times. If your application starts and is immediately hit with high traffic, those first requests will run slower while the JIT catches up. You can create a warm-up routine.
# In config/application.rb or similar, after initialization
if defined?(RubyVM::YJIT) && Rails.env.production?
# Execute critical code paths to trigger JIT compilation
100.times do
User.find_by(email: '[email protected]')
Order.where(status: 'pending').limit(5).to_a
end
end
This runs your common database queries and object instantiation logic, prompting the JIT to compile those methods before real users arrive. Profiling tools like stackprof can help you identify which methods are truly hot and deserve focus. The key is that JIT benefits repetitive, CPU-intensive logic. It won’t magically speed up a single, slow database query, but it will make the Ruby code that processes the query results much faster.
As your application runs for hours or days, memory can become fragmented. Think of it like a library where books are constantly checked out and returned in different sizes. Eventually, you might have plenty of total free space, but no single shelf gap is big enough for a new encyclopedia. Ruby’s garbage collector (GC) can now compact memory, moving objects together to create large, usable free blocks.
For long-running processes like job workers or web servers, periodic compaction can prevent gradual memory bloat.
def compact_if_needed
gc_stats = GC.stat
# A simple heuristic: compact if we have a lot of old generation objects
if gc_stats[:old_objects] > (gc_stats[:total_objects] * 0.6)
GC.compact if GC.respond_to?(:compact)
puts "Memory compacted at #{Time.now}"
end
end
# Call this after processing a large batch of jobs, or on a timer.
compact_if_needed
Compaction is a relatively expensive operation, so you shouldn’t do it on every request. Trigger it based on a timer (e.g., every 1000 requests or every 5 minutes) or when you detect high fragmentation. This pattern helps maintain consistent memory usage and prevents the “gradual creep” that can cause an otherwise healthy application to be restarted.
Finally, we must handle large datasets without loading everything into memory at once. The classic mistake is using User.all.map, which pulls every user record into an array before processing. For large tables, this can crash your process. The solution is laziness and streaming.
Ruby’s Enumerator::Lazy lets you chain operations on a huge or infinite collection without creating intermediate arrays.
# This processes a huge file line by line, never holding it all in memory.
File.foreach('gigantic.log').lazy
.map(&:chomp)
.select { |line| line.include?('ERROR') }
.take(10_000) # Only take the first 10,000 errors
.each { |error_line| send_alert(error_line) }
For generating large responses in a web app, like a CSV export, you should stream the response.
def stream_large_csv
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename="data.csv"'
# Set the response body to an Enumerator
self.response_body = Enumerator.new do |yielder|
yielder << "ID,Name,Email\n" # Write the header
User.find_each(batch_size: 1000) do |user| # Uses batches internally
yielder << "#{user.id},#{user.name},#{user.email}\n"
end
end
end
This find_each method fetches records in batches (default 1000 at a time). The Enumerator yields rows one by one to the HTTP client. Your server’s memory usage stays flat, whether you’re exporting a thousand users or ten million. This pattern is essential for building robust data export features or processing large background jobs.
Each of these patterns addresses a different constraint: CPU, I/O, memory, or data size. They are not silver bullets, but tools. The real art is in knowing which one to apply. Start by measuring. Profile your application to see if it’s CPU-bound, memory-bound, or I/O-bound. Then, apply the patterns that fit your bottleneck. Sometimes, the biggest gain comes from the simplest change, like adding # frozen_string_literal: true to your files or switching from all to find_each. The goal is to let Ruby 3’s advancements work for you, building applications that are not just fast, but consistently and reliably performant under real-world load.