/ Tags: RAILS-CODE / Categories: SOLUTIONS

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 %>

Learn More

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More