All that being said I still use (and like) Rails, currently comparing Phoenix/Elixir to Rails 8 in a side project. But I use typescript w/ Node and Bun in my day job.
---
I have now done several Google searches to - well, admittedly, to try and counter your argument; but what I've since found is:
* Every friggin' benchmark is wildly different [0, 1]
* Some of these test pages are obnoxious to read and filter; **BUT** Javascript regularly finds itself to be **VERY** fast [0]
On a more readable and easily-filtered version (that has very differnet answers) [1],
* plain Javascript (not Next.js) has gotten *REALLY* fast, serverside
* Kotlin is (confusingly?!) often slower than JS, depending on the benchmark
^-- this one doesn't make sense to me
^-- in at least one example, they're basically on par (70k rps each)
* Ruby and Python are painfully slow, but everyone else sorta sits in a pack togetherI will probably be able to find another benchmark that says completely different things.
Benchmarking is hard.
I'm also having trouble finding the article from HN that I was sure I saw about Next.JS's SSR rendering performance being abysmal.
[0] https://www.techempower.com/benchmarks/#section=data-r23
[1] https://web-frameworks-benchmark.netlify.app/result?asc=0&f=...
https://benchmarksgame-team.pages.debian.net/benchmarksgame/... is the most adequate, if biased in ways you may not agree with, if you want to understand raw _language_ overhead on optimized-ish code (multiplied by the willingness of the submission authors to overthink/overengineer, you may be interested in comparing specific submissions). Which is only a half (or even one third) of the story because the other half, as you noted, is performance of frameworks/libraries. I.e. Spring is slow, ActiveJ is faster.
However, it's important to still look at the performance of most popular libraries and how well the language copes with somewhat badly written user code which absolutely will dominate the latency way more often than anyone trying to handwave away the shortcomings of interpreted languages with "but I/O bound!!!" would be willing to admit.
Rails is a sharp knife. There is Rails way to do things. You may of course choose to do them differently (this is a contrast with other toolkits that fight this hard), but you are going to have to understand the system well to make that anything but a giant hassle.
With rails, the way it scales is statelessness. You have to map the front end actions to individual endpoints on the server. This works seamlessly for crud stuff (create a thing; edit a thing; delete a thing; list those things). For other use cases it works less seamlessly. NB: it works seamlessly for nested "things" too.
Complex multi-step flows are a pain point. eg you want to build data structures over time where between actions on the server (and remember, you must serialize everything you wish to save between each action), you have incomplete state. Concretely: an onboarding flow which sets up 3 different things in sequence with a decision tree is going to be somewhat unpleasant.
You must keep most state on the server and limit FE state. Hotwire works extremely well but the FE must be structured to make hotwire work well.
I've actually found it to work pretty well with individual pages build in react. My default is to build everything with hotwire and, when the FE gets too complex, to fall back to react.
Rails is nobody's idea of a fast system. You can make it perform more than well enough, but fast it is not.
Upsides, my take: it is the best tool to build websites. The whole thing is built by developers for developers. DX and niceness of tools are valued by the community. Contrast with eg the terrible standard lib that other languages (hi, js) have. Testing is by far the most pleasant I've used, with liberal support for mocking rather than having to do DI. For eg things like [logic, api calls, logic, api calls, logic, db calls] it works incredibly well. It is not the most popular toolkit and it's not react, so that can count against you in hiring.
If you have a class `Customer` with a field `roles` that is an array of strings, you can write code like this
class Customer
ROLES = ["superadmin", "admin", "user"]
ROLES.each do |role|
define_method("is_#{role}?") do
roles.include?(role)
end
end
end
In this case, I am dynamically defining 3 methods `is_superadmin?` `is_admin?` and `is_user?`. This code runs when the class is loaded by the Ruby interpreter. If you were just freshly introduced into this codebase, and you saw code using the `is_superadmin?` method, you would have no way of knowing where it's defined by simply grepping. You'd have to really dig into the code - which could be more complicated by the fact that this might not even be happening in the Customer class. It could happen in a module that the Customer class includes/extends.The other feature is `method_missing`. Here's the same result achieved by using that instead of define_method:
class Customer
ROLES = ["superadmin", "admin", "user"]
def method_missing(method_name, *args)
if method_name.to_s =~ /^is_(\w+)\?$/ && ROLES.include?($1)
roles.include?($1)
else
super
end
end
end
Now what's happening is that if you try to call a method that isn't explicitly defined using `def` or the other `define_method` approach, then as a last resort before raising an error, Ruby checks "method_missing" - you can write code there to handle the situation.These 2 features combined with modules are the reason why "Go to Definition" can be so tricky.
Personally, I avoid both define_method and method_missing in my actual code since they're almost never worth the tech debt. I have been developing in Rails happily for 15+ years and only had one or two occasions where I felt they were justified and the best approach, and that code was heavily sprinkled with comments and documentation.
See here:
https://github.com/heartcombo/devise/blob/main/lib/devise/co...
def self.define_helpers(mapping) #:nodoc:
mapping = mapping.name
class_eval <<-METHODS, __FILE__, __LINE__ + 1
def authenticate_#{mapping}!(opts = {})
That code is *literally* calling class_eval with a multi-line string parameter, where it inlines the helper name (like admin, user, whatever), to grow the class at runtime.It hurts my soul.
Dynamically generated methods can provide amazing DX when used appropriately. A classic example from Rails is belongs_to, which dynamically defines methods based on the arguments provided:
class Post < ApplicationRecord belongs_to :user end
This generates methods like:
post.user - retrieves the associated user
post.user=(user) - sets the associated user
post.user_changed? - returns true if the user foreign key has changed.
Customer.limit(1000).to_a
completes in about 10ms, whereas:
Customer.last(1000).pluck(:id, :name, :tenant_id, :owner_id, :created_at, :updated_at)
runs in around 7ms.
Active Record methods are defined at application boot time as part of the class, they're not rebuilt each time an instance is created. So in a typical web app, there's virtually no performance penalty for working with Active Record objects.
And when you do need raw data without object overhead, .pluck and similar methods are available. It’s just a matter of understanding your use case and choosing the right tool for the job.
You can prototype stuff very fast with rails and its a mighty tool in the right hands.
- The Global interpreter lock (GIL) in Ruby is less performant than async thread programming in JS (and some other languages)
- Rails creates a monolith rather than a bunch of independent endpoints. If you have a large team, this can be tricky (but is great for smaller teams who want to move fast)
- How Rails integrates with JS/CSS is always changing. I recommend using Vite instead of the asset pipeline, unless you're going with the stand Rails stimulus js setup.
- Deploying Rails in a way that auto-scales the way serverless functions can is tricky. Their favored deployment is to server of set size using Kamal.
The upsides is that by not trying to hide the database and pretend it doesn't exist you can avoid a whole class of work (and the safety abstractions provided) and be incredibly productive if the requirements align.
Not everyone has looked into or tried everything.
Rails also uses way too much magic to dynamically construct identifiers and do control flow.
The over-use of magic and the under-use of static types makes it extraordinarily difficult to navigate Rails codebases. It's one of those things where you have to understand the entire codebase to be able to find anything. Tractable for tiny projects. For large projects it's a disaster.
Rails is a bad choice (as is Ruby).
My favourite web framework at the moment is Deno's Fresh. You get the pleasure of TSX but it's based around easy SSR rather than complex state management and hooks. Plus because it's Deno it's trivial to set up.