
Ruby Optimise
- 227 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ruby-optimise: A skill for development. This provides functionality for development workflows.
Key points
- ruby-optimise
Ruby Optimise by the numbers
- 227 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,745 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ruby-optimiseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 227 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ruby-optimise for development tasks?
Use ruby-optimise for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ruby-optimise.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ruby-optimise for development tasks, or when ruby-optimise: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ruby-optimise: ruby-optimise.
Files
Community Ruby Best Practices
Comprehensive performance optimization guide for Ruby applications, maintained by the community. Contains 42 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new Ruby code or gems
- Optimizing ActiveRecord queries and database access patterns
- Processing large collections or building data pipelines
- Reviewing code for memory bloat and GC pressure
- Configuring Ruby runtime settings for production
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Object Allocation | CRITICAL | alloc- |
| 2 | Collection & Enumeration | CRITICAL | enum- |
| 3 | I/O & Database | HIGH | io- |
| 4 | String Handling | HIGH | str- |
| 5 | Method & Dispatch | MEDIUM-HIGH | meth- |
| 6 | Data Structures | MEDIUM | ds- |
| 7 | Concurrency | MEDIUM | conc- |
| 8 | Runtime & Configuration | LOW-MEDIUM | runtime- |
Quick Reference
1. Object Allocation (CRITICAL)
- `alloc-avoid-unnecessary-dup` - Avoid Unnecessary Object Duplication
- `alloc-freeze-constants` - Freeze Constant Collections
- `alloc-lazy-initialization` - Use Lazy Initialization for Expensive Objects
- `alloc-avoid-temp-arrays` - Avoid Temporary Array Creation
- `alloc-reuse-buffers` - Reuse Buffers in Loops
- `alloc-avoid-implicit-conversions` - Avoid Repeated Computation in Hot Paths
2. Collection & Enumeration (CRITICAL)
- `enum-single-pass` - Use Single-Pass Collection Transforms
- `enum-lazy-large-collections` - Use Lazy Enumerators for Large Collections
- `enum-flat-map` - Use flat_map Instead of map.flatten
- `enum-each-with-object` - Use each_with_object Over inject for Building Collections
- `enum-avoid-count-in-loops` - Avoid Recomputing Collection Size in Conditions
- `enum-chunk-batch-processing` - Use each_slice for Batch Processing
3. I/O & Database (HIGH)
- `io-eager-load-associations` - Eager Load ActiveRecord Associations
- `io-select-only-needed-columns` - Select Only Needed Columns
- `io-batch-find-each` - Use find_each for Large Record Sets
- `io-avoid-queries-in-loops` - Avoid Database Queries Inside Loops
- `io-stream-large-files` - Stream Large Files Line by Line
- `io-connection-pool-sizing` - Size Connection Pools to Match Thread Count
- `io-cache-expensive-queries` - Cache Expensive Database Results
4. String Handling (HIGH)
- `str-frozen-literals` - Enable Frozen String Literals
- `str-shovel-over-plus` - Use Shovel Operator for String Building
- `str-interpolation-over-concatenation` - Use String Interpolation Over Concatenation
- `str-avoid-repeated-gsub` - Chain gsub Calls into a Single Replacement
- `str-symbol-for-identifiers` - Use Symbols for Identifiers and Hash Keys
5. Method & Dispatch (MEDIUM-HIGH)
- `meth-avoid-method-missing-hot-paths` - Avoid method_missing in Hot Paths
- `meth-cache-method-references` - Cache Method References for Repeated Calls
- `meth-block-vs-proc` - Pass Blocks Directly Instead of Converting to Proc
- `meth-avoid-dynamic-send` - Avoid Dynamic send in Performance-Critical Code
- `meth-reduce-method-chain-depth` - Reduce Method Chain Depth in Hot Loops
6. Data Structures (MEDIUM)
- `ds-set-for-membership` - Use Set for Membership Tests
- `ds-struct-over-openstruct` - Use Struct Over OpenStruct
- `ds-sort-by-over-sort` - Use sort_by Instead of sort with Block
- `ds-array-preallocation` - Preallocate Arrays When Size Is Known
- `ds-hash-default-value` - Use Hash Default Values Instead of Conditional Assignment
7. Concurrency (MEDIUM)
- `conc-fiber-for-io` - Use Fibers for I/O-Bound Concurrency
- `conc-thread-pool-sizing` - Size Thread Pools to Match Workload
- `conc-ractor-cpu-bound` - Use Ractors for CPU-Bound Parallelism
- `conc-avoid-shared-mutable-state` - Avoid Shared Mutable State Between Threads
8. Runtime & Configuration (LOW-MEDIUM)
- `runtime-enable-yjit` - Enable YJIT for Production
- `runtime-tune-gc-parameters` - Tune GC Parameters for Your Workload
- `runtime-frozen-string-literal-default` - Set Frozen String Literal as Project Default
- `runtime-optimize-require` - Optimize Require Load Order
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Ruby
Version 0.1.0 Community February 2026
Note: This document is for agents and LLMs to follow when writing, reviewing, or refactoring ruby codebases.
Humans may also find it useful, but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive performance optimization guide for Ruby applications, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact from critical (object allocation, collection enumeration) to incremental (runtime configuration). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Object Allocation — CRITICAL
- 1.1 Avoid Repeated Computation in Hot Paths — MEDIUM-HIGH (eliminates redundant allocations from repeated to_s, to_a, Time.now)
- 1.2 Avoid Temporary Array Creation — HIGH (eliminates N intermediate allocations per iteration)
- 1.3 Avoid Unnecessary Object Duplication — CRITICAL (eliminates redundant allocations in hot paths)
- 1.4 Freeze Constant Collections — CRITICAL (prevents repeated allocation of identical objects)
- 1.5 Reuse Buffers in Loops — MEDIUM-HIGH (reduces allocations from O(n) to O(1))
- 1.6 Use Lazy Initialization for Expensive Objects — HIGH (defers allocation until needed, reduces startup overhead)
2. Collection & Enumeration — CRITICAL
- 2.1 Avoid Recomputing Collection Size in Conditions — MEDIUM (O(n) to O(1) per check for non-Array enumerables)
- 2.2 Use each_slice for Batch Processing — MEDIUM (O(batch) memory vs O(n) for full dataset)
- 2.3 Use each_with_object Over inject for Building Collections — MEDIUM (eliminates common accumulator-return bugs and improves readability)
- 2.4 Use flat_map Instead of map.flatten — HIGH (eliminates intermediate nested array allocation)
- 2.5 Use Lazy Enumerators for Large Collections — CRITICAL (processes elements on demand, avoids loading entire collection)
- 2.6 Use Single-Pass Collection Transforms — CRITICAL (eliminates N intermediate arrays from chained methods)
3. I/O & Database — HIGH
- 3.1 Avoid Database Queries Inside Loops — HIGH (reduces N queries to 1 bulk query)
- 3.2 Cache Expensive Database Results — MEDIUM (eliminates repeated identical queries across requests)
- 3.3 Eager Load ActiveRecord Associations — HIGH (eliminates N+1 queries, reduces from 2N+1 to 3 queries)
- 3.4 Select Only Needed Columns — HIGH (reduces memory allocation and query transfer time by 50-90%)
- 3.5 Size Connection Pools to Match Thread Count — MEDIUM (prevents connection checkout timeouts under load)
- 3.6 Stream Large Files Line by Line — MEDIUM-HIGH (O(1) memory vs O(n) — saves GBs on large files)
- 3.7 Use find_each for Large Record Sets — HIGH (O(1000) memory vs O(n) for entire table)
4. String Handling — HIGH
- 4.1 Chain gsub Calls into a Single Replacement — MEDIUM (reduces N string allocations and regex scans to 1)
- 4.2 Enable Frozen String Literals — HIGH (reduces GC pressure by ~20%, saves ~100MB in production Rails apps)
- 4.3 Use Shovel Operator for String Building — HIGH (reduces N string allocations to 0 in loops)
- 4.4 Use String Interpolation Over Concatenation — MEDIUM-HIGH (single allocation vs N intermediate strings)
- 4.5 Use Symbols for Identifiers and Hash Keys — MEDIUM (1.3-2x faster hash lookups, single allocation per symbol)
5. Method & Dispatch — MEDIUM-HIGH
- 5.1 Avoid Dynamic send in Performance-Critical Code — MEDIUM (send bypasses visibility checks and prevents YJIT optimization)
- 5.2 Avoid method_missing in Hot Paths — MEDIUM-HIGH (method_missing is 2-10x slower than direct dispatch)
- 5.3 Cache Method References for Repeated Calls — MEDIUM-HIGH (avoids repeated method lookup and Proc allocation overhead)
- 5.4 Pass Blocks Directly Instead of Converting to Proc — MEDIUM (avoids Proc allocation on each call)
- 5.5 Reduce Method Chain Depth in Hot Loops — MEDIUM (reduces N × depth dispatch calls to N × 1)
6. Data Structures — MEDIUM
- 6.1 Preallocate Arrays When Size Is Known — LOW-MEDIUM (avoids repeated resizing and memory copies)
- 6.2 Use Hash Default Values Instead of Conditional Assignment — LOW-MEDIUM (eliminates conditional branches and simplifies accumulation)
- 6.3 Use Set for Membership Tests — MEDIUM (O(1) lookup vs O(n) with Array#include?)
- 6.4 Use sort_by Instead of sort with Block — MEDIUM (2-5x faster for large collections via Schwartzian transform)
- 6.5 Use Struct Over OpenStruct — MEDIUM (Struct is 10-50x faster to instantiate than OpenStruct)
7. Concurrency — MEDIUM
- 7.1 Avoid Shared Mutable State Between Threads — MEDIUM (prevents race conditions and eliminates mutex contention overhead)
- 7.2 Size Thread Pools to Match Workload — MEDIUM (prevents GVL contention and resource exhaustion)
- 7.3 Use Fibers for I/O-Bound Concurrency — MEDIUM (reduces memory 250x per concurrent task (~4KB vs ~1MB))
- 7.4 Use Ractors for CPU-Bound Parallelism — MEDIUM (2-8x throughput improvement on multi-core for CPU-bound work)
8. Runtime & Configuration — LOW-MEDIUM
- 8.1 Enable YJIT for Production — MEDIUM (15-25% latency reduction with zero code changes)
- 8.2 Optimize Require Load Order — LOW (reduces boot time by deferring heavy gem loading)
- 8.3 Set Frozen String Literal as Project Default — LOW-MEDIUM (reduces string allocations across entire codebase)
- 8.4 Tune GC Parameters for Your Workload — LOW-MEDIUM (reduces GC pause frequency by 30-50% for known allocation patterns)
---
References
1. https://docs.ruby-lang.org 2. https://github.com/rubocop/ruby-style-guide 3. https://ruby-style-guide.shopify.dev 4. https://shopify.engineering/ruby-yjit-is-production-ready 5. https://railsatscale.com/2025-01-10-yjit-3-4-even-faster-and-more-memory-efficient/ 6. https://www.datadoghq.com/blog/ruby-performance-optimization/ 7. https://guides.rubyonrails.org/active_record_querying.html 8. https://blog.appsignal.com/2021/11/17/practical-garbage-collection-tuning-in-ruby.html 9. https://pragprog.com/titles/adrpo/ruby-performance-optimization/ 10. https://github.com/fastruby/fast-ruby
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on performance implications and cascade effects.
Incorrect (description of the problem/cost):
# Comment on problematic line explaining consequence
bad_example_code_hereCorrect (description of the benefit/solution):
good_example_code_hereReference: Reference Title
{
"version": "1.0.5",
"organization": "Community",
"technology": "Ruby",
"date": "February 2026",
"abstract": "Comprehensive performance optimization guide for Ruby applications, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact from critical (object allocation, collection enumeration) to incremental (runtime configuration). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://docs.ruby-lang.org",
"https://github.com/rubocop/ruby-style-guide",
"https://ruby-style-guide.shopify.dev",
"https://shopify.engineering/ruby-yjit-is-production-ready",
"https://railsatscale.com/2025-01-10-yjit-3-4-even-faster-and-more-memory-efficient/",
"https://www.datadoghq.com/blog/ruby-performance-optimization/",
"https://guides.rubyonrails.org/active_record_querying.html",
"https://blog.appsignal.com/2021/11/17/practical-garbage-collection-tuning-in-ruby.html",
"https://pragprog.com/titles/adrpo/ruby-performance-optimization/",
"https://github.com/fastruby/fast-ruby"
]
}
Ruby Optimise Best Practices
Performance optimization guidelines for Ruby applications. Contains 42 rules across 8 categories for writing efficient Ruby code.
Overview/Structure
ruby-optimise/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version, organization, references
├── README.md # This file
├── references/
│ ├── _sections.md # Category definitions
│ ├── alloc-*.md # Object allocation rules
│ ├── enum-*.md # Collection & enumeration rules
│ ├── io-*.md # I/O & database rules
│ ├── str-*.md # String handling rules
│ ├── meth-*.md # Method & dispatch rules
│ ├── ds-*.md # Data structure rules
│ ├── conc-*.md # Concurrency rules
│ └── runtime-*.md # Runtime & configuration rules
└── assets/
└── templates/
└── _template.md # Rule template for extensionsGetting Started
Installation
# Clone or copy this skill to your project
cp -r ruby-optimise/ .claude/skills/ruby-optimise/
# Install dependencies (if using validation scripts)
pnpm installBuild
# Build AGENTS.md from individual rules
pnpm build
# Or directly:
node scripts/build-agents-md.js .claude/skills/ruby-optimiseValidate
# Validate skill structure and content
pnpm validate
# Or directly:
node scripts/validate-skill.js .claude/skills/ruby-optimiseCreating a New Rule
1. Choose the appropriate category based on performance impact 2. Create a new file in references/ following the naming convention 3. Use the template structure for consistency 4. Run validation to ensure compliance
Prefix Reference
| Category | Prefix | Impact |
|---|---|---|
| Object Allocation | alloc- | CRITICAL |
| Collection & Enumeration | enum- | CRITICAL |
| I/O & Database | io- | HIGH |
| String Handling | str- | HIGH |
| Method & Dispatch | meth- | MEDIUM-HIGH |
| Data Structures | ds- | MEDIUM |
| Concurrency | conc- | MEDIUM |
| Runtime & Configuration | runtime- | LOW-MEDIUM |
Rule File Structure
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10x improvement")
tags: prefix, technique, related-concepts
---
## Rule Title Here
Brief explanation (1-3 sentences) of why this matters.
**Incorrect (description of what's wrong):**
\`\`\`ruby
# Bad example with comments explaining cost
\`\`\`
**Correct (description of what's right):**
\`\`\`ruby
# Good example with comments explaining benefit
\`\`\`
Reference: [Source](url)File Naming Convention
Files follow the pattern: {prefix}-{description}.md
prefix: Category identifier (3-8 chars)description: Kebab-case description of the rule
Examples:
alloc-freeze-constants.mdio-eager-load-associations.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Multiplicative impact, affects entire program execution |
| HIGH | Significant per-operation improvement |
| MEDIUM-HIGH | Notable improvement in specific scenarios |
| MEDIUM | Measurable improvement on hot paths |
| LOW-MEDIUM | Small but consistent improvement |
| LOW | Micro-optimization for tight loops |
Scripts
| Script | Description |
|---|---|
build-agents-md.js | Compiles all rules into AGENTS.md |
validate-skill.js | Validates structure and content |
Contributing
1. Read existing rules to understand the style 2. Create your rule using the template 3. Run validation before submitting 4. Ensure all code examples are syntactically correct 5. Include authoritative references
Acknowledgments
This skill synthesizes best practices from:
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Object Allocation (alloc)
Impact: CRITICAL Description: GC accounts for 80% of Ruby slowdowns. Every unnecessary allocation increases GC pressure, pause times, and memory footprint. Reducing allocations is the single highest-leverage optimization.
2. Collection & Enumeration (enum)
Impact: CRITICAL Description: Chained Enumerable methods create N intermediate arrays per stage. Single-pass transforms and lazy evaluation eliminate multiplicative allocations in data pipelines.
3. I/O & Database (io)
Impact: HIGH Description: Database queries and network I/O dominate wall-clock time. N+1 queries multiply latency by record count. Eager loading and batch processing eliminate the most common bottlenecks.
4. String Handling (str)
Impact: HIGH Description: Strings are Ruby's most-allocated object type. Frozen strings reduce GC pressure by ~20% and memory by ~100MB in production Rails applications.
5. Method & Dispatch (meth)
Impact: MEDIUM-HIGH Description: Method lookup, metaprogramming overhead, and dynamic dispatch patterns affect hot-path throughput. Avoiding unnecessary indirection keeps method calls fast.
6. Data Structures (ds)
Impact: MEDIUM Description: Hash, Array, and Set choice determines lookup complexity. Symbol keys are 1.3-2x faster than string keys on large hashes. Struct outperforms OpenStruct by 10-50x.
7. Concurrency (conc)
Impact: MEDIUM Description: The GVL limits true parallelism. Choosing fibers for I/O, threads for blocking operations, and Ractors for CPU-bound work determines throughput under load.
8. Runtime & Configuration (runtime)
Impact: LOW-MEDIUM Description: YJIT delivers 15-25% latency improvement out of the box. GC tuning and frozen_string_literal defaults reduce baseline overhead with minimal code changes.
Avoid Repeated Computation in Hot Paths
Expressions like Time.now.to_s, Integer#to_s, and group.members.to_a allocate new objects on every invocation. Inside tight loops, these repeated computations accumulate thousands of throwaway objects. Hoist invariant conversions outside the loop and pass raw values to helpers that format once.
Incorrect (repeated conversions inside loop):
class InventoryReport
def generate(products)
rows = []
products.each do |product|
rows << "#{product.sku}: #{product.quantity} units @ #{product.price}"
log_entry = {
sku: product.sku.to_s, # Allocates new string if sku is a Symbol
quantity: product.quantity.to_s, # Integer#to_s allocates every call
timestamp: Time.now.to_s # New Time + new String per iteration
}
audit_log(log_entry)
end
rows
end
endCorrect (hoist invariant conversions, pass raw values):
class InventoryReport
def generate(products)
rows = []
timestamp = Time.now.to_s # Compute once — same timestamp for the batch
products.each do |product|
sku = product.sku
qty = product.quantity
price = product.price
rows << "#{sku}: #{qty} units @ #{price}"
audit_log(sku, qty, timestamp) # Pass raw values, let the logger format once
end
rows
end
private
def audit_log(sku, quantity, timestamp)
@logger.write(sku, quantity, timestamp)
end
endPre-convert collections when shape is known:
# Incorrect -- to_a inside loop re-creates array each time
user_groups.each do |group|
members = group.members.to_a # New array per group even if already an Array
process_members(members)
end
# Correct -- only convert if needed
user_groups.each do |group|
members = group.members
members = members.to_a unless members.is_a?(Array)
process_members(members)
endAvoid Temporary Array Creation
Splat operators (*args) and array-wrapping patterns silently allocate intermediate arrays on every call. In hot paths, this creates thousands of throwaway objects that pressure the GC. Pass arguments directly or use Array() only when the input type genuinely varies.
Incorrect (splat creates a temporary array per call):
class NotificationService
def notify_all(users, message)
users.each do |user|
send_notification(*build_params(user, message)) # Allocates throwaway array per user
end
end
private
def build_params(user, message)
[user.email, message.subject, message.body] # Array allocated and immediately unpacked
end
endCorrect (pass arguments directly):
class NotificationService
def notify_all(users, message)
users.each do |user|
send_notification(user.email, message.subject, message.body)
end
end
endAnother common pattern -- unnecessary array construction via splat:
Incorrect (splat collects into throwaway array):
def process_line_items(order)
items = *order.line_items # Splat always allocates a new array, even if input is already an array
items.each do |item|
update_inventory(item)
end
endCorrect (iterate directly):
def process_line_items(order)
order.line_items.each do |item| # No intermediate allocation
update_inventory(item)
end
endAvoid Unnecessary Object Duplication
Calling .dup or .clone inside loops creates a new heap object per iteration, multiplying GC pressure linearly with the collection size. Freeze shared objects once and reference them directly, or restructure the logic to avoid duplication entirely.
Incorrect (allocates a new object per iteration):
class OrderExporter
HEADER_TEMPLATE = ["Order ID", "Customer", "Total", "Status"]
def export(orders)
rows = []
orders.each do |order|
header = HEADER_TEMPLATE.dup # Allocates a new array every iteration
rows << header
rows << [order.id, order.customer_name, order.total, order.status]
end
rows
end
endCorrect (zero per-iteration allocations):
class OrderExporter
HEADER_TEMPLATE = ["Order ID", "Customer", "Total", "Status"].freeze
def export(orders)
rows = []
orders.each do |order|
rows << HEADER_TEMPLATE
rows << [order.id, order.customer_name, order.total, order.status]
end
rows
end
endWhen `.dup` IS appropriate:
- When the caller will mutate the returned object
- When building a modified copy from a template (but do it outside the loop)
- When passing data across thread boundaries that requires isolation
Freeze Constant Collections
Ruby re-evaluates array and hash literals assigned to constants each time they are referenced in certain contexts. Without .freeze, accidental mutation can corrupt shared state, and the interpreter cannot optimize access. Freezing enables the VM to reuse the same object safely.
Incorrect (mutable constants, risk of corruption and extra allocations):
class ProductCatalog
ALLOWED_CATEGORIES = ["electronics", "clothing", "home", "garden"]
DEFAULT_FILTERS = { in_stock: true, min_rating: 3.0 }
SORT_OPTIONS = [:price_asc, :price_desc, :newest, :rating]
def filter_products(products, category:)
unless ALLOWED_CATEGORIES.include?(category)
raise ArgumentError, "invalid category"
end
filters = DEFAULT_FILTERS # Shares the mutable reference
filters[:category] = category # Mutates the constant for all callers
apply_filters(products, filters)
end
endCorrect (frozen constants, immutable and safe):
class ProductCatalog
ALLOWED_CATEGORIES = ["electronics", "clothing", "home", "garden"].freeze
DEFAULT_FILTERS = { in_stock: true, min_rating: 3.0 }.freeze
SORT_OPTIONS = [:price_asc, :price_desc, :newest, :rating].freeze
def filter_products(products, category:)
unless ALLOWED_CATEGORIES.include?(category)
raise ArgumentError, "invalid category"
end
filters = DEFAULT_FILTERS.merge(category: category) # Returns a new hash
apply_filters(products, filters)
end
endDeep freeze nested structures:
SHIPPING_RATES = {
domestic: { standard: 5.99, express: 12.99 }.freeze,
international: { standard: 19.99, express: 39.99 }.freeze
}.freezeUse Lazy Initialization for Expensive Objects
Eagerly building expensive objects in initialize forces allocation even when those objects are never accessed. Lazy initialization with ||= defers the cost until first use, keeping object construction fast and memory footprint low for unused code paths.
Incorrect (allocates everything upfront):
class OrderProcessor
def initialize(config)
@config = config
@validator = OrderValidator.new(config.rules) # Allocated even if unused
@tax_calculator = TaxCalculator.new(config.region) # Expensive API lookup on init
@shipping_estimator = ShippingEstimator.new(
carriers: config.carriers,
warehouse: config.warehouse # Opens connection immediately
)
@audit_logger = AuditLogger.new(config.log_path) # File handle opened on init
end
def validate(order)
@validator.check(order)
end
endCorrect (allocates only when first accessed):
class OrderProcessor
def initialize(config)
@config = config
end
def validate(order)
validator.check(order)
end
private
def validator
@validator ||= OrderValidator.new(@config.rules)
end
def tax_calculator
@tax_calculator ||= TaxCalculator.new(@config.region)
end
def shipping_estimator
@shipping_estimator ||= ShippingEstimator.new(
carriers: @config.carriers,
warehouse: @config.warehouse
)
end
def audit_logger
@audit_logger ||= AuditLogger.new(@config.log_path)
end
endWhen to prefer eager initialization:
- When the object is always used in every code path
- When initialization failure should surface immediately at construction time
- When thread safety requires controlled initialization order
Reuse Buffers in Loops
Creating a new String or Array inside a loop allocates a fresh object per iteration. Declaring the buffer once outside the loop and clearing it with .clear or .replace reuses the same memory, dropping allocations from O(n) to O(1).
Incorrect (allocates a new string per iteration):
class CsvExporter
def generate(orders)
output = +""
orders.each do |order|
line = +"" # New string allocated every iteration
line << order.id.to_s
line << ","
line << order.customer_name
line << ","
line << format("%.2f", order.total)
line << "\n"
output << line
end
output
end
endCorrect (reuses a single buffer):
class CsvExporter
def generate(orders)
output = +""
line = +"" # Single allocation, reused across iterations
orders.each do |order|
line.clear # Resets length to 0, keeps allocated memory
line << order.id.to_s
line << ","
line << order.customer_name
line << ","
line << format("%.2f", order.total)
line << "\n"
output << line
end
output
end
endSame pattern with arrays:
# Incorrect -- allocates per batch
batches.each do |batch|
ids = [] # New array per batch
batch.each { |record| ids << record.id }
process_ids(ids)
end
# Correct -- reuses buffer
ids = []
batches.each do |batch|
ids.clear # Resets without deallocating
batch.each { |record| ids << record.id }
process_ids(ids)
endAvoid Shared Mutable State Between Threads
Sharing mutable data between threads requires Mutex locks that serialize access, destroying concurrency benefits. Thread-local accumulators merged at the end eliminate contention while preserving correctness.
Incorrect (shared counter with Mutex creates serialized bottleneck):
class InventoryAuditor
def count_items_by_category(products)
totals = Hash.new(0)
mutex = Mutex.new
workers = products.each_slice(100).map do |batch|
Thread.new do
batch.each do |product|
mutex.synchronize do # Every increment waits for the lock
totals[product.category] += 1
end
end
end
end
workers.each(&:join)
totals # Threads spent more time waiting than working
end
endCorrect (thread-local accumulators merged at end):
class InventoryAuditor
def count_items_by_category(products)
workers = products.each_slice(100).map do |batch|
Thread.new do
local_totals = Hash.new(0) # No sharing, no locks needed
batch.each do |product|
local_totals[product.category] += 1
end
local_totals
end
end
workers
.map(&:value)
.each_with_object(Hash.new(0)) do |local, merged|
local.each { |category, count| merged[category] += count }
end
end
endUse Fibers for I/O-Bound Concurrency
Fibers provide cooperative concurrency with minimal memory overhead, making them ideal for I/O-bound workloads like HTTP requests, database queries, and file operations. Spawning OS threads for each concurrent I/O task wastes memory and hits OS limits quickly.
Incorrect (one thread per HTTP request exhausts resources):
require "net/http"
def fetch_product_prices(product_urls)
threads = product_urls.map do |url|
Thread.new do # ~1MB stack per thread, OS limit ~1024 threads
uri = URI(url)
response = Net::HTTP.get_response(uri)
JSON.parse(response.body)
end
end
threads.map(&:value) # Blocks until all complete
rescue ThreadError => e
# "can't create Thread: Resource temporarily unavailable"
Rails.logger.error("Thread pool exhausted: #{e.message}")
[]
endCorrect (fibers handle thousands of concurrent I/O operations):
require "async"
require "async/http/internet"
def fetch_product_prices(product_urls)
Async do
internet = Async::HTTP::Internet.new
barrier = Async::Barrier.new
results = product_urls.map do |url|
barrier.async do # ~4KB per fiber, scales to thousands
response = internet.get(url)
JSON.parse(response.read)
end
end
barrier.wait
results.map(&:wait)
ensure
internet&.close
end
endUse Ractors for CPU-Bound Parallelism
Ruby threads share the GVL, so CPU-bound work runs sequentially regardless of core count. Ractors provide isolated execution contexts that bypass the GVL, enabling true parallel computation across all available cores. Because Ractors are fully isolated, all computation logic must be self-contained inside the Ractor block.
Incorrect (threads serialize CPU work due to GVL):
class ProductRecommendationEngine
def compute_scores(user_profiles)
threads = user_profiles.map do |profile|
Thread.new do
# GVL forces sequential execution despite multiple threads
profile.preferences.combination(2).sum do |a, b|
cosine_similarity(a, b)
end
end
end
threads.map(&:value) # Runs at ~1 core speed regardless of thread count
end
endCorrect (Ractors achieve true parallelism across cores):
class ProductRecommendationEngine
def compute_scores(user_profiles)
ractors = user_profiles.map do |profile|
prefs = Ractor.make_shareable(profile.preferences.dup)
Ractor.new(prefs) do |preferences|
# Self-contained computation — Ractor blocks cannot access outer scope
preferences.combination(2).sum do |a, b|
a.zip(b).sum { |x, y| x * y } /
(Math.sqrt(a.sum { |v| v**2 }) * Math.sqrt(b.sum { |v| v**2 }))
end
end
end
ractors.map(&:take) # Scales linearly with core count
end
endWhen NOT to use this pattern:
- Most gems and C extensions are not Ractor-safe — test thoroughly before adopting
- Ractors cannot share mutable state; all data passed in must be deeply frozen or copied via
Ractor.make_shareable - For I/O-bound concurrency, use Fibers or Threads instead — Ractors add unnecessary isolation overhead
- Ractors remain experimental in Ruby 3.x and are stabilizing in Ruby 4.0; verify compatibility with your Ruby version and gem dependencies before production use
Size Thread Pools to Match Workload
Unbounded thread creation causes memory bloat and excessive GVL contention. A fixed-size thread pool with a work queue keeps resource usage predictable and throughput stable under load.
Incorrect (unbounded threads cause resource exhaustion):
class OrderExportService
def export_all(orders)
threads = orders.map do |order|
Thread.new { generate_pdf(order) } # 10,000 orders = 10,000 threads
end
threads.each do |t|
t.join # GVL thrashing kills throughput
end
end
private
def generate_pdf(order)
# CPU-bound PDF generation competing for GVL
PdfGenerator.new(order).render
end
endCorrect (fixed pool with queue prevents resource exhaustion):
class OrderExportService
POOL_SIZE = Integer(ENV.fetch("EXPORT_THREADS", 5))
def export_all(orders)
queue = Queue.new
orders.each { |order| queue << order }
POOL_SIZE.times { queue << :done }
workers = POOL_SIZE.times.map do
Thread.new do
while (order = queue.pop) != :done
generate_pdf(order)
end
end
end
workers.each(&:join) # Bounded concurrency, predictable memory
end
private
def generate_pdf(order)
PdfGenerator.new(order).render
end
endPreallocate Arrays When Size Is Known
When the result size is known upfront, using Array.new(n) with a block allocates the correct capacity in a single step. Building an array with << in a loop triggers multiple resize-and-copy cycles as the internal buffer grows (typically doubling at 0, 4, 8, 16, ...).
Incorrect (repeated resizing as array grows):
def compute_monthly_totals(transactions, month_count)
totals = []
month_count.times do |i|
month_transactions = transactions.select { |t| t.month_index == i }
totals << month_transactions.sum(&:amount) # Resizes at capacity boundaries
end
totals
endCorrect (single allocation with exact size):
def compute_monthly_totals(transactions, month_count)
Array.new(month_count) do |i|
month_transactions = transactions.select { |t| t.month_index == i }
month_transactions.sum(&:amount) # No resizing needed
end
endAlso applies to map/collect:
# Already optimal — map preallocates based on receiver size
totals = transactions.map(&:amount)When preallocation matters most:
- Large arrays (1000+ elements)
- Latency-sensitive code paths
- Memory-constrained environments
Use Hash Default Values Instead of Conditional Assignment
Manual nil-checking with ||= or ternary operators before accumulating into a hash adds branching and visual noise. Hash.new(default) and Hash.new { |h, k| h[k] = default } handle missing keys automatically, producing cleaner code that eliminates an entire class of nil-related bugs.
Incorrect (manual nil guard on every access):
def count_orders_by_status(orders)
counts = {}
orders.each do |order|
counts[order.status] = (counts[order.status] || 0) + 1 # Nil check on every iteration
end
counts
end
def group_products_by_category(products)
grouped = {}
products.each do |product|
grouped[product.category] ||= [] # Nil check before append
grouped[product.category] << product
end
grouped
endCorrect (default values handle missing keys automatically):
def count_orders_by_status(orders)
counts = Hash.new(0)
orders.each do |order|
counts[order.status] += 1 # Returns 0 for missing keys
end
counts
end
def group_products_by_category(products)
grouped = Hash.new { |h, k| h[k] = [] }
products.each do |product|
grouped[product.category] << product # Auto-creates array for new keys
end
grouped
endImportant: Use Hash.new(0) for immutable defaults (integers, symbols). Use the block form Hash.new { |h, k| h[k] = [] } for mutable defaults to avoid sharing the same object across all keys.
Use Set for Membership Tests
Array#include? scans elements linearly, making each lookup O(n). Set#include? uses a hash table internally, providing O(1) average-case lookups. For any collection checked repeatedly, the constant-time lookup dominates as size grows.
Incorrect (linear scan on every check):
ALLOWED_STATUSES = ["active", "pending", "trialing"].freeze
def filter_eligible_users(users)
users.select do |user|
ALLOWED_STATUSES.include?(user.status) # O(n) scan per user
end
endCorrect (constant-time hash lookup):
require "set"
ALLOWED_STATUSES = Set["active", "pending", "trialing"].freeze
def filter_eligible_users(users)
users.select do |user|
ALLOWED_STATUSES.include?(user.status) # O(1) lookup per user
end
endWhen to prefer Array:
- Very small collections (< 5 elements) where linear scan is faster than hashing
- Ordered iteration is required
- Elements are not hashable
Use sort_by Instead of sort with Block
sort with a comparison block calls the block O(n log n) times, recomputing the sort key on every comparison. sort_by computes each key exactly once (Schwartzian transform), then sorts by the cached values. For collections where the key extraction is non-trivial (attribute access, string operations, method calls), sort_by is 2-5x faster.
Incorrect (key recomputed on every comparison):
products = catalog.sort { |a, b|
a.name.downcase <=> b.name.downcase # downcase called O(n log n) times
}
orders = user.orders.sort { |a, b|
a.created_at <=> b.created_at # Method dispatch on every comparison
}Correct (key computed once per element):
products = catalog.sort_by { |product|
product.name.downcase # downcase called exactly N times, then cached
}
orders = user.orders.sort_by(&:created_at) # Single pass for key extractionFor descending order:
# Numeric keys — negate
products.sort_by { |p| -p.price }
# Non-numeric keys — reverse after sort
products.sort_by { |p| p.name.downcase }.reverseUse Struct Over OpenStruct
OpenStruct dynamically defines methods via method_missing and define_method on each new key, making instantiation 10-50x slower than Struct. Struct predefines its accessors at class creation time, resulting in fixed-layout objects the VM can optimize.
Incorrect (dynamic method definition on each instantiation):
def parse_api_response(raw_data)
raw_data.map do |entry|
OpenStruct.new( # Dynamically defines methods per key
name: entry["name"],
email: entry["email"],
role: entry["role"],
created_at: Time.parse(entry["created_at"])
)
end
endCorrect (fixed layout, precompiled accessors):
UserRecord = Struct.new(:name, :email, :role, :created_at, keyword_init: true)
def parse_api_response(raw_data)
raw_data.map do |entry|
UserRecord.new(
name: entry["name"],
email: entry["email"],
role: entry["role"],
created_at: Time.parse(entry["created_at"])
)
end
endAlternative (Data class in Ruby 3.2+):
UserRecord = Data.define(:name, :email, :role, :created_at)
# Data objects are immutable by design
record = UserRecord.new(name: "Jane", email: "jane@example.com", role: "admin", created_at: Time.now)Avoid Recomputing Collection Size in Conditions
Using .count > 0 or .length > 0 to check for presence forces a full traversal on enumerables that lack a cached size (e.g., ActiveRecord relations, lazy enumerators, custom collections). .any? short-circuits on the first match, and .empty? avoids computing the total count.
Incorrect (full traversal to check presence):
if order.line_items.count > 0 # executes SELECT COUNT(*) on every call
apply_discount(order)
end
pending = users.select(&:pending?)
if pending.count == 0 # already an array, but reads less clearly
notify_admin("No pending users")
end
while unprocessed_jobs.count > 0 # O(n) recount on every loop iteration
process(unprocessed_jobs.shift)
endCorrect (short-circuit presence checks):
if order.line_items.any? # SELECT 1 ... LIMIT 1, stops immediately
apply_discount(order)
end
pending = users.select(&:pending?)
if pending.empty?
notify_admin("No pending users")
end
until unprocessed_jobs.empty? # O(1) check per iteration
process(unprocessed_jobs.shift)
endUse each_slice for Batch Processing
Loading an entire dataset into memory before processing risks exhausting available RAM on large tables. each_slice breaks the collection into fixed-size batches, keeping only one batch in memory at a time and allowing the garbage collector to reclaim previous batches between iterations.
Incorrect (loads all records then processes):
users = User.where(subscribed: true).to_a # loads entire result set into memory
users.each do |user|
NotificationMailer.weekly_digest(user).deliver_later
end
products = Product.all.to_a # millions of rows materialized at once
products.each do |product|
SearchIndex.update(product) # memory grows unbounded during processing
endCorrect (processes in fixed-size batches):
User.where(subscribed: true).find_each(batch_size: 1000) do |user|
NotificationMailer.weekly_digest(user).deliver_later
end
Product.all.find_in_batches(batch_size: 1000) do |batch|
SearchIndex.bulk_update(batch) # only 1000 records in memory at a time
end
# For non-ActiveRecord enumerables, use each_slice
large_csv_rows.each_slice(500) do |batch|
ImportService.process(batch)
endUse each_with_object Over inject for Building Collections
When building a hash or array with inject, each iteration must explicitly return the accumulator. Forgetting to return it (e.g., using merge instead of merge!, or omitting the hash at the end) silently produces wrong results. each_with_object passes the same mutable object throughout, eliminating this class of bugs and producing cleaner code.
Incorrect (must remember to return accumulator on every iteration):
products_by_sku = catalog.inject({}) do |hash, product|
hash[product.sku] = product
hash # Easy to forget — omitting this returns the Product, breaking the accumulator
end
order_totals = line_items.inject({}) do |totals, item|
totals[item.order_id] = totals.fetch(item.order_id, 0) + item.price
totals # Must return totals on every branch
endCorrect (mutates single object in place, no return needed):
products_by_sku = catalog.each_with_object({}) do |product, hash|
hash[product.sku] = product # Accumulator is always the same object
end
order_totals = line_items.each_with_object(Hash.new(0)) do |item, totals|
totals[item.order_id] += item.price
endUse flat_map Instead of map.flatten
Calling .map { ... }.flatten first builds a full nested array, then allocates a second flattened copy. flat_map yields directly into a single output array, cutting allocations in half and avoiding the extra traversal.
Incorrect (intermediate nested array):
all_line_items = orders
.map { |order| order.line_items } # builds array of arrays
.flatten # traverses again to flatten into new array
tag_names = products
.map { |product| product.categories.map(&:name) } # nested array of arrays of strings
.flattenCorrect (single flattened pass):
all_line_items = orders
.flat_map { |order| order.line_items } # yields directly into one array
tag_names = products
.flat_map { |product| product.categories.map(&:name) }Use Lazy Enumerators for Large Collections
Eager enumeration materializes every intermediate array in full before moving to the next stage. When you only need a subset of results from a large collection, .lazy builds a pipeline that processes one element at a time and stops as soon as the final condition is satisfied.
Incorrect (processes all elements eagerly):
recent_premium = transactions
.map { |txn| enrich_with_metadata(txn) } # builds full array of enriched records
.select { |txn| txn.amount > 500 } # builds second full array
.first(10) # discards all but 10 after processing everything
log_entries = File.readlines("/var/log/app.log") # loads entire file into memory
.map { |line| JSON.parse(line) } # parses every single line
.select { |entry| entry["level"] == "error" }
.first(25)Correct (lazy pipeline, processes on demand):
recent_premium = transactions
.lazy
.map { |txn| enrich_with_metadata(txn) }
.select { |txn| txn.amount > 500 }
.first(10) # stops after finding 10 matches
log_entries = File.foreach("/var/log/app.log") # streams line by line
.lazy
.map { |line| JSON.parse(line) }
.select { |entry| entry["level"] == "error" }
.first(25)Use Single-Pass Collection Transforms
Chained .select.map creates a temporary array after each stage. For a collection of N elements, this allocates two full-size arrays and iterates twice. Single-pass alternatives like filter_map or each_with_object traverse once and allocate only the final result.
Incorrect (multiple intermediate arrays):
active_emails = users
.select { |user| user.confirmed? && user.active? } # allocates intermediate array of active users
.map(&:email) # allocates second array of emails
discounted_totals = orders
.select { |order| order.coupon_applied? }
.map { |order| order.total * 0.85 } # two passes, two throwaway arraysCorrect (single-pass transform):
active_emails = users.filter_map { |user|
user.email if user.confirmed? && user.active? # one pass, one allocation
}
discounted_totals = orders.each_with_object([]) { |order, totals|
totals << order.total * 0.85 if order.coupon_applied?
}Avoid Database Queries Inside Loops
Issuing a find or where call inside a loop sends a separate SQL query per iteration. For 200 IDs, that is 200 round trips to the database. Load all records in a single bulk query and index them for O(1) lookup.
Incorrect (one query per iteration):
class OrderFulfillmentService
def fulfill(order_ids)
order_ids.each do |id|
order = Order.find(id) # SELECT * FROM orders WHERE id = ? (per id)
product = Product.find(order.product_id) # SELECT * FROM products WHERE id = ? (per order)
ship(order, product)
end
end
endCorrect (two bulk queries, then in-memory lookup):
class OrderFulfillmentService
def fulfill(order_ids)
orders = Order.where(id: order_ids).index_by(&:id)
product_ids = orders.values.map(&:product_id).uniq
products = Product.where(id: product_ids).index_by(&:id)
order_ids.each do |id|
order = orders[id]
product = products[order.product_id]
ship(order, product)
end
end
endUse find_each for Large Record Sets
User.all.each loads every record into memory before iteration begins. For tables with millions of rows, this can exhaust available RAM and crash the process. find_each fetches records in batches of 1000 (configurable), keeping memory usage constant regardless of table size.
Incorrect (loads entire table into memory at once):
class AccountCleanupJob
def perform
User.where("last_login_at < ?", 2.years.ago).each do |user| # loads all matching rows into memory
user.anonymize_personal_data!
user.update!(status: :archived)
end
end
endCorrect (processes in batches with constant memory):
class AccountCleanupJob
def perform
User.where("last_login_at < ?", 2.years.ago).find_each(batch_size: 500) do |user|
user.anonymize_personal_data!
user.update!(status: :archived)
end
end
endCache Expensive Database Results
Expensive aggregate queries or complex joins that produce the same result across multiple requests waste database resources when executed repeatedly. Use Rails.cache.fetch with a time-based expiry to serve cached results and only hit the database when the cache expires.
Incorrect (runs expensive query on every request):
class ProductCatalogController < ApplicationController
def index
@categories = Category.all
.joins(:products)
.select("categories.*, COUNT(products.id) AS product_count")
.group("categories.id")
.order("product_count DESC") # complex join + aggregation on every page load
@featured = Product.where(featured: true)
.includes(:reviews)
.order(average_rating: :desc)
.limit(12) # repeated on every request despite rarely changing
end
endCorrect (caches results with appropriate expiry):
class ProductCatalogController < ApplicationController
def index
@categories = Rails.cache.fetch("catalog:categories_with_counts", expires_in: 15.minutes) do
Category.all
.joins(:products)
.select("categories.*, COUNT(products.id) AS product_count")
.group("categories.id")
.order("product_count DESC")
.to_a # materialize to Array so it is serializable
end
@featured = Rails.cache.fetch("catalog:featured_products", expires_in: 1.hour) do
Product.where(featured: true)
.includes(:reviews)
.order(average_rating: :desc)
.limit(12)
.to_a
end
end
endSize Connection Pools to Match Thread Count
When the database connection pool is smaller than the number of threads or workers competing for connections, threads block waiting for a checkout and eventually raise ActiveRecord::ConnectionTimeoutError. Set the pool size to at least match the maximum thread count of your application server.
Incorrect (pool smaller than thread count, causes timeouts):
# config/database.yml
production:
adapter: postgresql
database: storefront_production
pool: 5 # default, but Puma runs 5 threads per worker = contention under load# config/puma.rb
workers 2
threads 5, 5 # 5 threads per worker, 10 total — only 5 connections available per processCorrect (pool sized to thread count):
# config/database.yml
production:
adapter: postgresql
database: storefront_production
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %># config/puma.rb
max_threads = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
workers ENV.fetch("WEB_CONCURRENCY") { 2 }.to_i
threads max_threads, max_threads # pool size matches thread countEager Load ActiveRecord Associations
Accessing associations inside a loop without eager loading fires a separate SQL query per record. For 100 orders with comments, this means 101 queries instead of 2. Use includes to load all associated records in a single additional query.
Incorrect (fires a query per iteration):
class OrderSummaryService
def generate(user)
orders = user.orders.where(status: :completed)
orders.map do |order|
{
id: order.id,
total: order.total,
comments: order.comments.map(&:body), # SELECT * FROM comments WHERE order_id = ? (per order)
items_count: order.line_items.size # SELECT COUNT(*) FROM line_items WHERE order_id = ? (per order)
}
end
end
endCorrect (three queries total regardless of record count):
class OrderSummaryService
def generate(user)
orders = user.orders
.where(status: :completed)
.includes(:comments, :line_items)
orders.map do |order|
{
id: order.id,
total: order.total,
comments: order.comments.map(&:body),
items_count: order.line_items.size
}
end
end
endSelect Only Needed Columns
Loading full ActiveRecord objects when you only need one or two columns wastes memory on attribute storage, type casting, and object overhead. Use .select for partial models or .pluck when you only need raw values without ActiveRecord instances.
Incorrect (loads every column into full ActiveRecord objects):
class NewsletterService
def subscriber_emails
users = User.where(subscribed: true) # SELECT * FROM users — loads all columns
users.map(&:email) # instantiates a full User object per row
end
def active_user_ids
User.where(active: true).map(&:id) # loads all columns just to extract ids
end
endCorrect (loads only the columns needed):
class NewsletterService
def subscriber_emails
User.where(subscribed: true).pluck(:email) # SELECT email FROM users — returns plain strings
end
def active_user_ids
User.where(active: true).ids # SELECT id FROM users — optimized id-only query
end
endStream Large Files Line by Line
File.read loads the entire file contents into a single string in memory. For a 2 GB CSV, that means 2 GB of RAM consumed before processing begins. File.foreach streams one line at a time, keeping memory usage constant regardless of file size.
Incorrect (loads entire file into memory):
class ProductImporter
def import(path)
lines = File.read(path).split("\n") # entire file loaded into one string, then split into array
lines.drop(1).each do |line|
columns = line.split(",")
Product.create!(
sku: columns[0],
name: columns[1],
price: BigDecimal(columns[2])
)
end
end
endCorrect (streams line by line with constant memory):
class ProductImporter
def import(path)
first_line = true
File.foreach(path, chomp: true) do |line|
if first_line
first_line = false
next
end
columns = line.split(",")
Product.create!(
sku: columns[0],
name: columns[1],
price: BigDecimal(columns[2])
)
end
end
endAvoid Dynamic send in Performance-Critical Code
send and public_send resolve method names at runtime, which bypasses the inline method cache and prevents YJIT from compiling an optimized dispatch. In tight loops this means each call pays the full lookup cost instead of hitting a cached path.
Incorrect (dynamic dispatch defeats inline caching):
class OrderExporter
def to_csv(order)
order.values.join(",")
end
def to_json(order)
order.to_h.to_json
end
def export_all(orders, format)
method_name = "to_#{format}"
orders.map do |order|
send(method_name, order) # Runtime lookup on every iteration
end
end
endCorrect (static dispatch, YJIT-optimizable):
class OrderExporter
def to_csv(order)
order.values.join(",")
end
def to_json(order)
order.to_h.to_json
end
def export_all(orders, format)
case format
when :csv
orders.map { |order| to_csv(order) } # Direct dispatch, cacheable
when :json
orders.map { |order| to_json(order) } # Direct dispatch, cacheable
else
raise ArgumentError, "unsupported format: #{format}"
end
end
endWhen `send` is acceptable:
- Metaprogramming frameworks (ORMs, serializers) where dynamism is the point
- One-off calls outside hot paths
- Test helpers accessing private methods
Avoid method_missing in Hot Paths
Ruby's method_missing bypasses the method lookup cache and triggers a full method resolution on every call. In hot paths this overhead compounds quickly, making it 2-10x slower than a direct method call. Generating real methods with define_method gives the VM a concrete dispatch target it can cache and optimize.
Incorrect (full method resolution on every access):
class UserProfile
def initialize(attrs)
@attrs = attrs
end
def method_missing(name, *args)
if @attrs.key?(name)
@attrs[name] # Triggers full method lookup chain every time
else
super
end
end
def respond_to_missing?(name, include_private = false)
@attrs.key?(name) || super # Must also be overridden for consistency
end
end
# In a request loop — method_missing fires on each iteration
users.each do |user|
profile = UserProfile.new(user)
profile.email # No cached dispatch, 2-10x slower per call
endCorrect (generates real methods the VM can cache):
class UserProfile
ATTRIBUTES = %i[email name role department].freeze
def initialize(attrs)
@attrs = attrs
end
ATTRIBUTES.each do |attr|
define_method(attr) do
@attrs[attr]
end
end
end
# In a request loop — direct dispatch, fully cacheable
users.each do |user|
profile = UserProfile.new(user)
profile.email # Real method, normal dispatch speed
endPass Blocks Directly Instead of Converting to Proc
Using &method(:name) creates a new Proc object on every invocation, which adds allocation pressure in tight loops. Passing a block literal avoids the intermediate Proc allocation entirely, keeping the call stack simpler for the VM to optimize.
Incorrect (new Proc allocated per call site):
class ProductCatalog
def initialize(products)
@products = products
end
def format_name(product)
product.name.strip.downcase
end
def normalized_names
@products.map(&method(:format_name)) # Allocates a new Proc each time
end
def export_names
@products.select(&method(:active?)).map(&method(:format_name)) # Two Proc allocations
end
endCorrect (block literals, no intermediate Proc):
class ProductCatalog
def initialize(products)
@products = products
end
def format_name(product)
product.name.strip.downcase
end
def normalized_names
@products.map { |product| format_name(product) }
end
def export_names
@products.select { |product| active?(product) }.map { |product| format_name(product) }
end
endException: Using &:symbol for simple method calls on the receiver (e.g., names.map(&:downcase)) is idiomatic and optimized by most Ruby implementations. The overhead concern applies specifically to &method(:name).
Cache Method References for Repeated Calls
Each call to obj.method(:name) allocates a new Method object and performs a method lookup. When passing the same method reference to map, select, or callbacks inside a loop, capture it once before iteration to eliminate repeated lookups and allocations.
Incorrect (new Method object allocated on every iteration):
class OrderProcessor
def format(order)
"#{order.id}: #{order.total}"
end
end
processor = OrderProcessor.new
batches.each do |batch|
batch.map(&processor.method(:format)) # New Method + Proc allocated per batch
endCorrect (single lookup, reused reference):
class OrderProcessor
def format(order)
"#{order.id}: #{order.total}"
end
end
processor = OrderProcessor.new
formatter = processor.method(:format) # One lookup, one allocation
batches.each do |batch|
batch.map(&formatter) # Reuses cached reference
endReduce Method Chain Depth in Hot Loops
Deep method chains like order.customer.address.city perform multiple dispatches per access. Inside a loop, this overhead multiplies by the iteration count. Caching the terminal value in a local variable before the loop eliminates redundant traversals.
Incorrect (repeated chain traversal on every iteration):
def shipping_labels(orders)
labels = []
orders.each do |order|
labels << {
recipient: order.customer.full_name, # 2 dispatches per access
street: order.customer.address.street, # 3 dispatches per access
city: order.customer.address.city, # 3 dispatches per access
postal_code: order.customer.address.postal_code # 3 dispatches per access
}
end
labels
endCorrect (cache intermediate objects before accessing fields):
def shipping_labels(orders)
orders.map do |order|
customer = order.customer
address = customer.address # Single traversal to address
{
recipient: customer.full_name,
street: address.street,
city: address.city,
postal_code: address.postal_code
}
end
endEnable YJIT for Production
YJIT is Ruby's built-in JIT compiler that compiles frequently-executed methods to native code at runtime. It ships with Ruby 3.1+ and is production-ready since Ruby 3.2, delivering significant latency improvements for web workloads with negligible memory cost.
Incorrect (default interpreter without JIT compilation):
# Procfile or deployment config
# Ruby runs in interpreter-only mode by default
web: bundle exec puma -C config/puma.rb
# No JIT compilation, every method call goes through
# the interpreter on every invocation
# Hot paths like serialization and routing pay full
# interpreter overhead on each requestCorrect (YJIT enabled for native code compilation):
# Option 1: Environment variable (recommended for containers)
# Dockerfile or .env
# RUBY_YJIT_ENABLE=1
# Option 2: Command-line flag
# Procfile
web: bundle exec ruby --yjit -S puma -C config/puma.rb
# config/initializers/yjit.rb
if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
Rails.logger.info(
"YJIT enabled: #{RubyVM::YJIT.runtime_stats[:compiled_iseq_count]} methods compiled"
)
endSet Frozen String Literal as Project Default
Every unadorned string literal in Ruby allocates a new object. Freezing string literals by default eliminates these redundant allocations project-wide, reducing GC pressure. Relying on per-file pragma comments is error-prone and inconsistent. See Enable Frozen String Literals for per-file details and the +"" escape hatch.
Incorrect (relying on per-file pragma comments is inconsistent):
# Some files have the pragma, most don't
# app/services/order_service.rb
# frozen_string_literal: true (easy to forget in new files)
class OrderService
STATUS_PENDING = "pending" # Frozen only if pragma present
def status_label(order)
"Order ##{order.id}: #{order.status}" # New allocation every call
end
end
# app/models/product.rb
# (no pragma — developer forgot)
class Product
DEFAULT_CURRENCY = "USD" # New object allocated every reference
endCorrect (enforce frozen strings project-wide):
# .rubocop.yml — enforce the pragma on every file
Style/FrozenStringLiteralComment:
Enabled: true
EnforcedStyle: always
# Alternatively, set via Ruby flag in Procfile or Dockerfile
# ruby --enable-frozen-string-literal app.rb
# Or RUBYOPT="--enable-frozen-string-literal"
# app/services/order_service.rb
# frozen_string_literal: true
class OrderService
STATUS_PENDING = "pending" # Shared frozen instance
def status_label(order)
+"Order ##{order.id}: #{order.status}" # Unary + for mutable when needed
end
endOptimize Require Load Order
Loading every gem at boot increases startup time and memory usage, even when most gems are only needed for specific code paths. Deferring heavy dependencies with require: false and autoload keeps boot fast and memory lean.
Incorrect (all gems loaded eagerly at boot):
# Gemfile — every gem loads at startup
gem "rails"
gem "pg"
gem "sidekiq"
gem "prawn" # PDF generation, 15MB+ memory, rarely used
gem "rmagick" # Image processing, loads C extensions at boot
gem "elasticsearch" # Only needed by search controller
gem "grover" # HTML-to-PDF, loads Puppeteer at require time
# Boot time: ~8 seconds, RSS: ~350MB
# Every web worker pays the cost even if it never generates a PDFCorrect (defer heavy gems until first use):
# Gemfile — defer gems not needed on every request
gem "rails"
gem "pg"
gem "sidekiq"
gem "prawn", require: false # Loaded only when generating PDFs
gem "rmagick", require: false # Loaded only for image processing
gem "elasticsearch", require: false # Loaded only by search module
gem "grover", require: false
# app/services/invoice_pdf_service.rb
class InvoicePdfService
def generate(order)
require "prawn" # First call pays ~200ms, subsequent calls are no-ops
Prawn::Document.new do |pdf|
pdf.text "Invoice ##{order.invoice_number}"
pdf.text "Total: #{order.formatted_total}"
end.render
end
endTune GC Parameters for Your Workload
Ruby's default GC settings are conservative, optimized for small scripts. Web applications with predictable allocation patterns benefit from pre-allocating heap slots and reducing growth frequency, cutting GC pauses that add latency to every request.
Incorrect (default GC settings cause frequent pauses under load):
# No GC configuration — defaults apply
# Ruby starts with a small heap and grows incrementally
# Each request triggers multiple GC cycles as the heap
# expands to fit the application's actual memory needs
# Result: 50-100ms p99 spikes from major GC during traffic
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY", 2)
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
threads threads_count, threads_countCorrect (tuned GC reduces pause frequency for web workloads):
# config/environments/production.rb or container ENV
# Pre-allocate heap slots per size pool (Ruby 3.3+)
ENV["RUBY_GC_HEAP_0_INIT_SLOTS"] ||= "600000"
ENV["RUBY_GC_HEAP_1_INIT_SLOTS"] ||= "100000"
ENV["RUBY_GC_HEAP_2_INIT_SLOTS"] ||= "50000"
# Grow heap conservatively to avoid over-allocation
ENV["RUBY_GC_HEAP_GROWTH_FACTOR"] ||= "1.1"
# Allow more allocations between GC runs
ENV["RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO"] ||= "0.20"
ENV["RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO"] ||= "0.40"
# Raise threshold before triggering major GC
ENV["RUBY_GC_MALLOC_LIMIT"] ||= "64000000"
ENV["RUBY_GC_OLDMALLOC_LIMIT"] ||= "64000000"
# Verify settings at boot
Rails.logger.info("GC stats: #{GC.stat.slice(:heap_available_slots, :major_gc_count)}")When NOT to use this pattern:
- The per-size-pool variables (
RUBY_GC_HEAP_0_INIT_SLOTS, etc.) require Ruby 3.3+; for Ruby 3.2 and earlier, use the legacyRUBY_GC_HEAP_INIT_SLOTS - Profile with
GC.statunder realistic load before choosing values — wrong parameters can increase memory without reducing pauses
Reference: Practical Garbage Collection Tuning in Ruby (AppSignal)
Chain gsub Calls into a Single Replacement
Each .gsub call scans the entire string and allocates a new copy with the substitutions applied. Chaining N calls means N full scans and N intermediate strings. A single .gsub with a Regexp union and a replacement hash performs one scan and one allocation, doing the same work in a fraction of the time.
Incorrect (each gsub scans and allocates a new string):
def sanitize_user_input(raw_input)
result = raw_input
.gsub("&", "&") # scan 1, allocation 1
.gsub("<", "<") # scan 2, allocation 2
.gsub(">", ">") # scan 3, allocation 3
.gsub('"', """) # scan 4, allocation 4
.gsub("'", "'") # scan 5, allocation 5 — 5 full passes over the string
result
end
def normalize_product_slug(name)
name
.gsub(/\s+/, "-") # first pass: whitespace to hyphens
.gsub(/[^\w-]/, "") # second pass: strip non-word chars
.gsub(/--+/, "-") # third pass: collapse double hyphens
.downcase
endCorrect (single scan with hash replacement or combined regex):
HTML_ESCAPE = { "&" => "&", "<" => "<", ">" => ">",
'"' => """, "'" => "'" }.freeze
HTML_ESCAPE_PATTERN = Regexp.union(HTML_ESCAPE.keys).freeze
def sanitize_user_input(raw_input)
raw_input.gsub(HTML_ESCAPE_PATTERN, HTML_ESCAPE) # one scan, one allocation
end
def normalize_product_slug(name)
name.downcase.gsub(/[^\w-]+/, "-") # one pass: lowercase then replace all non-word sequences
.delete_prefix("-").delete_suffix("-")
endEnable Frozen String Literals
Every string literal in Ruby allocates a new mutable object by default. In a request-heavy Rails app, this produces millions of short-lived strings that flood the garbage collector. The frozen_string_literal pragma makes every literal in the file frozen and deduplicated at compile time, eliminating those allocations entirely.
Incorrect (new string allocated on every call):
class OrderMailer
def confirmation_subject(order)
prefix = "Order Confirmation" # new String allocated each invocation
separator = " - " # another allocation
prefix + separator + order.reference # yet another for the concatenation result
end
def format_status(order)
status = "pending" # new "pending" every time, even though it never changes
order.status == status ? "awaiting" : order.status
end
endCorrect (literals frozen and deduplicated at compile time):
# frozen_string_literal: true
class OrderMailer
def confirmation_subject(order)
prefix = "Order Confirmation"
separator = " - "
"#{prefix}#{separator}#{order.reference}"
end
def format_status(order)
status = "pending"
order.status == status ? "awaiting" : order.status
end
endSee also: Set Frozen String Literal as Project Default for enforcing this pragma across an entire codebase.
When you need a mutable string in a frozen file:
# frozen_string_literal: true
def build_csv_row(product)
row = +"" # unary + creates a mutable copy
row << product.name
row << ","
row << product.price.to_s
row
endUse String Interpolation Over Concatenation
Each + between strings allocates and copies an intermediate result. With four fragments you get three throwaway strings before the final one. Interpolation compiles to a single String#new that sizes the buffer once and fills it in order, producing exactly one object regardless of how many expressions are embedded.
Incorrect (intermediate string per concatenation):
def order_summary(user, order)
greeting = "Hello, " + user.name + "! " # 2 intermediate strings
details = "Your order #" + order.reference + " for " +
order.total.to_s + " was placed on " +
order.placed_at.strftime("%B %d, %Y") + "." # 4 intermediate strings
greeting + details # 1 more to join them
end
def product_url(product)
"/products/" + product.category.slug + "/" + product.slug # 2 throwaway strings
endCorrect (single allocation per string):
def order_summary(user, order)
greeting = "Hello, #{user.name}! "
details = "Your order ##{order.reference} for #{order.total}" \
" was placed on #{order.placed_at.strftime("%B %d, %Y")}."
"#{greeting}#{details}"
end
def product_url(product)
"/products/#{product.category.slug}/#{product.slug}"
endUse Shovel Operator for String Building
The + operator creates a new String object for every concatenation, copying both operands into fresh memory. In a loop that processes thousands of records, this means thousands of throwaway allocations. The shovel operator (<<) appends directly to the receiver's buffer, growing it in place with amortized O(1) cost.
Incorrect (new string allocated on each iteration):
def export_products_csv(products)
result = "id,name,price,stock\n"
products.each do |product|
result = result + product.id.to_s # allocates new string, copies entire buffer
result = result + "," # another allocation + copy of everything so far
result = result + product.name
result = result + ","
result = result + product.price.to_s
result = result + ","
result = result + product.stock.to_s
result = result + "\n" # 8 allocations per product
end
result
endCorrect (mutates in place, single buffer grows as needed):
def export_products_csv(products)
result = String.new("id,name,price,stock\n")
products.each do |product|
result << product.id.to_s
result << ","
result << product.name
result << ","
result << product.price.to_s
result << ","
result << product.stock.to_s
result << "\n" # zero intermediate allocations
end
result
endUse Symbols for Identifiers and Hash Keys
String keys are full objects: each lookup computes a hash from every byte of the key, and every literal occurrence may allocate a new String. Symbols are interned and immutable, so the VM allocates each one exactly once and compares them by object ID rather than content. For hashes that are accessed on every request, the difference compounds into measurable throughput gains.
Incorrect (string keys compared by content each time):
def process_order(params)
user_id = params["user_id"] # hashes every byte of "user_id" on each lookup
product = params["product_id"]
quantity = params["quantity"]
coupon = params["coupon_code"] # 4 string hashes per call
order = {
"status" => "pending", # new String allocated for each key
"total" => calculate_total(product, quantity, coupon),
"created_at" => Time.now,
"user_id" => user_id
}
order["status"] = "confirmed" # another byte-by-byte hash to find the key
order
endCorrect (symbol keys compared by identity):
def process_order(params)
user_id = params[:user_id] # integer comparison, no byte hashing
product = params[:product_id]
quantity = params[:quantity]
coupon = params[:coupon_code]
order = {
status: :pending,
total: calculate_total(product, quantity, coupon),
created_at: Time.now,
user_id: user_id
}
order[:status] = :confirmed
order
endRelated skills
FAQ
What does ruby-optimise do?
ruby-optimise: A skill for development. This provides functionality for development workflows.
When should I use ruby-optimise?
When you need to use ruby-optimise for development tasks, or when ruby-optimise: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ruby-optimise.