LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 2 views

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("&lt;script&gt;alert(1)&lt;/script&gt;"));
});
✔ 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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}
✔ 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.

#rails #javascript #node #comparison

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.