Prevent Duplicate Form Submissions In Rails With An Idempotency Key
A user double-clicks Submit, or a mobile client retries after a timeout it thought had failed. Two identical requests arrive and you create two orders. Disabling the button in JavaScript does not solve this — the fix has to be on the server.
Description
Generate a unique key when the form is rendered, submit it as a hidden field, and record it in a table with a unique index when the request is processed. A second request carrying the same key hits the constraint and returns the original result instead of creating a duplicate.
The unique index is what makes it correct. A find_by check followed by a create has a race window; the database constraint does not.
For APIs, take the key from an Idempotency-Key header instead of a form field — that is the convention Stripe and most payment providers use.
Expire old keys with a scheduled job so the table does not grow without bound.
Sample input:
POST /orders { idempotency_key: "a3f8c21b...", order: { sku: "ABC-1" } }
Sample Output:
# First request -> creates the order, stores the key
# Second request -> returns the same order, creates nothing
Answer
# Migration
create_table :idempotency_keys do |t|
t.string :key, null: false
t.references :resource, polymorphic: true
t.datetime :created_at, null: false
end
add_index :idempotency_keys, :key, unique: true
add_index :idempotency_keys, :created_at
# Controller
class OrdersController < ApplicationController
def new
@order = Order.new
@idempotency_key = SecureRandom.uuid
end
def create
key = params[:idempotency_key].presence || request.headers["Idempotency-Key"]
return head :bad_request if key.blank?
record = IdempotencyKey.find_by(key: key)
return redirect_to(record.resource) if record
ActiveRecord::Base.transaction do
@order = current_account.orders.create!(order_params)
IdempotencyKey.create!(key: key, resource: @order)
end
redirect_to @order, notice: "Order created"
rescue ActiveRecord::RecordNotUnique
# Lost the race — the other request created it
redirect_to IdempotencyKey.find_by!(key: key).resource
end
end
<%= form_with model: @order do |f| %>
<%= hidden_field_tag :idempotency_key, @idempotency_key %>
<%= f.text_field :sku %>
<%= f.submit %>
<% end %>
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀