The ergonomics of rails

# June 16, 2025

Few web frameworks have been able to rise to the ranks of Rails - or really even come close. Over 20 years after its founding1 people still swear by it for consistency and productivity. You can get started quickly and keep moving quickly over time.

Stripe processes billions in payments, Shopify handles 80,000+ requests per second, and GitHub hosts the world's largest repos. All on Rails. Even though market penetration is down over time, Rails still has a 55.4% admiration rate. Developers who use it want to keep using it.

Why the success? These are my takes.

2004 In Code

I want to turn the clock back for a second. To understand why Rails felt revolutionary, we need to remind ourselves of what it replaced. I spent this last weekend re-familiarizing myself with some of the old projects I built around 2004. Boy were they a mess.

Here's how you created a user in typical 2004 PHP:

<?php
// user_create.php - The 2004 way
session_start();
include_once('config.php');
include_once('db_connect.php');

if ($_POST['submit']) {
    $username = mysql_real_escape_string($_POST['username']);
    $email = mysql_real_escape_string($_POST['email']);  
    $password = $_POST['password'];

    // "Validation"
    $errors = array();
    if (empty($username)) {
        $errors[] = "Username is required";
    }
    if (empty($email)) {
        $errors[] = "Email is required";
    }
    if (strlen($password) < 6) {
        $errors[] = "Password must be at least 6 characters";
    }

    if (empty($errors)) {
        // Check if user exists (remember to escape every input manually)
        $query = "SELECT * FROM users WHERE username = '$username' OR email = '$email'";
        $result = mysql_query($query) or die(mysql_error());

        if (mysql_num_rows($result) > 0) {
            $errors[] = "User already exists";
        } else {
            // Create user (at least we escaped the input strings above)
            $password_hash = md5($password); // "Security"
            $insert_query = "INSERT INTO users (username, email, password, created_at) VALUES ('$username', '$email', '$password_hash', NOW())";

            if (mysql_query($insert_query)) {
                header('Location: login.php?success=1');
                exit();
            } else {
                $errors[] = "Database error: " . mysql_error();
            }
        }
    }
}
?>

<!DOCTYPE html>
<html>
<head><title>Create User</title></head>
<body>
    <?php if (!empty($errors)): ?>
        <div style="color: red;">
            <?php foreach($errors as $error): ?>
                <p><?php echo $error; ?></p>
            <?php endforeach; ?>
        </div>
    <?php endif; ?>

    <form method="post" action="">
        Username: <input type="text" name="username" value="<?php echo $_POST['username']; ?>"><br>
        Email: <input type="text" name="email" value="<?php echo $_POST['email']; ?>"><br>
        Password: <input type="password" name="password"><br>
        <input type="submit" name="submit" value="Create User">
    </form>
</body>
</html>

Now here's the equivalent in Rails (circa 2005):

# app/models/user.rb
class User < ActiveRecord::Base
  validates :username, presence: true, uniqueness: true
  validates :email, presence: true, uniqueness: true
  validates :password, length: { minimum: 6 }

  has_secure_password
end

# app/controllers/users_controller.rb  
class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to login_path, notice: 'User created successfully'
    else
      render :new
    end
  end

  private

  def user_params
    params.require(:user).permit(:username, :email, :password)
  end
end
<!-- app/views/users/new.html.erb -->
<%= form_with model: @user do |form| %>
  <% if @user.errors.any? %>
    <div class="errors">
      <% @user.errors.full_messages.each do |message| %>
        <p><%= message %></p>
      <% end %>
    </div>
  <% end %>

  <%= form.text_field :username, placeholder: "Username" %>
  <%= form.email_field :email, placeholder: "Email" %>  
  <%= form.password_field :password, placeholder: "Password" %>
  <%= form.submit "Create User" %>
<% end %>

Wild difference in lines of code, sure, but also in density. The PHP version is 60+ lines of mixed concerns, manual SQL, and brittle error handling. The Rails version is 31 lines total, with automatic SQL injection protection, proper password hashing, database-backed validations, and clean separation of concerns.

You were also left to roll your own PHP conventions. Most PHP code mixed HTML rendering, business logic, database queries, and session management all in one file. Change the validation rules and you risk breaking the SQL or the form display. Rails enforced the most common architectural boundaries: models handle data and validation, controllers manage HTTP flow, views handle presentation.

Because of the separation of concerns the Rails version scales. Add a new field? One line in the model, one in the form. Need user authentication? has_secure_password handles it. Want to add an API endpoint? Add respond_to :json and you're done.

Security in PHP was largely an afterthought. It required such careful attention to manually escaping every single database input. Miss one mysql_real_escape_string() call and you've created a vulnerability. Rails made security the default by abstracting away manual SQL construction entirely.

The PHP approach felt like hacking multiple layers for a change that otherwise felt trivial. Rails felt way more extendable. It was closer to how a regular person would think about updating a program.2

2004 In Architecture

In 2004 Linode was still the new shiny webhosting service. My big architectural migration around that time was switching from Hostgator to Linode, which incidentally still hosts my site today.

I managed to dig up some of my old deployment notes from these projects3. They read like an archeological dig. I almost forgot how much Heroku changed the game with one-line deployments and Procfiles. Here's what it actually took to get a production environment running. Edited for clarity.

Step 1: MySQL Setup

  • Download mysql-4.0.21.tar.gz from mysql.com
  • ./configure --prefix=/usr/local/mysql
  • make
  • make install
  • Manually create mysql user: groupadd mysql && useradd -g mysql mysql
  • Initialize databases: /usr/local/mysql/bin/mysql_install_db
  • Edit /etc/my.cnf by hand
  • Start mysqld manually and hope it doesn't crash

Step 2: PHP Compilation

PHP usually came bundled on release servers but they were several releases behind.

  • Download php-4.3.8.tar.gz
  • ./configure --with-mysql=/usr/local/mysql --with-apache2=/usr/local/apache2 --enable-mbstring
  • Compilation fails: missing zlib-devel
  • Install zlib-devel, recompile
  • Compilation fails: missing libxml2-devel
  • Install libxml2-devel, recompile

Step 3: Apache Configuration

  • Edit /usr/local/apache2/conf/httpd.conf
  • Add LoadModule php4_module modules/libphp4.so
  • Set up virtual hosts by copying examples and modifying randomly
  • Restart Apache, "Forbidden" errors
  • Fix file permissions
  • Finally works, but only for one specific directory structure

My notes literally say: "linode has different PHP version (4.3.4 vs 4.3.8) and different modules enabled. but working locally. need to head out to school"

Back then "it works on my machine" wasn't just a meme. Every developer had a slightly different stack because everything was compiled from source with different options. Deployment meant uploading files via FTP and praying they'd work in a completely different environment.

Deployment was its own nightmare:

  1. Make changes locally
  2. Open Cyberduck
  3. Upload changed files one by one
  4. Test on production server
  5. Something breaks because production has different PHP settings
  6. Download production files to see what's different
  7. Make changes locally, upload again
  8. Repeat until it works or you give up

There was no version control for most small projects. Github didn't come on the scene until 2008 and even git itself wasn't released until 2005. Backing up in 2004 meant copying your entire htdocs folder to htdocs_backup_jan_15_2004. Database schema changes were applied by running SQL statements manually in phpMyAdmin, with no way to roll back if something went wrong.

Looking back at those notes, it's rather crazy that anyone built websites at all. Perhaps one reason why native apps were still equally en vogue: it was as easy to distribute an Objective-C executable as it was to build an entire remote webapp.

Pre-Ajax

Before Ajax became mainstream (circa 2005-2006), web interaction usually catered to the lowest common denominator. And that denominator was page refreshes. Click a button? Full refresh. Submit a form? Full refresh.

The technology that would become Ajax actually existed back in 2000. Microsoft's Internet Explorer 5 included an XMLHTTP ActiveX control for background HTTP requests. Mozilla implemented XMLHttpRequest as a native Javascript object around 2002, followed by Safari and Opera. But it remained pretty obscure, used mainly in enterprise apps like Outlook Web.

The path to cross-browser Ajax was brutal in part because of these incompatible APIs. Here's the code you needed to make a simple background request work across browsers in 2005:

function createXMLHttpRequest() {
    var xmlhttp = null;

    // IE 7+ and other modern browsers
    if (window.XMLHttpRequest) {
        xmlhttp = new XMLHttpRequest();
    }
    // IE 6 and older
    else if (window.ActiveXObject) {
        try {
            // Try the newer ActiveX object first
            xmlhttp = new ActiveXObject("Msxml2.XMLHTTP");
        } catch (e) {
            try {
                // Fall back to older ActiveX object
                xmlhttp = new ActiveXObject("Microsoft.XMLHTTP");
            } catch (e) {
                xmlhttp = null;
            }
        }
    }
    return xmlhttp;
}

function makeRequest(url, callback) {
    var xhr = createXMLHttpRequest();
    if (!xhr) {
        alert("Your browser doesn't support Ajax!");
        return;
    }

    xhr.onreadystatechange = function() {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                callback(xhr.responseText);
            } else {
                alert("Error: " + xhr.status);
            }
        }
    };

    xhr.open("GET", url, true);
    xhr.send(null);
}

This was the "simple" version. You needed different code paths for Internet Explorer's ActiveX implementation versus Firefox/Safari's native XMLHttpRequest. Even big web companies like Kayak referred to their Ajax implementation as "the xml http thing" because there wasn't established terminology yet. Jesse James Garrett wouldn't coin the term AJAX until February 2005.

AJAX was a technique not an actual series of APIs. So Rails was free to build on its base and pitch "interaction out of the box". It felt more stable than having to hack around the manual Javascript, because it was a first class feature of the language. Even if the outputted Javascript was just as hacky behind the scenes.

Rails introduced respond_to and RJS (Ruby Javascript) templates around 2005:

# app/controllers/posts_controller.rb
def update
  @post = Post.find(params[:id])

  respond_to do |format|
    if @post.update(post_params)
      format.html { redirect_to @post }
      format.js   # Renders update.js.erb
    else
      format.html { render :edit }
      format.js   # Renders error handling
    end
  end
end
<!-- app/views/posts/update.js.erb -->
$('#post_<%= @post.id %>').html('<%= escape_javascript(render @post) %>');
$('#flash').html('<%= escape_javascript("Post updated!") %>').show().delay(3000).fadeOut();

Rails still had to compile down to standard Javascript/HTML to render on the frontend. But the framework hid a ton of tricks behind the scenes4:

  • User clicks "Update Post" button on a form with remote: true attribute
  • Rails' Javascript helpers intercept the form submission, preventing default browser behavior
  • XMLHttpRequest fires to /posts/1 with Accept: text/javascript header
  • If Javascript was disabled, the form would just submit normally to /posts/1 with a default html-accept attached. So the server would know to respond with the page rendered with the full expected html.
  • Controller's respond_to block sees the Javascript request and renders update.js.erb instead of redirecting
  • Server returns raw Javascript code: $('#post_42').html('...updated HTML...');
  • Browser executes the returned Javascript, updating specific DOM elements without page refresh
  • User sees instant updates with no loading screens or lost state

This was pretty crazy. Rails could automatically handle both traditional form submissions and Ajax requests with the same controller action. The RJS templates let you write Javascript operations in Ruby, and Rails handled all the XMLHttpRequest complexity behind the scenes.

You could build truly interactive web applications without writing a single line of Javascript or worrying about browser compatibility. Rails made Ajax feel like a natural extension of server-side rendering instead of a completely different paradigm.

When we do so much of our web development work in Javascript and jsx templates these days, it seems strange that we shied away from using JS back in 2005. But without browser polyfills, a much more fractured browser engine ecosystem, and outdated browsers - every line of JS that you wrote had to be weighed against the risk that a user can't execute it properly and you just lost a customer. Rails made that consideration largely irrelevant.

Why Rails Won

Into that 2004 landscape came Rails, but also a slew of other web frameworks that were hopping on new trends. I think here's what made the difference in Rails:

Bootstrap CLI: Almost all new Rails projects are bootstrapped with their CLI. In the days when we were slinging PHP code manually5, it was one of the first mainstream frameworks that let you get started immediately. You had no blank page problem with starting a new project. You just entered one command and went from there.

Rails optimized for the wow factor of getting something working quickly.

  • rails new blog - full application scaffold
  • rails generate scaffold Post title:string content:text - new CRUD endpoints for the new object
  • rails server - running application

There was a dopamine hit to starting a new project in under a minute. You felt like you had a stable foundation to build from. You didn't even have to wait for your first page of PHP code to start rendering and displaying. And you certainly didn't have to wait to wire up SQL queries to be able to get a sense for object persistence.

Integrated ORM: Most languages just expected that people would write raw SQL queries. Any ORM bolt-on was a third party library where you would have to download a zip archive, extract them locally, and import into your project. Java’s Hibernate launched in 2001 but few web frameworks bundled an ORM out of the box.

Writing manual SQL was prone to errors and query injection. But even more importantly it took you out of the flow. When you're used to working with an object oriented language, switching into explicit SQL felt clunky.

Philosophy: "Optimizing for programmer happiness" was literally in their marketing. This seems obvious now with the amount of effort companies spend on perfecting the best developer experience. But this was radical at the time. Most frameworks optimized for performance, flexibility, or correctness. You were more likely to see benchmark stats with a zip file of php files than you were to see a sexy website and comprehensive docs.

Rails also embraced what other frameworks saw as flaws:

  • "Convention over configuration" seemed like magic. You could start with no code of your own and make a full site
  • ActiveRecord felt magical with its dynamic finders
  • Other frameworks tried to be "explicit" and "transparent" - Rails embraced beneficial complexity

Ruby Metaprogramming: For syntax, Rails can really thank Ruby instead of the framework. Inherently it's more DSL-friendly than most other languages. Especially the compiled ones that were typically the go-to for serious web projects™.

Ruby's flexible syntax allowed Rails to create domain-specific languages just for your project:

has_many :posts, dependent: :destroy
validates :email, presence: true, uniqueness: true
before_action :authenticate_user!

This reads like English, not code. Other languages couldn't replicate this naturalness.

Ruby's metaprogramming allowed Rails to generate methods dynamically (find_by_email, Post.published). Since there was no language server support to worry about, you could rely on more magical runtime behavior. Developer were used to memorizing a deeper API surface area. Having a convention for the metaprogramming actually made things simpler.

Other languages weren't structured to allow this same kind of support. Languages like Java, C#, and C++ were too rigid because they needed to be compiled. Javascript lacked the metaprogramming sophistication because of its Prototype inheritance. Python had it but the community preferred explicit over implicit logic.

The DHH Factor: Without a doubt, DHH and the marketing tactics over at Basecamp undoubtedly helped the rise of Rails. He helped make engineering cool - in vibe, in militant philosophy, in bucking the institutional trends. In an era where the independent hackers of the 80s were quickly getting replaced by briefcase carrying office workers, he represented a lone voice of independence.6

Arguing the counterfactual is always impossible. But I suspect that had DHH advocated for a framework with fewer fundamental advantages, it wouldn't have reached the echelons that Rails did.

2025 Lessons Learned

I've tried to learn from these same lessons when designing Mountaineer - my full featured webapp framework library for Python. The problems are different in 2025 but the ergonomic principles remain the same.

Modern Creation CLI: Just like Rails' rails new blog, Mountaineer provides pipx run create-mountaineer-app. One command gets you a complete project structure with Python backend, React frontend, PostgreSQL database, Docker configuration, and Typescript setup. People still burn more time7 than they should setting up build pipelines, basic API routes, or figuring out how to connect their database.

Integrated Type System: Where Rails had ActiveRecord for database-to-object mapping, Mountaineer goes further with end-to-end type safety. Define your data models in Python, and the framework automatically generates Typescript interfaces for your React components. Change a field type in your database model? Your frontend immediately knows about it. This helps both with IDE typehinting and with passing data from the server to the frontend and back again.

# Python model
class BlogPost(TableBase):
    title: str
    content: str
    published_at: datetime | None = None
// Typescript automatically knows about this structure
const post: BlogPost = {
    title: "My Post",
    content: "Content here",
    published_at: new Date()
};

Convention Over Configuration (2025 Edition): Modern web development is still rather complex. You need to configure bundlers, set up API endpoints, handle serialization, manage state synchronization, and coordinate between multiple build systems. The handoff between frontend data and backend data is still leaky.

Mountaineer provides the same "convention over configuration" philosophy - define your backend controllers in Python, put your React components in the views directory, and the framework handles all the binding automatically. Actions are defined as either simple server calls with @passthrough or calls that will modify state with @sideeffect. Decorate your functions with these and Mountaineer will know to update the right data in the frontend using lightweight http calls.

Agent-First Architecture: There's one more problem that didn't exist in 2004: making codebases navigable by AI agents. Mountaineer's strict conventions help solve this. When an AI agent needs to add user authentication, it knows exactly where to go: controllers in /controllers, React components in /views, database models in /models. No guessing, no architectural debates.

Mountaineer's guaranteed static type hints act as interpreted languages' equivalent to a compilation phase. When an AI agent generates Python code with type annotations, the type checker immediately validates whether function signatures are correct, database field types match frontend expectations, and all required parameters are provided. The agent can iterate until it passes type checking, catching errors that would otherwise require human debugging.

The result is AI agents that can make substantial changes to a Mountaineer codebase with minimal supervision. They know where to put the code, and they know when they've gotten it right.

The core insight remains the same: productivity frameworks succeed by eliminating repetitive manual work. Rails eliminated database configuration and deployment scripts. Mountaineer eliminates API boilerplate and type synchronization. Different decades, same ergonomic principles.

Today's equivalent of Rails' 15-minute blog demo is Mountaineer's ability to add a new data field to your database and immediately have it available in your React components with full type safety - no API endpoints, no manual type definitions, no synchronization bugs. The magic feeling is the same, just applied to the modern web.

If you haven't yet worked in a Mountaineer codebase, give it a try. You may just feel a bit of that spark from the early Rails days.


  1. All the way back in 2004. Think: MTV Cribs, new Spongebob episodes, the works. 

  2. And deep down engineers are regular people too. 

  3. Made easier thanks to my recent homelab retrofit.

    Was anyone even using README files back then? I seemingly just wrote everything in scattered .txt files on disk. 

  4. The most powerful thing a framework can do is figure out what repetitive tasks engineers do over and over and over again. Even if the solution is hacked together with duct tape, when it can cut down extensive boilerplate across a whole project, it still feels like magic. There's something that feels more official about a framework's blessing. 

  5. With a phpmyadmin interface to boot. 

  6. I suspect some of this was simply true to his personality. I also imagine when it proved successful as a marketing tactic, became more deliberate over time. 

  7. Or more tokens

/dev/newsletter

Technical deep dives on machine learning research, engineering systems, and building scalable products. Published weekly.

Unsubscribe anytime. No spam, promise.