Ruby on Rails Zero to Advanced #10: JSON APIs, N+1, Background Jobs, Caching & Deployment
Welcome to the final part of the series. Our blog has models, CRUD, authentication and tests. Now we prepare it for real traffic: we expose a JSON API, hunt down N+1 queries with includes, move slow work into background jobs with Active Job, add caching, and finish with a deployment checklist. Each topic deserves its own book, so the goal here is to show the essential pattern and the output you should expect.
Building a JSON API
Keep API endpoints in a versioned namespace so you can change them later without breaking existing clients:
# config/routes.rb
namespace :api do
namespace :v1 do
resources :articles, only: [:index, :show, :create]
end
end
API controllers inherit from ActionController::API, a lighter base class without views, cookies or CSRF protection. Clients authenticate with a token in the Authorization header instead of a session. Add has_secure_token :api_token to User (with an api_token string column) to generate one per user.
# app/controllers/api/v1/articles_controller.rb
module Api
module V1
class ArticlesController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate, only: :create
rescue_from ActiveRecord::RecordNotFound do
render json: { error: "Not found" }, status: :not_found
end
def index
articles = Article.includes(:user).where(published: true)
.order(created_at: :desc).limit(20)
render json: articles.map { |article| serialize(article) }
end
def show
render json: serialize(Article.find(params[:id]))
end
def create
article = @api_user.articles.new(params.expect(article: [:title, :body]))
if article.save
render json: serialize(article), status: :created
else
render json: { errors: article.errors }, status: :unprocessable_entity
end
end
private
def authenticate
@api_user = authenticate_with_http_token do |token, _options|
User.find_by(api_token: token)
end
render json: { error: "Unauthorized" }, status: :unauthorized unless @api_user
end
def serialize(article)
{
id: article.id,
title: article.title,
author: article.user.name,
published_at: article.created_at.iso8601
}
end
end
end
end
Test it with curl. The token is read from an environment variable, never pasted into scripts:
curl -s http://localhost:3000/api/v1/articles
curl -s http://localhost:3000/api/v1/articles/999
curl -s -X POST http://localhost:3000/api/v1/articles \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"article": {"title": "Hi"}}'
Output:
[{"id":2,"title":"Active Record tips","author":"Siti","published_at":"2026-10-13T03:21:05Z"},{"id":1,"title":"Hello Rails","author":"Budi","published_at":"2026-10-13T03:20:11Z"}]
{"error":"Not found"}
{"errors":{"title":["is too short (minimum is 5 characters)"]}}
For larger APIs, move the serialize logic into a dedicated serializer class or Jbuilder views (app/views/api/v1/articles/index.json.jbuilder), and always return meaningful status codes: 200, 201, 401, 404, 422.
N+1 queries and includes
The most common Rails performance bug is the N+1 query: one query loads N articles, then each article triggers another query for its user. Remove includes(:user) from index and watch the log:
Output (without includes):
Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."published" = ? ORDER BY "articles"."created_at" DESC LIMIT ? [["published", 1], ["LIMIT", 20]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
... one more query per article ...
With 20 articles that is 21 queries. includes preloads all users in a single extra query:
Output (with includes):
Article Load (0.3ms) SELECT "articles".* FROM "articles" WHERE "articles"."published" = ? ORDER BY "articles"."created_at" DESC LIMIT ? [["published", 1], ["LIMIT", 20]]
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?) [["id", 2], ["id", 1], ["id", 3]]
Two queries, no matter how many articles. Nested associations work too: Article.includes(:user, comments: :user). To catch N+1 early, enable strict_loading on a relation or model, which raises an error when an association is lazy-loaded:
article = Article.strict_loading.first
article.user
Output:
(blog):2:in '<main>': `Article` is marked for strict_loading. The User association named `:user` cannot be lazily loaded. (ActiveRecord::StrictLoadingViolationError)
For counts, avoid article.comments.count in a loop (one COUNT per row). Use a counter cache: add an integer comments_count column and declare belongs_to :article, counter_cache: true in Comment.
Background jobs with Active Job
Anything slow or unreliable (sending emails, calling external APIs, resizing images, generating reports) should not run inside the web request. Put it in a job:
bin/rails generate job PublishNotification
# app/jobs/publish_notification_job.rb
class PublishNotificationJob < ApplicationJob
queue_as :default
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
def perform(article)
Rails.logger.info "Notifying readers about article #{article.id}"
# e.g. ArticleMailer.with(article: article).published.deliver_now
end
end
# in ArticlesController#create, after a successful save:
PublishNotificationJob.perform_later(@article)
Output (development log, async adapter):
[ActiveJob] Enqueued PublishNotificationJob (Job ID: 3f1c9a52-8d47-4b8e-9a51-2c6e0d7b4a10) to Async(default) with arguments: #<GlobalID:0x000000012f8a1c38 @uri=#<URI::GID gid://blog/Article/5>>
[ActiveJob] [PublishNotificationJob] [3f1c9a52-8d47-4b8e-9a51-2c6e0d7b4a10] Performing PublishNotificationJob (Job ID: 3f1c9a52-8d47-4b8e-9a51-2c6e0d7b4a10) from Async(default) enqueued at 2026-10-13T08:45:12.345678Z with arguments: #<GlobalID:0x000000012f9b2d40 @uri=#<URI::GID gid://blog/Article/5>>
[ActiveJob] [PublishNotificationJob] [3f1c9a52-8d47-4b8e-9a51-2c6e0d7b4a10] Notifying readers about article 5
[ActiveJob] [PublishNotificationJob] [3f1c9a52-8d47-4b8e-9a51-2c6e0d7b4a10] Performed PublishNotificationJob (Job ID: 3f1c9a52-8d47-4b8e-9a51-2c6e0d7b4a10) from Async(default) in 12.4ms
Notice the argument is a GlobalID, not the whole object: Active Job stores a reference and reloads the record when the job runs. In production, Rails 8 uses Solid Queue, a database-backed queue, so you do not need Redis. Run workers with bin/jobs, or inside Puma with SOLID_QUEUE_IN_PUMA=true on small apps. Schedule jobs with PublishNotificationJob.set(wait: 10.minutes).perform_later(article).
Caching
Caching is disabled in development by default. Toggle it with:
bin/rails dev:cache
Output:
Development mode is now being cached.
Fragment caching stores rendered HTML. The cache key includes the record's id and updated_at, so editing an article automatically produces a new key:
<!-- app/views/articles/index.html.erb -->
<%= render partial: "article", collection: @articles, cached: true %>
Output (second request, after Budi edited one article):
Rendered collection of articles/_article.html.erb [2 / 3 cache hits] (Duration: 1.9ms | GC: 0.0ms)
If a partial also shows comments, add touch: true to belongs_to :article in Comment so a new comment updates the article's updated_at and expires its cache. Low-level caching stores any value:
Rails.cache.fetch("stats/published_count", expires_in: 10.minutes) do
Article.where(published: true).count
end
Rails.cache.fetch("stats/published_count", expires_in: 10.minutes) do
Article.where(published: true).count
end
Output:
Article Count (0.2ms) SELECT COUNT(*) FROM "articles" WHERE "articles"."published" = ? [["published", 1]]
=> 2
=> 2
The second call returned from the cache without touching the database. In production, Rails 8 defaults to Solid Cache, which also lives in the database.
Deployment checklist
Rails 8 generates a production-ready Dockerfile and a Kamal config for deploying to any server. Whatever platform you choose, go through this list:
- Secrets from the environment. Provide
RAILS_MASTER_KEYandDATABASE_URLas secrets; never commit them. Read other keys viaRails.application.credentialsorENV.fetch. - Database migrations run on every deploy (
bin/rails db:preparein the Docker entrypoint does this). - Assets precompiled during the image build (
bin/rails assets:precompile). - HTTPS enforced with
config.force_ssl = true. - Job workers running (Solid Queue), otherwise
perform_laterjobs just pile up. - Health check at
/upis wired to your load balancer. - Security and style checks in CI:
bin/brakeman,bin/rubocop,bin/rails test. - Logs, error tracking and database backups configured before launch day, not after.
# config/database.yml (production, PostgreSQL)
production:
primary:
<<: *default
url: <%= ENV["DATABASE_URL"] %>
# config/deploy.yml (Kamal, shortened)
service: blog
image: example/blog
servers:
web:
- app.example.com
proxy:
ssl: true
host: blog.example.com
env:
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
bin/brakeman --quiet --no-pager
curl -s -o /dev/null -w "%{http_code}\n" https://blog.example.com/up
Output:
== Brakeman Report ==
...
== Warnings ==
No warnings found
200
Common mistakes
- Calling associations in loops without includes. Read your development log; a wall of identical queries is an N+1.
- Passing huge objects to jobs. Pass records or ids, not arrays of data, and make
performsafe to run twice (jobs can be retried). - Caching without a key strategy. Cache by record (
cache article) so keys expire automatically. Manual keys that never change serve stale content forever. - Leaking internals in the API.
render json: userexposes every column, includingpassword_digestandapi_token. Always choose fields explicitly. - Deploying without running the tests. Make CI block the deploy when tests or Brakeman fail.
Exercise
Add a comments_count counter cache to articles and include it in the API response, keeping the index at two queries:
# migration
add_column :articles, :comments_count, :integer, default: 0, null: false
# app/models/comment.rb
belongs_to :article, counter_cache: true, touch: true
# backfill existing rows once, in the console
Article.find_each { |article| Article.reset_counters(article.id, :comments) }
# serialize(article)
comments: article.comments_count
Output (curl -s http://localhost:3000/api/v1/articles/1):
{"id":1,"title":"Hello Rails","author":"Budi","published_at":"2026-10-13T03:20:11Z","comments":3}
Summary and what's next
You exposed a versioned JSON API with token authentication, fixed N+1 queries with includes and strict_loading, moved slow work to Active Job, cached fragments and values, and reviewed what a production deployment needs. That completes Ruby on Rails Zero to Advanced: from irb in part 1 to a deployable app in part 10. Good next steps are Hotwire (Turbo Streams and Stimulus) for interactive UIs without heavy JavaScript, Action Mailer and Active Storage, and reading the official Rails Guides end to end. Happy shipping!
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Python Zero to Advanced #8: OOP - Classes, Inheritance, Dunder Methods & Dataclasses
Model real-world things with classes: attributes, methods, class vs instance data, properties, inheritance and super(), dunder methods like __repr__ and __eq__, and modern dataclasses.