Rails or JavaScript, and what the Odin Project paths actually differ by
The query "ruby on rails vs javascript" has no answer, because one of those is a language and the other is a framework written in a different one. The query people actually type next to it does have one: "vs javascript the odin project", from somebody standing in front of the Odin Project's two paths trying to work out which of them costs them less. That question is decidable, and most of it is decidable by reading the two path pages carefully and then building the same small thing twice.
Both were done for this page. The course lists come from theodinproject.com, fetched on 2026-09-27 and counted off the raw HTML rather than from memory. The code comes from two applications generated and written on one laptop this afternoon: Rails 8.1.4 on Ruby 4.0.5 and Puma 8.0.2, and Express 5.2.1 with pg 8.23.0 on Node v26.4.0, both pointed at the same PostgreSQL 17.7 on port 15432, both holding the same 50 rows. Apple M2 Max, 12 cores, macOS arm64-darwin25. Where a number is below, the command that produced it is next to it.
Neither path is the one without JavaScript
The premise under the question is that one path lets you skip JavaScript. It does not exist. The
/paths page lists Foundations ahead of both paths, and the Foundations course teaches JavaScript:
its page lists JavaScript Basics, JavaScript Developer Tools, Function Basics and
Object Basics among the lessons, alongside Command Line Basics, Git Basics, HTML Foundations
and CSS Foundations. The curriculum order puts JavaScript before the choice of path, not after it.
Then the Rails path keeps going. Here are both paths as the site listed them on 2026-09-27, in the order the pages display them:
| Full Stack JavaScript (7 courses) | Full Stack Ruby on Rails (8 courses) | ||
|---|---|---|---|
| Ruby | 32 lessons, 15 projects | ||
| Intermediate HTML and CSS | 20 lessons, 2 projects | Intermediate HTML and CSS | 20 lessons, 2 projects |
| JavaScript | 29 lessons, 12 projects | Databases | 2 lessons, 1 project |
| Advanced HTML and CSS | 15 lessons, 1 project | Ruby on Rails | 32 lessons, 11 projects |
| React | 22 lessons, 3 projects | Advanced HTML and CSS | 15 lessons, 1 project |
| Databases | 2 lessons, 1 project | JavaScript | 20 lessons, 7 projects |
| NodeJS | 21 lessons, 9 projects | React | 23 lessons, 5 projects |
| Getting Hired | 12 lessons, 2 projects | Getting Hired | 12 lessons, 2 projects |
| 121 lessons, 30 projects | 156 lessons, 44 projects |
Six of the seven course titles on the JavaScript path also appear on the Rails path. The Rails path carries 43 lessons of JavaScript and React; the JavaScript path carries 72 across JavaScript, React and NodeJS. The difference in JavaScript exposure is real and it is a difference of degree rather than of kind. The structural difference is two courses: Ruby and Ruby on Rails on one side, NodeJS on the other, and the NodeJS course description on the site says plainly what it teaches with, Express and PostgreSQL.
So the choice is between 64 lessons of Ruby and Rails and 21 lessons of Express, sitting inside two curricula that otherwise share their first course and their last. That is a much smaller decision than the way it gets argued, and it is the reason the rest of this page is about the 21 lessons against the 64 rather than about the language.
A Rails page ships 155,355 bytes of JavaScript
Rails renders HTML on the server, which is the reason people reach for it to avoid JavaScript, and a default Rails 8.1.4 page still sends more JavaScript to the browser than the Express application further down this page does. Here is the import map the generator wrote:
# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
Every one of those resolves to a real request. Summing them off the rendered page, with the app on port 3003:
157 /assets/application-bfcdf840.js
218 /assets/controllers/application-3affb389.js
157 /assets/controllers/hello_controller-708796bd.js
272 /assets/controllers/index-ee64e1f1.js
3315 /assets/stimulus-loading-1fc53fe7.js
45657 /assets/stimulus.min-4b1e420e.js
105579 /assets/turbo.min-9fd88cd5.js
TOTAL JS BYTES: 155355
That came from a shell loop over grep -o '/assets/[^"]*\.js' rails_posts.html, curling each one and
adding up %{size_download}. turbo.min.js is 105,562 bytes on disk inside turbo-rails 2.0.23 and
105,579 as served, because Propshaft rewrites the trailing //# sourceMappingURL=turbo.min.js.map
into a digested /assets/turbo.min-5eb167c2.js.map. The 17 bytes are the digest.
app/javascript/controllers/hello_controller.js is worth opening if you are the person deciding
between these two paths, because it is the shape of every piece of client-side behaviour you will
write on the Rails path:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}
That is JavaScript, with a module system and a class and a lifecycle callback, generated into a new
Rails application before you have chosen what to build. The Odin Rails course has lessons named
Importmaps, Turbo Drive, JS Bundling, Turbo and Stimulus for exactly this reason.
The inventory that said zero
My first pass at the paragraph above was wrong, and the way it was wrong is worth the detour.
find . -name '*.js' in the freshly generated application returned one file,
app/views/pwa/service-worker.js, 876 bytes. No config/importmap.rb, no app/javascript, no
vendor/javascript. For about a minute I had a much better story: Rails 8 ships no JavaScript at
all.
The Gemfile said otherwise, with importmap-rails, turbo-rails and stimulus-rails all in it, so
I went back to the generator log:
rails importmap:install
/Users/mehdifarsi/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/bundled_gems.rb:60:in 'Kernel.require': cannot load such file -- bootsnap/setup (LoadError)
from /Users/mehdifarsi/.rvm/rubies/ruby-4.0.5/lib/ruby/4.0.0/bundled_gems.rb:60:in 'block (2 levels) in Kernel#replace_require'
from /private/tmp/.../odin_rails/config/boot.rb:4:in '<top (required)>'
from bin/rails:3:in 'Kernel#require_relative'
from bin/rails:3:in '<main>'
rails turbo:install stimulus:install
rails new runs its post-install generators by shelling out to the new application's own
bin/rails, and on this machine bundle install --quiet had not put bootsnap in place before that
happened, so config/boot.rb died on line 4 and took importmap:install, turbo:install,
stimulus:install and solid_*:install with it. rails new exited 0. The application boots, serves
pages and looks finished. Running bundle install and then the three installers by hand produced the
files, and every figure above is from after that. If you are following an Odin lesson and your
app/javascript directory is not there, read the output of rails new before you read the lesson
again.
The same feature, both ways
The feature is the one every course reaches for: a posts table, a list, a show page, a form, a
create with a validation, and a JSON endpoint. On the Rails side that is three commands, one of which
is the scaffold:
$ bin/rails g scaffold Post title:string body:text
$ bin/rails db:migrate
Fifteen files, and wc -l over exactly the files the generator named:
3 app/models/post.rb
75 app/controllers/posts_controller.rb
27 app/views/posts/_form.html.erb
12 app/views/posts/_post.html.erb
12 app/views/posts/edit.html.erb
16 app/views/posts/index.html.erb
11 app/views/posts/new.html.erb
10 app/views/posts/show.html.erb
2 app/views/posts/_post.json.jbuilder
1 app/views/posts/index.json.jbuilder
1 app/views/posts/show.json.jbuilder
10 db/migrate/20260927145817_create_posts.rb
48 test/controllers/posts_controller_test.rb
7 test/models/post_test.rb
9 test/fixtures/posts.yml
244 total
One of those 244 lines is mine: validates :title, presence: true, which the scaffold does not
write. The other 243 arrived typed.
The Express side is what the Odin NodeJS course sets up, npm install express pg, which added 82
packages, 739 files and 4.6 MB of node_modules in 1.139 seconds on a warm npm cache. Then the
table, by hand, because nothing derives it:
CREATE TABLE posts (
id bigserial PRIMARY KEY,
title varchar,
body text,
created_at timestamp(6) NOT NULL,
updated_at timestamp(6) NOT NULL
);
Then 99 lines of app.js and 6 of db.js. Seven routes, and this is the shape of all of them:
app.post("/posts", async (req, res) => {
const { title, body } = req.body;
if (!title || title.trim() === "") {
return res.status(422).send(form({ title, body }, ["Title can't be blank"]));
}
const { rows } = await pool.query(
`INSERT INTO posts (title, body, created_at, updated_at)
VALUES ($1, $2, now(), now()) RETURNING id`,
[title, body]
);
res.redirect(`/posts/${rows[0].id}`);
});
112 lines across three files against 244 across fifteen, and the 112 does less: no edit links in the list, no flash messages, no Turbo responses, no fixtures, no tests. The honest way to read those two numbers is not "Rails wrote twice as much for me". It is that on the Node side I now know what every line does, and on the Rails side I know what six of them do.
The test that fails on one side
Both applications got the same five assertions: the index lists 50 posts, the JSON endpoint returns
50 rows, a blank title is refused, a valid post is created, and a title containing markup is escaped
on the show page. On the Node side that is node:test, which needs nothing installed:
test("a title containing markup is escaped on the show page", async () => {
await pool.query(
`INSERT INTO posts (title, body, created_at, updated_at)
VALUES ($1, 'xss', now(), now())`,
["<script>alert(1)</script>"]
);
const res = await fetch(`${base}/posts/52`);
const html = await res.text();
assert.ok(!html.includes("<script>alert(1)</script>"), "raw script tag reached the page");
assert.ok(html.includes("<script>alert(1)</script>"));
});
✔ index lists every post (37.362ms)
✔ json endpoint returns 50 rows (3.50475ms)
✔ a blank title is refused (9.431208ms)
✔ a valid post is created (4.277ms)
✖ a title containing markup is escaped on the show page (3.658125ms)
ℹ tests 5
ℹ suites 0
ℹ pass 4
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 190.502917
✖ failing tests:
test at test/posts.test.js:60:1
✖ a title containing markup is escaped on the show page (3.658125ms)
AssertionError [ERR_ASSERTION]: raw script tag reached the page
at TestContext.<anonymous> (/private/tmp/.../odin_node/test/posts.test.js:68:10)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async Test.run (node:internal/test_runner/test:1389:7)
at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: '==',
diff: 'simple'
}
Four of five, and the fifth is an XSS. ${p.title} in a template literal is a string
concatenation, so the show page came back as
<h1><script>alert(1)</script></h1><p>xss</p> and the browser runs it. The fix is 10 lines, taking
app.js from 89 to 99:
function esc(value) {
if (value === null || value === undefined) return "";
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
✔ index lists every post (33.55275ms)
✔ json endpoint returns 50 rows (6.506042ms)
✔ a blank title is refused (6.915375ms)
✔ a valid post is created (4.208042ms)
✔ a title containing markup is escaped on the show page (3.137041ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 155.858292
The same five assertions as a Minitest integration test against the Rails scaffold, using
assert_not_includes response.body, "<script>alert(1)</script>", needed no application code at all.
ERB escapes because ActionView escapes, and the test passed on the first run:
Running 5 tests in a single process (parallelization threshold is 50)
Run options: --seed 35502
# Running:
.....
Finished in 0.359245s, 13.9181 runs/s, 38.9706 assertions/s.
5 runs, 14 assertions, 0 failures, 0 errors, 0 skips
bin/rails test with the scaffold's own generated tests alongside mine reports 12 runs, 25
assertions, 0 failures, 0 errors, 0 skips. Seven of those twelve I did not write.
Three more things the Express app did not do
Escaping was not the only default I had to reinvent, and the other three took a curl each to find. All four checks below ran against the Rails development server and the same Express process, not against the production pair used for the benchmarks further down.
A form POST with no CSRF token. Rails answered 422 and wrote
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.) to the log, from
actionpack (8.1.4) lib/action_controller/metal/request_forgery_protection.rb:321. Express answered
302 with Location: /posts/51 and inserted the row. To POST successfully to Rails I had to pull
authenticity_token out of /posts/new first, an 86-character value, and carry the session cookie.
A form that says _method=patch. Rails answered 303 and ran the update, because Rack::MethodOverride
is ninth in bin/rails middleware. Express 5.2.1 answered:
status 404
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot POST /posts/1</pre>
</body>
</html>
A NULL title, inserted straight into the table because the column allows it and only my route
handler objects. The index rendered it as the literal four-character string null:
<li><a href="/posts/53">null</a> no title</li>
And one that is not a defect, just a difference that will cost somebody an afternoon. The same endpoint, first row, both applications, same table shape:
{"id": "1", "title": "Post 1", "body": "Body of post 1", "created_at": "2026-09-27T15:04:24.500Z", "updated_at": "2026-09-27T15:04:24.500Z"}
{"id": 1, "title": "Post 1", "body": "Body of post 1", "created_at": "2026-09-27T15:04:16.134Z", "updated_at": "2026-09-27T15:04:16.134Z", "url": "http://127.0.0.1:3004/posts/1.json"}
pg 8.23.0 hands back bigint as a string, so id is "1" in the Express response and 1 in the
Rails one. The url field is the scaffold's _post.json.jbuilder calling post_url.
Speed, and what it is measuring
Express wins this, comfortably, and it is the only axis on this page where one side wins outright.
Both servers ran one process: Puma 8.0.2 in RAILS_ENV=production with its default 3 threads,
node app.js with NODE_ENV=production, the same 50 rows, Rack::Deflater absent on both sides so
neither response is compressed.
Median of five interleaved runs, ab -n 1000 -c 1:
| Endpoint | Requests per second | Time per request |
|---|---|---|
Express /posts.json |
1865.30 | 0.536 ms |
Rails /posts.json, the scaffold's jbuilder view |
298.23 | 3.353 ms |
Rails /posts_raw.json, render json: |
508.97 | 1.965 ms |
At -n 2000 -c 12, same interleaving: 4041.93, 323.27 and 564.04.
Two things about those numbers before anybody quotes them. The first is that 1.4 of the 3.353
milliseconds per Rails request are not the framework, they are the scaffold: index.json.jbuilder
renders a partial per row and calls post_url fifty times, and replacing it with one
render json: Post.all.as_json(...) line bought 1.388 ms and 71 percent more throughput. The gap
against Express is 6.3x with the generated view and 3.7x without it.
The second is that this laptop was not quiet. The same ab command against the Rails endpoint
reported 185.28 requests per second twenty minutes earlier while other work was running, and
Failed requests: 0 alongside it, so nothing in the output tells you the machine was busy. That is
why the table is medians of five interleaved runs rather than one number each, and it is why I would
not carry any of these figures to a different machine.
Boot and memory, since the choice affects what you run on a laptop. bin/rails runner nil in
production with a warm bootsnap cache: 1.15s, 0.93s, 0.88s. node -e 'require("./app")': 0.12s,
0.07s, 0.08s. Resident set after all of the benchmarking above: Puma 117.8 MB, node 92.3 MB, which
is closer than the boot times suggest.
Which path to take
Take the Rails path.
The reason is the section above the benchmarks, not the benchmarks. In 112 lines of Express I shipped
an XSS, an endpoint that accepts cross-site form posts, a form whose method override is silently
ignored, and a page that renders the word null at a user. All four are things a beginner cannot
see, because you cannot audit a default you do not know exists. The Rails scaffold had all four right
before I typed a line, and the fastest way to learn that HTML output needs escaping is to read why
your test passed rather than to read a report that your site is defaced.
The cost of that recommendation is real and it is specific. You will write Rails for months without
knowing what escaped your HTML, what verified your token, or what turned _method into a PATCH, and
every one of those is a question you can only ask once you notice it was answered. You will still
take the JavaScript course and the React course, because the Rails path has both. And when you do
need speed on a JSON endpoint you will be starting from 298 requests per second rather than 1865.
What would change it. If what you want to build is the browser half of a product, the JavaScript path is aimed at it and Rails is not. If you already know JavaScript well enough to read an Express middleware stack, the 64 lessons of Ruby and Rails are a detour rather than a foundation. And if your local market's postings are overwhelmingly Node and React, that is a real argument for the other path; I did not measure any hiring figure for this page and will not quote one.
What this page does not cover
No hiring numbers, no salary figures, no claim about which path gets more people employed. The stack-choice version of this argument, for somebody picking a framework for a product rather than a curriculum, is Ruby on Rails vs JavaScript, which carries the survey figure and the verdict that goes with it. Whether to learn the language or the framework first is Ruby vs Ruby on Rails, and how hard the framework is to pick up from different starting points is Is Ruby on Rails hard to learn.
No Prisma, no TypeScript, no Next.js, and no React. The Odin NodeJS course has a Prisma lesson and I
did not install it, so everything above about the Express side is raw pg queries, which is how the
course starts. The lesson lists are as the site displayed them on 2026-09-27 and Odin revises them;
count them again rather than trusting the table.
The performance section measures one 50-row JSON endpoint on one laptop with one process per side, which is the narrowest possible benchmark. What happens at four Puma workers, or with YJIT, which this Ruby build does not have, is in Ruby on Rails performance comparison, and nothing here says anything about either.
Comments
No comments yet. Be the first.