Send a Welcome Email After Signup Using a Sidekiq Background Job in Rails
class RegistrationsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
session[:user_id] = @user.id
redirect_to dashboard_path, notice: "Welcome aboard! Check your inbox."
else
render :new, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :name, :password, :password_confirmation)
end
end
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: { case_sensitive: false }
validates :name, presence: true
after_commit :enqueue_welcome_email, on: :create
def welcome_email_sent?
welcome_email_sent_at.present?
end
private
def enqueue_welcome_email
WelcomeEmailJob.perform_later(id)
end
end
class WelcomeEmailJob < ApplicationJob
queue_as :mailers
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
discard_on ActiveJob::DeserializationError
def perform(user_id)
user = User.find_by(id: user_id)
return if user.nil?
return if user.welcome_email_sent?
UserMailer.with(user: user).welcome.deliver_now
user.touch(:welcome_email_sent_at)
end
end
class UserMailer < ApplicationMailer
default from: "hello@example.com"
def welcome
@user = params[:user]
@dashboard_url = dashboard_url
mail(
to: @user.email,
subject: "Welcome to Acme, #{@user.name}!"
)
end
end
This snippet shows the idiomatic Rails way to send a welcome email after a user signs up without blocking the HTTP request. Sending email inline during a signup request couples the response time to an external SMTP provider, and any slowness or outage in that provider makes signup itself feel slow or fail. Moving delivery to a background job decouples the two: the controller returns quickly and the email is delivered by a worker process, with automatic retries if the provider is temporarily down.
In RegistrationsController, the create action builds and saves a User inside save_and_notify. The important detail is that the email is not enqueued from the controller directly. Instead the model owns that responsibility via an after_commit callback, so the job is only scheduled once the database transaction has actually committed. This avoids a classic race: if the job were enqueued inside the transaction (for example from after_create), a fast worker could pick up the job and query for a User row that has not yet been committed, producing a spurious RecordNotFound.
In User model, enqueue_welcome_email calls WelcomeEmailJob.perform_later(id). Passing the primary key rather than the whole object is deliberate: ActiveJob serializes arguments, and GlobalID would work, but a bare integer id keeps the payload tiny and forces the job to reload fresh state at execution time. The model also exposes welcome_email_sent? backed by a welcome_email_sent_at timestamp column, which is the foundation for idempotency.
In WelcomeEmailJob, the job is a plain ApplicationJob on the mailers queue. It uses find_by rather than find so a deleted user simply returns early instead of raising and burning retries. The return if user.welcome_email_sent? guard makes the job idempotent: Sidekiq guarantees at-least-once execution, meaning a job can run more than once after a crash or a retry, so the code must tolerate duplicate invocations. Delivery uses deliver_now because the job is already asynchronous — calling deliver_later here would pointlessly enqueue a second job. After a successful send, touch(:welcome_email_sent_at) records the fact so subsequent runs short-circuit.
The trade-off worth understanding is the ordering of the guard and the send. If the process dies between deliver_now and touch, the same email could be sent twice; if the timestamp were set first, a crash could drop the email entirely. This implementation favors delivery over strict exactly-once, which is the usual choice for welcome emails where a rare duplicate is harmless but a missing email is not. For stricter guarantees a developer would introduce a provider-level idempotency key.
This pattern generalizes to almost any post-signup side effect — provisioning, analytics, syncing to a CRM — where the work should happen reliably but need not happen synchronously. Keeping the enqueue in after_commit and the idempotency guard in the job are the two habits that make it robust in production.
Share this code
Here's the card — post it anywhere.