This post was also published on my website.
In this article I'll describe a Rails performance caveat that I've recently faced on a project: Rails strong parameters and its routing are not performant and should be avoided if you are generating many URLs.
Scenario
The project needed a faceted filter which would generated thousands of URLs. Within a single faceted filter object, I had to generate an URL with the current URL merged with a given object's set of options.
A common approach to this issue would be using Rails' url_for
and strong parameters. I ended up writing the following method for my view object:
def current_path(options = {})
permitted_params = params.permit(DEFAULT_PERMITTED_PARAMS).merge(options)
url_for(permitted_params)
end
Cool! That's concise, secure and it'll give me the current URL with all extra options I want to merge with it.
As you can imagine, there's was a caveat: this was slow.
Benchmarks
How slow? Pages were taking 500 milliseconds to 800 milliseconds to load, with a single user request and a heavy CPU load. I knew there was something wrong on that view.
After profiling that the current_path
method was the one to blame, I ran a couple of benchmarks to test how the Rails way would compare to a pure Ruby stdlib approach. Using Ruby's stdlib URI
, the method could be written as:
def current_path(options = {})
permitted_params = request.parameters.slice(*DEFAULT_PERMITTED_PARAMS) # Avoid strong parameters
uri = URI(request.original_url) # Get the current URL through the request
uri.query = permitted_params.merge(options).to_param
uri.to_s
end
Here's the benchmark code:
# Inside the `app/controllers/benchmark_controller.rb` file
def show
uri = URI(request.original_url)
Benchmark.ips do |x|
x.report("ActionDispatch::Routing::UrlFor#url_for with query string") do
params.permit(:id)
url_for(params.permit(:id)) # => http://localhost:3000/?id=42
end
x.report("Ruby stdlib URI") do
uri.query = request.params.slice(:id).to_param
uri.to_s # => http://localhost:3000/?id=42
end
x.compare!
end
end
The results were quite interesting, given two different scenarios I often observed:
- Users requests fit strong parameters, it didn't need to cleanup user input
- Users requests didn't fit strong parameters, it needed to cleanup user input
Results with parameters that were permitted
Example request to URL http://lvh.me:3000/?id=42
Warming up --------------------------------------
ActionDispatch::Routing::UrlFor#url_for with query string
735.000 i/100ms
Ruby stdlib URI 9.807k i/100ms
Calculating -------------------------------------
ActionDispatch::Routing::UrlFor#url_for with query string
7.449k (± 4.8%) i/s - 37.485k in 5.044535s
Ruby stdlib URI 103.079k (± 4.9%) i/s - 519.771k in 5.054106s
Comparison:
Ruby stdlib URI: 103079.2 i/s
ActionDispatch::Routing::UrlFor#url_for with query string: 7448.5 i/s - 13.84x slower
Results with parameters that were not permitted
Example request to URL http://lvh.me:3000/?id=42&count=123
Warming up --------------------------------------
ActionDispatch::Routing::UrlFor#url_for with query string
309.000 i/100ms
Ruby stdlib URI 9.325k i/100ms
Calculating -------------------------------------
ActionDispatch::Routing::UrlFor#url_for with query string
3.051k (± 8.4%) i/s - 15.450k in 5.101804s
Ruby stdlib URI 81.424k (± 3.8%) i/s - 410.300k in 5.046415s
Comparison:
Ruby stdlib URI: 81423.9 i/s
ActionDispatch::Routing::UrlFor#url_for with query string: 3050.7 i/s - 26.69x slower
Analyzing results and conclusion
Given that url_for
+ strong parameters had a 3k iteration per second (on its worst case scenario) and that some rendered pages had more than 1500 anchor tags, that easily could consume 500 milliseconds of processing time, only to render faceted filtering!
Rails ease and magic comes with a performance cost. Its router methods are easy to use, but keep in mind you will need to move away from the Rails way if you want faster responses.
Top comments (0)