When we build APIs in Ruby, how our data moves from our application to the client is not an afterthought. It’s a core part of the design. I’ve spent years tweaking this process, moving from slow, bloated responses to fast, efficient ones. The journey often comes down to choosing the right pattern for the job. Let’s walk through some of the most effective approaches I’ve used.
The simplest idea is often the best. Why send everything if the client only needs a little? I start by defining exactly what data should be in the response. A serializer class is a clean home for this logic. It takes an object and a list of fields, then builds a hash with only those pieces.
class PostSerializer
def initialize(post, fields: nil)
@post = post
@fields = fields || default_fields
end
def to_json
@fields.each_with_object({}) do |field, hash|
hash[field] = send(field) if respond_to?(field, true)
end.to_json
end
private
def default_fields
[:id, :title, :excerpt]
end
def id
@post.id
end
def title
@post.title
end
def excerpt
@post.content.truncate(100)
end
# Other potential fields...
def author_name
@post.user.full_name
end
def comment_count
@post.comments.approved.count
end
end
In the controller, I can now let the client choose. A fields parameter gives them control. If they only want the ID and title for a list view, they get just that. The payload shrinks, the response speeds up, and everyone is happier.
class PostsController < ApplicationController
def index
posts = Post.published
fields = params[:fields]&.split(',')&.map(&:to_sym)
serialized_posts = posts.map do |post|
PostSerializer.new(post, fields: fields).as_json
end
render json: { data: serialized_posts }
end
end
A common problem is the “N+1” API call. A client fetches a blog post, then makes 10 more calls to get the comments, then 10 more for each comment’s author. We can solve this by embedding related data right inside the main response. This is sometimes called a compound document.
The key is predictability. I use an include parameter, much like JSON:API does. The client tells me what they want embedded.
class ArticleSerializer
def initialize(article, includes: [])
@article = article
@includes = includes
end
def as_json
output = {
id: @article.id,
title: @article.title,
body: @article.body
}
if @includes.include?(:author)
output[:author] = AuthorSerializer.new(@article.author).as_json
end
if @includes.include?(:comments)
output[:comments] = @article.comments.map do |comment|
CommentSerializer.new(comment).as_json
end
end
output
end
end
My controller preloads the associated data to avoid database query problems. It then passes the requested inclusions to the serializer.
class ArticlesController < ApplicationController
def show
article = Article.includes(:author, :comments).find(params[:id])
includes = params[:include]&.split(',')&.map(&:to_sym) || []
render json: ArticleSerializer.new(article, includes: includes).as_json
end
end
Now a request to /articles/123?include=author,comments gives the client everything in one trip. It’s a balance. I don’t embed everything by default, but I provide the option. This keeps the common case fast and the complex case possible.
Sometimes, turning an object into JSON is hard work. Maybe it involves complex calculations, statistics, or text processing. Doing this fresh for every request can bring a server to its knees. This is where caching the final result pays off.
I cache the fully serialized JSON string. The trick is the cache key. It must change if anything that affects the output changes. I often use the object’s ID, its updated_at timestamp, and any context like the user’s locale.
class ExpensiveReportSerializer
def self.serialize(report)
cache_key = "serializer/report/v2/#{report.id}/#{report.updated_at.to_i}"
Rails.cache.fetch(cache_key, expires_in: 12.hours) do
compute_serialization(report)
end
end
private
def self.compute_serialization(report)
# This part is slow. We do it once, then cache the result.
analysis = report.run_complex_analysis
{
id: report.id,
summary: analysis.summary,
data_points: analysis.processed_data,
generated_at: Time.current.iso8601
}.to_json
end
end
Caching is useless if the data becomes stale. I ensure the cache is cleared when the underlying object changes. An ActiveRecord callback is a straightforward place to do this.
class Report < ApplicationRecord
after_commit :expire_serialization_cache
private
def expire_serialization_cache
cache_key = "serializer/report/v2/#{id}/#{updated_at_before_last_save.to_i}"
Rails.cache.delete(cache_key)
end
end
When I see the response time drop from 2 seconds to 2 milliseconds for cached objects, the extra complexity feels worth it.
What happens when I need to send ten thousand records? Loading them all into memory and calling to_json will likely run out of memory or be painfully slow. The answer is to stream the response piece by piece.
Instead of building one giant JSON string, I build it as a series of chunks. I start with a [, send each record as its own JSON object, separate them with commas, and end with a ]. The client starts receiving data immediately.
class StreamingSerializer
def initialize(relation, serializer_class)
@relation = relation
@serializer_class = serializer_class
end
def each_chunk
# Start the JSON array
yield "[\n"
first = true
@relation.find_each(batch_size: 500) do |record|
# Add a comma before all but the first record
unless first
yield ",\n"
end
first = false
# Yield the serialized record
yield @serializer_class.new(record).as_json.to_json
end
# Close the JSON array
yield "\n]"
end
end
In the controller, I set up a streaming response. I use Enumerator to create a lazy stream of chunks that Rack can send to the client.
class DataExportController < ApplicationController
def large_export
# This could be millions of records
users = User.where(created_at: 1.year.ago..)
stream = StreamingSerializer.new(users, BasicUserSerializer)
headers['Content-Type'] = 'application/json'
headers['Content-Disposition'] = 'attachment; filename="users.json"'
self.response_body = Enumerator.new do |yielder|
stream.each_chunk do |chunk|
yielder << chunk
end
end
end
end
The server’s memory usage stays flat. The client gets a valid, incremental download. For background jobs or admin exports, this pattern is a lifesaver.
As an API grows, its responses become a contract. Different clients—a web app, a mobile app, a partner service—might expect different shapes of data. Breaking this contract breaks their application. I manage this with versioning and adaptation.
One way is to put the version in the URL or a header. The serializer then branches its logic based on that version.
class AdaptiveProductSerializer
def initialize(product, api_version: 'v1')
@product = product
@api_version = api_version
end
def as_json
case @api_version
when 'v1'
v1_serialization
when 'v2'
v2_serialization
when 'internal'
internal_serialization
else
v1_serialization
end
end
private
def v1_serialization
# Legacy shape for old mobile apps
{
product_id: @product.id,
product_name: @product.name,
cost: @product.price
}
end
def v2_serialization
# New, nested structure
{
data: {
type: 'products',
id: @product.id,
attributes: {
name: @product.name,
price: {
amount: @product.price,
currency: 'USD'
}
}
}
}
end
end
The controller detects the version from the request.
class Api::ProductsController < ApplicationController
before_action :set_api_version
def show
product = Product.find(params[:id])
serializer = AdaptiveProductSerializer.new(product, api_version: @api_version)
render json: serializer.as_json
end
private
def set_api_version
# Check header first, then URL path, default to v1
@api_version = request.headers['X-API-Version'] || 'v1'
end
end
This lets me improve the API without shutting down old clients. I can add new fields to v2 without affecting the v1 consumers. It’s a bit more code, but it prevents many support headaches.
Most serialization creates text (JSON, XML). But for internal services or performance-critical paths, binary formats are far faster and smaller. Ruby’s Marshal module is one option, but it’s Ruby-specific. I sometimes design a simple custom binary format.
I define a structure using pack and unpack. A binary header tells me the type of data and its length.
class BinaryStatusSerializer
# Format: [32-bit message length][32-bit status code][message bytes]
HEADER_FORMAT = 'N2'
def self.serialize(status_code, message)
message_bytes = message.dup.force_encoding('BINARY')
header = [message_bytes.bytesize, status_code].pack(HEADER_FORMAT)
header + message_bytes
end
def self.deserialize(binary_data)
msg_length, status_code = binary_data[0..7].unpack(HEADER_FORMAT)
message = binary_data[8..-1].force_encoding('UTF-8')
{
status: status_code,
message: message
}
end
end
In an endpoint, I send it as plain binary. This isn’t for a browser, but for another service I control.
class Internal::StatusController < ApplicationController
skip_forgery_protection # Common for internal APIs
def heartbeat
status_code = 200
message = "OK #{Time.current.iso8601}"
binary_data = BinaryStatusSerializer.serialize(status_code, message)
send_data binary_data,
type: 'application/octet-stream',
disposition: 'inline'
end
end
The consuming service can unpack it in microseconds. For high-throughput internal communication, this beats parsing JSON every time. It’s a specialized tool, but it’s incredibly effective in the right spot.
Finally, there are times when I need to see everything. Not for a production API, but for debugging, logging, or internal admin tools. I need to serialize an entire object graph—the main object, its associations, their associations, and so on—while avoiding infinite loops.
I write a serializer that walks the graph like a detective, mapping connections.
class DebugSerializer
def initialize(root, max_depth: 2)
@root = root
@max_depth = max_depth
@visited = {} # Tracks object_id to prevent cycles
@output = {}
end
def serialize
explore(@root, 'root', 0)
@output
end
private
def explore(object, path, depth)
# Stop if we've gone too deep, hit a nil, or seen this object before
return if depth > @max_depth
return if object.nil?
object_key = "#{object.class.name}:#{object.object_id}"
return if @visited[object_key]
@visited[object_key] = true
# Store basic info about this node
@output[path] = {
class: object.class.name,
id: object.try(:id),
attributes: safe_attributes(object)
}
# If it's an ActiveRecord object, explore its associations
if object.class.respond_to?(:reflect_on_all_associations)
object.class.reflect_on_all_associations.each do |assoc|
next if assoc.options[:serialize] == false # Skip sensitive relations
associated_object = object.send(assoc.name)
next if associated_object.nil?
# Handle both single associations and collections
targets = Array(associated_object)
targets.each_with_index do |target, idx|
new_path = "#{path}.#{assoc.name}[#{idx}]"
explore(target, new_path, depth + 1)
end
end
end
end
def safe_attributes(object)
obj_attributes = object.attributes rescue {}
# Filter out sensitive fields
obj_attributes.except('password_digest', 'ssn', 'api_key')
end
end
I can use this in a debug endpoint to get a full picture of why a complex object is in a certain state.
class Admin::DebugController < ApplicationController
before_action :authorize_admin!
def object_web
order = Order.find(params[:id])
serializer = DebugSerializer.new(order, max_depth: 3)
render json: serializer.serialize
end
end
This gives me a tree view of my data, which is invaluable for tracking down tricky bugs. It’s not for external use, but it’s a powerful internal tool.
No single pattern is always correct. The field selector is my daily workhorse. The compound document pattern keeps client applications snappy. Caching saves me from repeated expensive work. Streaming handles the giant datasets. Versioning keeps the peace with various clients. Binary serialization wins on pure speed for internal calls. The debug serializer is my investigative lens.
The real skill is in knowing which tool to reach for. I start by asking: Who is the client? What is the scale? What is the purpose? The answers point me toward the right pattern. Keeping this process efficient isn’t a one-time task. It’s an ongoing part of building APIs that are fast, reliable, and a pleasure to use.