Ruby on Rails vs Fastify
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?
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
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.
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
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
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.
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 };
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.
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;
}
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