ActiveStorage::AsyncVariants
Extends Active Storage with pluggable per-variant transformers, async-safe variant processing, and failure handling.
The Problem
Active Storage's variant system assumes transformations are fast and reliable -- like generating an image thumbnail. But some transformations are slow (transcoding a 1GB video to 720p VP9) and fallible (the transcode may permanently fail). When you use process: :later, Active Storage enqueues a background job, but if the variant is requested before the job finishes, it falls through to synchronous processing -- blocking the request for minutes or timing out entirely. And if the transformation fails, the error bubbles up with no tracking or retry limits.
Installation
gem "active_storage-async_variants"
bin/rails active_storage_async_variants:install:migrations
bin/rails db:migrate
Usage
Add processing: to any named variant to opt into the async pipeline. The value is what to serve while the variant is being processed:
class User < ApplicationRecord
has_one_attached :video do |attachable|
attachable.variant :web,
transformer: VideoTranscoder,
codec: "vp9",
resolution: "720p",
processing: :original
end
end
The presence of processing: is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The transformer: option is independent -- you can use a custom transformer synchronously, or use the default transformer asynchronously:
has_one_attached :video do |attachable|
# Async with custom transformer (video transcode)
attachable.variant :web,
transformer: VideoTranscoder,
codec: "vp9",
processing: :original
# Async with default transformer (large image resize that's too slow for inline)
attachable.variant :thumbnail,
resize_to_limit: [200, 200],
processing: :original
# Sync with custom transformer (fast custom processing, no opt-in needed)
attachable.variant :watermarked,
transformer: WatermarkStamper
end
In views, use the same Active Storage helpers:
<%= video_tag user.video.variant(:web).url %>
If the variant is still processing, this serves the original video. Once processing completes, it serves the transcoded variant.
image_tag / video_tag with async: and direct:
For a polish layer that swaps in placeholder content while a variant is being processed, polls for completion in the background, and optionally serves the finished variant straight from your CDN, the gem adds two options to image_tag and video_tag:
<%# Variant URL with the async client wired up: spinner while pending,
polls for completion, swaps to the real image when ready. %>
<%= image_tag user.avatar.variant(:web), async: true %>
<%# When the variant is ready, render its direct CDN/S3 URL instead of
routing through Rails. While pending/failed, falls back to the
Rails representation URL (which serves the placeholder). %>
<%= image_tag user.avatar.variant(:web), direct: true %>
<%# Both together: direct URL once processed, placeholder while pending,
polling for completion. %>
<%= image_tag user.avatar.variant(:web), async: true, direct: true %>
<%# Same for videos. %>
<%= video_tag user.video.variant(:web), async: true, controls: true %>
The first argument must be a VariantWithRecord or Preview when either option is set; otherwise image_tag / video_tag behave exactly as in stock Rails.
Configure the direct URL host
By default direct: uses the storage service's URL (presigned for private buckets, unsigned for public). To serve from a CDN, set the host in an initializer:
# config/initializers/active_storage_async_variants.rb
ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
The resulting URL is "#{cdn_host}/#{variant.key}".
Wiring the JavaScript
The gem ships a dependency-free Stimulus-like controller at app/assets/javascripts/active_storage_async_variants.js. It is registered with the asset pipeline by the engine but not auto-imported. Wire it up the same way as @rails/activestorage -- pick whichever fits your bundler:
Using importmap-rails:
# config/importmap.rb
pin "@active_storage/async_variants", to: "active_storage_async_variants.js", preload: true
// app/javascript/application.js
import "@active_storage/async_variants"
Using the asset pipeline (sprockets/propshaft) with a classic javascript_include_tag:
<%= javascript_include_tag "active_storage_async_variants" %>
The module auto-starts on DOMContentLoaded and finds elements with data-async-variant-state-value. Opt out by setting window.ActiveStorageAsyncVariants = null before the script loads, then calling start() yourself from the exported module when ready.
Writing a Transformer
Transformers come in two flavors: inline (the job blocks until processing completes) and external (the job kicks off remote work and a webhook signals completion).
External Transformers (recommended for slow work)
An external transformer delegates to a remote service and returns immediately, freeing up the job worker. The remote service uploads the result directly to storage and hits a callback URL when done.
class LambdaTranscoder < ActiveStorage::AsyncVariants::Transformer
def initiate(source_url:, destination_url:, callback_url:, **)
Http.post("https://transcode.example.com/jobs",
source_url: source_url,
destination_url: destination_url,
callback_url: callback_url,
codec: [:codec],
resolution: [:resolution],
)
end
end
The gem calls initiate with:
source_url-- a presigned GET URL for the original filedestination_url-- a presigned PUT URL where the result should be uploadedcallback_url-- a signed webhook URL to POST to when done
The remote service does its work (which could take minutes or hours), uploads the result to destination_url, then POSTs to callback_url:
POST <callback_url>
Content-Type: application/json
{ "status": "success", "content_type": "video/webm", "byte_size": 52428800 }
Or on failure:
POST <callback_url>
Content-Type: application/json
{ "status": "failed", "error": "ffmpeg exited with status 1" }
The callback URL is signed -- no authentication is needed on the caller's side.
Callback Endpoint
The gem mounts a callback endpoint at:
POST /active_storage/async_variants/callbacks/:token
The :token is a signed, single-use token that identifies the variant record. The gem generates this URL and passes it to initiate as callback_url. Your external service just POSTs to it -- no API keys or authentication headers required.
Expected request body:
{ "status": "success", "content_type": "video/webm", "byte_size": 52428800 }
{ "status": "failed", "error": "ffmpeg exited with status 1" }
Inline Transformers (simpler, blocks the worker)
For cases where you're running the transformation locally (e.g., ffmpeg on the same machine), an inline transformer blocks until done:
class LocalTranscoder < ActiveStorage::AsyncVariants::Transformer
def process(file, **)
output = Tempfile.new(["output", ".webm"])
system("ffmpeg", "-i", file.path,
"-c:v", "libvpx-vp9",
"-vf", "scale=-2:#{[:resolution]&.delete("p") || 720}",
"-c:a", "libopus",
output.path,
exception: true,
)
{ io: output, content_type: "video/webm", filename: "video.webm" }
end
end
The process method receives the source file and all non-reserved options from the variant definition. It returns a hash with io:, content_type:, and filename:.
The gem determines the mode by which method the transformer implements: initiate for external, process for inline.
Checking Variant State
variant = user.video.variant(:web)
variant.processed? # => true if processed successfully
variant.processing? # => true if job is running or external service is working
variant.pending? # => true if job is enqueued
variant.failed? # => true if permanently failed
variant.error # => error message string, or nil
Placeholder Options
The processing: option controls what gets served while a variant is being processed. The failed: option (optional) controls what gets served once the variant has permanently failed; if omitted, it defaults to the processing: value.
# Serve the original unprocessed file while processing
attachable.variant :web, processing: :original
# Return nil -- let the view handle it
attachable.variant :web, processing: :blank
# Static URL while processing
attachable.variant :web, processing: "/placeholders/processing.svg"
# Dynamic placeholder via Proc
attachable.variant :web,
processing: -> (blob) { "/placeholders/processing.svg" }
# Distinct placeholder when permanently failed
attachable.variant :web,
processing: :original,
failed: "/icons/broken.svg"
Both options accept the same set of values: :original, :blank, a String URL, or a Proc that receives the blob.
Failure Handling
A variant is retried up to 3 times before being marked as permanently failed. Once permanently failed, .processed no longer re-enqueues the job — the variant stays failed until you delete the ActiveStorage::VariantRecord row manually (or re-attach a fresh blob).
Inspect failures:
variant = user.video.variant(:web)
variant.failed? # => true
variant.error # => "ffmpeg exited with status 1: ..."
How It Works
External transformer flow
- User uploads a file to an attachment that has async variants defined
- After attachment, a background job is enqueued for each async variant
VariantRecordis created with statepending- The job calls the transformer's
initiatemethod with presigned source/destination URLs and a signed callback URL, then exits -- the worker is free - The external service processes the file, uploads the result to the destination URL
- The external service POSTs to the callback URL with success/failure status
- The gem's callback endpoint transitions the
VariantRecordtoprocessedorfailed - When a view requests the variant URL, the gem checks state and serves the variant or the
processing:/failed:placeholder
Inline transformer flow
1-3. Same as above
- The job calls the transformer's
processmethod, blocking until complete - On success, the output is uploaded, the
VariantRecordtransitions toprocessed - On failure, the error is recorded and the job is re-enqueued (up to 3 attempts)
- When a view requests the variant URL, the gem checks state and serves the variant or the
processing:/failed:placeholder
License
MIT