Back to Scaling Ruby Applications guides

Ruby on Rails vs Fastify

Stanley Ulili
Updated on September 29, 2025

Ruby on Rails and Fastify take completely different approaches to building web applications. One gives you a complete framework with built-in solutions for everything. The other gives you HTTP handling and plugins, then gets out of your way.

Rails comes loaded with opinions about how you should structure databases, handle routing, render templates, and deploy applications. Fastify gives you HTTP request handling and a plugin system, leaving architectural decisions entirely up to you.

After implementing the same shopping cart, user authentication, and payment processing in both frameworks, I learned exactly when each approach makes sense for different types of projects.

What is Ruby on Rails?

Screenshot of Ruby on Rails Github page

Ruby on Rails is what happens when someone gets tired of rebuilding the same web application pieces over and over again. David Heinemeier Hansson created it in 2003 after extracting common patterns from Basecamp, and the result was a framework that assumes you want the usual stuff like database handling, user authentication, file uploads, and background jobs.

Everything connects without you having to think about it. Need to store user data? ActiveRecord handles your database. Want to render JSON responses? ActionController does that. Need user login? There's a gem for that, and it works with everything else Rails already set up.

Rails makes a lot of decisions for you. Models go in the app/models folder. Form submissions have generators that build the whole flow. Someone already figured out the boring stuff so you can focus on building features.

What is Fastify?

Fastify scratches a different itch entirely. Matteo Collina and Tomas Della Vedova built it in 2016 because they wanted something fast and developer-friendly without all the baggage that comes with full-stack frameworks. Think of it as giving you really good tools while letting you decide how to use them.

The framework gives you the essentials that every web API needs. JSON schema validation that actually works, request logging that doesn't slow everything down, and TypeScript support that feels natural instead of bolted-on. But when you need a database connection, you pick your own ORM. When you need authentication, you choose the strategy that fits your use case.

This plugin architecture means you're not fighting against framework decisions when your project has specific requirements. Want to use PostgreSQL with raw SQL queries? Fine. Prefer MongoDB with Mongoose? Also fine. Fastify handles the HTTP stuff really well and gets out of your way for everything else.

Rails vs Fastify: quick comparison

Feature Ruby on Rails Fastify
Main approach Convention over configuration Plugin-based architecture
Language Ruby JavaScript/TypeScript
Learning curve Moderate, many conventions Gentle, familiar Node.js patterns
Project structure Standardized MVC structure Flexible plugin architecture
Database integration ActiveRecord ORM built-in Choose your own ORM/query builder
Request handling Action-based controllers Route handlers with schemas
Validation Model validations and gems Built-in JSON schema validation
Development speed Very fast for standard apps Fast with good tooling
Built-in features Extensive built-in functionality Minimal core, extensive plugins
Type safety Optional with Sorbet Native TypeScript support
Testing Built-in testing framework Choose testing libraries
Deployment Convention-based deployment Container-friendly, various options

Setting up and initial development

The difference between comprehensive built-in solutions and plugin-based architecture hit me immediately during setup. Rails gave me a complete application structure in one command, while Fastify handed me a web server and told me to figure out the rest.

Rails basically sets up your entire application for you.

 
# Install Rails and generate application
gem install rails
rails new product_api --api --database=postgresql
cd product_api

# Generate complete API resources
rails generate model Product name:string price:decimal description:text
rails generate controller Api::Products index show create update destroy
rails db:migrate

# Start development server (takes 10-15 seconds)
rails server

That one rails new command created everything. Database migrations, model files with validations, controller actions following REST conventions, routing configuration, test files, and even a basic CI setup. The generators are honestly magical. Type rails generate model Product and you get a model file, a migration, and test stubs all wired up correctly.

Fastify gives you just enough to get started.

 
# Initialize project and install Fastify
npm init -y
npm install fastify @fastify/autoload

# Create basic server
touch server.js
server.js
const fastify = require('fastify')({
  logger: true
});

// Register plugins
fastify.register(require('@fastify/autoload'), {
  dir: './routes'
});

// Start server (boots instantly)
const start = async () => {
  try {
    await fastify.listen({ port: 3000 });
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};
start();

This gave me a web server that starts in milliseconds and automatically loads route files from a directory. That's it. No database setup, no model generators, no controller structure. If I want those things, I need to choose libraries and wire them up myself.

The tradeoff is obvious. Rails gets you building features immediately, but you're locked into their way of doing things. Fastify requires more initial decisions, but every choice is yours to make.

Building API endpoints

After seeing how differently these frameworks handle setup, I wanted to understand how that would play out when building actual API functionality. The product catalog endpoints would be a good test since they need validation, database queries, error handling, and JSON responses.

Rails follows its established MVC pattern with everything integrated.

app/models/product.rb
class Product < ApplicationRecord
  validates :name, presence: true, length: { minimum: 2 }
  validates :price, presence: true, numericality: { greater_than: 0 }

  scope :available, -> { where(available: true) }
  scope :by_category, ->(category) { where(category: category) }
end
app/controllers/api/products_controller.rb
class Api::ProductsController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_product, only: [:show, :update, :destroy]

  def index
    @products = Product.available
                       .by_category(params[:category])
                       .page(params[:page])
                       .per(20)
    render json: @products, include: :reviews
  end

  def create
    @product = Product.new(product_params)
    if @product.save
      render json: @product, status: :created
    else
      render json: { errors: @product.errors }, status: :unprocessable_entity
    end
  end

  private

  def product_params
    params.require(:product).permit(:name, :price, :description, :category)
  end

  def set_product
    @product = Product.find(params[:id])
  end
end
config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    resources :products
  end
end

This is Rails at its best. The model handles validation and database scopes. The controller manages authentication, parameter filtering, and response formatting. The routing file generates all seven REST routes with one line. Everything talks to everything else seamlessly.

The before_action callbacks run automatically, strong parameters prevent mass assignment vulnerabilities, and ActiveRecord associations load related data efficiently. I didn't have to think about any of this because Rails made the decisions and implemented them correctly.

Fastify required me to build this functionality piece by piece, but gave me more control over the implementation.

schemas/product.js
const productSchema = {
  type: 'object',
  required: ['name', 'price'],
  properties: {
    name: { type: 'string', minLength: 2 },
    price: { type: 'number', minimum: 0.01 },
    description: { type: 'string' },
    category: { type: 'string' }
  }
};

const productResponseSchema = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    name: { type: 'string' },
    price: { type: 'number' },
    description: { type: 'string' },
    category: { type: 'string' },
    createdAt: { type: 'string', format: 'date-time' }
  }
};

module.exports = { productSchema, productResponseSchema };
routes/products.js
const { productSchema, productResponseSchema } = require('../schemas/product');

async function productRoutes(fastify, options) {
  // Get products with automatic validation and serialization
  fastify.get('/products', {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          category: { type: 'string' },
          page: { type: 'integer', minimum: 1, default: 1 },
          limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
        }
      },
      response: {
        200: {
          type: 'array',
          items: productResponseSchema
        }
      }
    }
  }, async (request, reply) => {
    const { category, page, limit } = request.query;
    const offset = (page - 1) * limit;

    let query = fastify.db.products.where({ available: true });
    if (category) query = query.where({ category });

    const products = await query.offset(offset).limit(limit);
    return products;
  });

  // Create product with validation
  fastify.post('/products', {
    schema: {
      body: productSchema,
      response: {
        201: productResponseSchema,
        400: {
          type: 'object',
          properties: {
            error: { type: 'string' },
            validation: { type: 'array' }
          }
        }
      }
    }
  }, async (request, reply) => {
    try {
      const product = await fastify.db.products.insert(request.body);
      reply.code(201);
      return product;
    } catch (error) {
      reply.code(400);
      return { error: error.message };
    }
  });
}

module.exports = productRoutes;

The schema-first approach is really clever. Fastify validates incoming requests against the schema, automatically serializes responses, and generates OpenAPI documentation from the same definitions. It's more work upfront, but the validation happens before my business logic runs, and the documentation stays in sync automatically.

I had to choose my database query approach (I went with Knex), set up error handling manually, and define every schema explicitly. But the result is a system that validates at the network boundary and gives me complete control over query optimization.

Database integration and queries

The endpoint implementations showed me something interesting. Rails handles database operations with almost magical simplicity, while Fastify makes me think about every query. This difference becomes really apparent when building complex features.

Rails provides ActiveRecord, which honestly spoils you with how easy it makes complex database operations.

 
# Complex queries with ActiveRecord
class Product < ApplicationRecord
  has_many :reviews, dependent: :destroy
  belongs_to :category

  scope :popular, -> { joins(:reviews).group('products.id').having('AVG(reviews.rating) > ?', 4.0) }
  scope :recent, -> { where('created_at > ?', 1.month.ago) }
end

# Controller with eager loading and caching
class Api::ProductsController < ApplicationController
  def bestsellers
    @products = Rails.cache.fetch('bestsellers', expires_in: 1.hour) do
      Product.includes(:reviews, :category)
             .popular
             .recent
             .order('AVG(reviews.rating) DESC')
             .limit(10)
    end
    render json: @products, include: [:reviews, :category]
  end

  def search
    @products = Product.includes(:category)
                       .where('name ILIKE ? OR description ILIKE ?', 
                              "%#{params[:q]}%", "%#{params[:q]}%")
                       .page(params[:page])
    render json: @products
  end
end

This code handles so much complexity behind the scenes it's almost unfair. The includes method prevents N+1 queries by eager loading associations. Rails.cache automatically handles cache invalidation. The scope chains build efficient SQL queries. The page method (from Kaminari gem) adds pagination with minimal setup.

ActiveRecord abstracts away database differences. This same code works with PostgreSQL, MySQL, or SQLite without changes. The ORM handles connection pooling, query optimization, and even some caching automatically.

Fastify required me to choose my database approach, but that meant I could optimize exactly where I needed to.

 
// Database plugin with connection pooling
const fp = require('fastify-plugin');
const { Pool } = require('pg');

async function dbConnector(fastify, options) {
  const pool = new Pool({
    host: process.env.DB_HOST,
    database: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    max: 20, // Maximum connections
    idleTimeoutMillis: 30000,
    connectionTimeoutMillis: 2000,
  });

  fastify.decorate('db', {
    query: (text, params) => pool.query(text, params),
    getClient: () => pool.connect()
  });
}

module.exports = fp(dbConnector);

// Optimized product queries
async function productRoutes(fastify, options) {
  // Cache bestsellers with Redis
  fastify.get('/bestsellers', async (request, reply) => {
    const cached = await fastify.redis.get('bestsellers');
    if (cached) {
      return JSON.parse(cached);
    }

    const query = `
      SELECT p.*, c.name as category_name, AVG(r.rating) as avg_rating
      FROM products p
      JOIN categories c ON p.category_id = c.id
      LEFT JOIN reviews r ON p.id = r.product_id
      WHERE p.created_at > NOW() - INTERVAL '1 month'
      GROUP BY p.id, c.name
      HAVING AVG(r.rating) > 4.0
      ORDER BY avg_rating DESC
      LIMIT 10
    `;

    const result = await fastify.db.query(query);
    const products = result.rows;

    await fastify.redis.setex('bestsellers', 3600, JSON.stringify(products));
    return products;
  });

  // Full-text search with PostgreSQL
  fastify.get('/search', {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          q: { type: 'string', minLength: 2 },
          page: { type: 'integer', minimum: 1, default: 1 }
        },
        required: ['q']
      }
    }
  }, async (request, reply) => {
    const { q, page } = request.query;
    const offset = (page - 1) * 20;

    const query = `
      SELECT p.*, c.name as category_name,
             ts_rank(to_tsvector('english', p.name || ' ' || p.description), 
                     plainto_tsquery('english', $1)) as rank
      FROM products p
      JOIN categories c ON p.category_id = c.id
      WHERE to_tsvector('english', p.name || ' ' || p.description) 
            @@ plainto_tsquery('english', $1)
      ORDER BY rank DESC
      OFFSET $2 LIMIT 20
    `;

    const result = await fastify.db.query(query, [q, offset]);
    return result.rows;
  });
}

Writing raw SQL felt scary at first, but it gave me superpowers. I could use PostgreSQL's full-text search features directly, fine-tune connection pool settings for my specific load patterns, and implement caching strategies that made sense for my data access patterns. Of course, I could have chosen Prisma, Sequelize, or another ORM instead, but having that choice was the point.

The tradeoff is clear. Rails gives you powerful abstractions that handle 95% of use cases beautifully. Fastify lets you choose your data layer approach and optimize where needed.

Development workflow and debugging

The database layer differences highlighted something I hadn't expected. These frameworks have completely different approaches to developer feedback and debugging. Rails tries to tell you everything, while Fastify gives you the tools to figure it out yourself.

Rails provides an incredibly rich development environment.

 
# Rails development workflow
rails console
> Product.where(price: 0).explain
> Product.connection.execute("EXPLAIN ANALYZE SELECT * FROM products")

# Built-in debugging
rails server
# Automatic reloading, detailed error pages, SQL logging
 
# Debug with built-in tools
class ProductsController < ApplicationController
  def create
    Rails.logger.info "Creating product with params: #{product_params}"
    @product = Product.new(product_params)

    # Rails shows exactly which validation failed
    unless @product.save
      Rails.logger.error "Product validation failed: #{@product.errors.full_messages}"
      render json: { errors: @product.errors }, status: :unprocessable_entity
    end
  end
end

The Rails console is like having a REPL for your entire application. You can test database queries, experiment with model relationships, and debug business logic interactively. When something breaks, Rails shows you exactly what went wrong with detailed stack traces and helpful error messages.

The development server automatically reloads your code, logs all SQL queries with execution times, and shows you exactly which files changed. When you make a mistake, the error page includes the full stack trace, highlighted source code, and even an interactive console to inspect variables.

Fastify takes a different approach. It gives you excellent logging tools and gets out of your way.

 
// Development setup with debugging
const fastify = require('fastify')({
  logger: {
    level: 'debug',
    development: true,
    transport: {
      target: 'pino-pretty',
      options: {
        colorize: true,
        translateTime: 'HH:MM:ss Z',
        ignore: 'pid,hostname'
      }
    }
  }
});

// Request/response logging
fastify.addHook('onRequest', async (request, reply) => {
  request.log.info({ url: request.url, method: request.method }, 'Request received');
});

fastify.addHook('onResponse', async (request, reply) => {
  request.log.info({ 
    statusCode: reply.statusCode, 
    responseTime: reply.getResponseTime() 
  }, 'Request completed');
});

// Error handling with detailed logging
fastify.setErrorHandler((error, request, reply) => {
  request.log.error({
    error: error.message,
    stack: error.stack,
    url: request.url,
    method: request.method,
    body: request.body
  }, 'Request failed');

  reply.status(500).send({
    error: 'Internal Server Error',
    message: process.env.NODE_ENV === 'development' ? error.message : undefined
  });
});

// Route with detailed debugging
fastify.post('/products', async (request, reply) => {
  request.log.debug({ body: request.body }, 'Creating product');

  try {
    const product = await createProduct(request.body);
    request.log.info({ productId: product.id }, 'Product created successfully');
    return product;
  } catch (error) {
    request.log.error({ error: error.message }, 'Failed to create product');
    throw error;
  }
});

The logging system is incredibly fast and produces structured JSON that's easy to search and analyze. The hooks let you trace exactly how requests flow through your application, and the error handling gives you complete control over how failures are reported and handled.

The downside is that you have to set up all this debugging infrastructure yourself. The upside is that you can customize it exactly for your needs and the performance impact is minimal even in production.

TypeScript integration and type safety

Something I hadn't expected from this comparison was how much the TypeScript experience would differ between the two frameworks. Rails treats types as an optional add-on, while Fastify makes them a first-class citizen.

Rails relies on external tooling for type safety.

 
# Optional type checking with Sorbet
# typed: true
class Product < ApplicationRecord
  sig { params(name: String, price: Float).returns(Product) }
  def self.create_with_validation(name:, price:)
    product = new(name: name, price: price)
    product.save!
    product
  end

  sig { returns(String) }
  def display_name
    "#{name} ($#{price})"
  end
end

# Runtime type checking with gems
class ProductsController < ApplicationController
  def create
    # Parameter validation happens at runtime
    @product = Product.new(product_params)
    if @product.save
      render json: @product
    else
      render json: { errors: @product.errors }
    end
  end
end

Sorbet adds static typing to Ruby, but it feels like fighting against the language rather than working with it. You have to add type annotations everywhere, and the type checker can't infer much from Rails' dynamic features like has_many associations or scope chains.

Most Rails apps I've worked on skip static typing entirely and rely on comprehensive test suites to catch type-related bugs. This works, but you only find problems at runtime or during testing.

Fastify's TypeScript integration feels completely natural.

types/product.ts
interface Product {
  id: string;
  name: string;
  price: number;
  description?: string;
  category: string;
  createdAt: Date;
  updatedAt: Date;
}

interface CreateProductRequest {
  name: string;
  price: number;
  description?: string;
  category: string;
}

interface ProductResponse {
  success: boolean;
  product?: Product;
  error?: string;
}
routes/products.ts
import { FastifyInstance, FastifyPluginOptions, FastifyRequest, FastifyReply } from 'fastify';

interface ProductQuerystring {
  category?: string;
  page?: number;
  limit?: number;
}

interface CreateProductBody extends CreateProductRequest {}

async function productRoutes(
  fastify: FastifyInstance, 
  options: FastifyPluginOptions
): Promise<void> {

  fastify.get<{
    Querystring: ProductQuerystring;
    Reply: Product[];
  }>('/products', {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          category: { type: 'string' },
          page: { type: 'integer', minimum: 1, default: 1 },
          limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
        }
      }
    }
  }, async (
    request: FastifyRequest<{ Querystring: ProductQuerystring }>, 
    reply: FastifyReply
  ): Promise<Product[]> => {
    const { category, page = 1, limit = 20 } = request.query;

    // TypeScript catches type mismatches at compile time
    const products: Product[] = await fastify.db.products
      .where({ available: true })
      .modify((query) => {
        if (category) query.where({ category });
      })
      .offset((page - 1) * limit)
      .limit(limit);

    return products;
  });

  fastify.post<{
    Body: CreateProductBody;
    Reply: ProductResponse;
  }>('/products', {
    schema: {
      body: {
        type: 'object',
        required: ['name', 'price', 'category'],
        properties: {
          name: { type: 'string', minLength: 2 },
          price: { type: 'number', minimum: 0.01 },
          description: { type: 'string' },
          category: { type: 'string' }
        }
      }
    }
  }, async (
    request: FastifyRequest<{ Body: CreateProductBody }>, 
    reply: FastifyReply
  ): Promise<ProductResponse> => {
    try {
      // TypeScript ensures type safety
      const productData: CreateProductRequest = request.body;
      const product = await createProduct(productData);

      return { success: true, product };
    } catch (error) {
      return { 
        success: false, 
        error: error instanceof Error ? error.message : 'Unknown error' 
      };
    }
  });
}

export default productRoutes;

The generic type system lets you specify exactly what each route expects and returns. TypeScript catches mismatches between your JSON schemas and TypeScript interfaces at compile time. Your IDE provides accurate autocomplete and refactoring throughout your codebase.

The combination of JSON Schema validation at runtime and TypeScript checking at compile time means you catch type errors early and maintain API contracts automatically. It's the kind of developer experience that makes you wonder how you worked without it.

Final thoughts

After trying both Rails and Fastify, I realized the choice is not about speed or feature lists. It is about which development style fits your project and team.

Rails was perfect when I had to deliver an MVP fast. Built-in tools for admin panels, authentication, payments, and deployment let me focus on business logic and ship a prototype in two weeks.

Fastify worked better when I needed custom flows, caching, and integrations outside Rails conventions. Its plugin system let me build exactly what I wanted without fighting the framework.

Rails is best for quick iteration and standard apps. Fastify is best when you need control, performance, or unique architectures

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.