chunks = orders.each_slice(50_000).to_a
can be quite memory-heavy, especially if orders is already a large Array. each_slice itself is lazy-ish through an Enumerator, but .to_a eagerly materializes all chunks and all their references.
More importantly, if orders came from:
orders = Order.where(...).pluck(:amount)
you’ve already loaded the entire result set into memory before chunking it.
The better approach
Don’t build all chunks. Keep only the amount of data necessary for the workers currently running.
For example, instead of:
orders = Order.where(...).pluck(:amount)
chunks = orders.each_slice(50_000).to_a
ractors = chunks.map do |chunk|
Ractor.new(chunk) do |values|
expensive_calculation(values)
end
end
use a bounded pipeline:
orders = Order.where(...).pluck(:amount)
results = []
orders.each_slice(50_000) do |chunk|
ractor = Ractor.new(chunk) do |values|
expensive_calculation(values)
end
results << ractor.value
end
But notice something important: this doesn’t actually give you parallelism. You’re creating one Ractor and immediately waiting for it.
The interesting solution is bounded concurrency:
MAX_RACTORS = 4
results = []
orders.each_slice(50_000).each_slice(MAX_RACTORS) do |batch|
ractors = batch.map do |chunk|
Ractor.new(chunk) do |values|
expensive_calculation(values)
end
end
results.concat(ractors.map(&:value))
end
Now you’re only holding roughly four chunks plus their Ractors at a time:
Database result │ ▼ 50k chunk 50k chunk 50k chunk 50k chunk │ ▼ ┌─────────────┐ │ 4 Ractors │ └─────────────┘ │ ▼ results │ ▼ next 4 chunks
However, there is an even bigger improvement for Rails.
Don’t pluck everything first
This:
orders = Order.where(...).pluck(:amount)
loads the entire result into memory.
For a million orders:
Database │ │ 1,000,000 amounts ▼Ruby Array │ ▼memory
Instead, stream records in batches:
Order .where(created_at: 30.days.ago..) .in_batches(of: 50_000) do |relation| amounts = relation.pluck(:amount) # process this batchend
Now the architecture becomes:
Database
│
50,000 rows
│
▼
Ruby memory
│
┌─────────┼─────────┐
▼ ▼ ▼
Ractor Ractor Ractor
│ │ │
└─────────┼─────────┘
▼
result
│
▼
next 50k rows
This is much healthier because the database itself becomes your natural source of batching.
And you can make the Ractor pool bounded:
BATCH_SIZE = 10_000
Order.where(created_at: 30.days.ago..)
.in_batches(of: BATCH_SIZE) do |relation|
ractors = []
values = relation.pluck(:amount)
ractors << Ractor.new do |values|
values.sum { |value| value ** 3 }
end
end
ractors.each(&:value)
One more important point
For our Ractor/Rails article, change the example we used earlier.
This:
orders = Order.where(...).pluck(:amount)chunks = orders.each_slice(50_000).to_a
teaches the wrong optimization pattern because it creates a potentially enormous in-memory dataset before parallelization.
A senior-level Rails example should demonstrate:
Database batching ↓bounded number of chunks ↓bounded number of Ractors ↓aggregate results ↓release memory ↓next database batch
That gives you bounded memory + bounded parallelism, which is a much better production architecture.
Also, there’s an important subtlety with Ractors: passing data between Ractors can itself have copying/memory costs, so “smaller batches” aren’t automatically better. The correct batch size needs to be benchmarked against CPU cost, memory pressure and Ractor communication overhead.