class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb,
resize_to_limit: [256, 256],
convert: :webp,
saver: { quality: 80 }
end
validate :acceptable_avatar
after_commit :prewarm_avatar, on: %i[create update], if: -> { avatar.attached? && saved_changes? }
def avatar_thumb
avatar.variant(:thumb)
end
private
def acceptable_avatar
return unless avatar.attached?
unless avatar.blob.content_type.in?(%w[image/png image/jpeg image/webp])
errors.add(:avatar, "must be a PNG, JPEG, or WebP")
end
if avatar.blob.byte_size > 8.megabytes
errors.add(:avatar, "is too large (max 8 MB)")
end
end
def prewarm_avatar
AvatarPrewarmJob.perform_later(self)
end
end
class AvatarsController < ApplicationController
before_action :authenticate_user!
def update
current_user.avatar.attach(avatar_param)
if current_user.save
redirect_to edit_profile_path, notice: "Avatar updated."
else
current_user.avatar.purge_later if current_user.avatar.attached?
flash.now[:alert] = current_user.errors.full_messages_for(:avatar).to_sentence
render "profiles/edit", status: :unprocessable_entity
end
end
def show
return head :not_found unless current_user.avatar.attached?
redirect_to rails_representation_url(current_user.avatar_thumb), allow_other_host: false
end
def destroy
current_user.avatar.purge_later
redirect_to edit_profile_path, notice: "Avatar removed."
end
private
def avatar_param
params.require(:user).permit(:avatar).fetch(:avatar)
end
end
class AvatarPrewarmJob < ApplicationJob
queue_as :low
discard_on ActiveRecord::RecordNotFound
retry_on ActiveStorage::FileNotFoundError, wait: 5.seconds, attempts: 3
def perform(user)
return unless user.avatar.attached?
# Force the variant to be generated and cached in the storage service now,
# so the first real request does not pay the resize cost.
user.avatar_thumb.processed
end
end
This snippet shows the idiomatic Rails way to accept an avatar upload, validate it, and serve a resized variant without ever storing a second file up front. Active Storage variants are computed lazily and cached in the same service (S3, disk, GCS), so the model only declares how the image should be transformed while the blob stays authoritative.
In User model, has_one_attached :avatar wires up the association, and a named avatar_thumb block centralizes the transformation via variant. Using a named variant keeps the resize spec (resize_to_limit, convert: :webp, saver quality) in one place so views and jobs never repeat magic numbers. The resize_to_limit transform preserves aspect ratio and never upscales, which is the correct choice for user-supplied images of unknown dimensions. acceptable_avatar runs on save and rejects blobs by content_type and byte_size before anything is processed — validating the blob rather than the variant matters because variant generation is expensive and untrusted input should be rejected early.
In AvatarsController, update calls attach on the association and relies on the model validation to gate the write; avatar.attach stages the blob, and save triggers the callback. On failure the controller re-renders with unprocessable_entity so form errors surface normally. The show-style redirect uses rails_representation_url to hand back a URL for the processed variant rather than streaming bytes through the app.
The enqueue avatar prewarm concern demonstrates a common production refinement: because variants are generated on first request, the very first viewer pays the resize cost. after_commit enqueues AvatarPrewarmJob which calls processed on the variant to force generation ahead of time, warming the cache. This trades a little background work for predictable request latency.
A key pitfall is calling variant in a hot loop or in views without the named helper — each call re-derives the key; the named variant and processed avoid redundant work. Reaching for this pattern makes sense whenever images are user-uploaded, must be normalized to a safe format and size, and should be served cheaply from object storage.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.