Guide · Ruby on Rails

Solid Queue limits_concurrency and Solid Objects: ordered work for each customer in Rails

Solid Queue limits_concurrency runs one job at a time for each key but does not keep order. Compare it with Solid Objects for each customer.

Solid Queue is the default Active Job backend in Rails 8. Its limits_concurrency option runs at most a set number of jobs at a time for each key, but it does not guarantee their order. Solid Objects (the solid_objects gem) gives each customer one actor with an ordered mailbox, its own durable state, and reminders, in the SQL database that the app already uses. Use Solid Queue when jobs are independent or order does not matter. Use Solid Objects when the updates for one customer must apply in the order they arrived, and each customer has state that a retry must keep.

Solid Objects is an independent project. It is not affiliated with Rails, 37signals, or Solid Queue.

The short answer

Question Solid Queue with limits_concurrency Solid Objects
What runs one at a time Jobs with the same concurrency key Calls to the same actor ID
Order for each key Not guaranteed. The README says "there's no guarantee about the order of execution" Kept. Messages for one actor run in the order that the runtime stores them
State for each key Your own table Actor attributes, stored by the gem
Later work set(wait_until:), retry_on, and recurring tasks Reminders with schedule(at:, key:)
A worker stops in the middle of a run After a graceful stop, the job goes back to the queue. After a process dies, the job is marked failed, and Solid Queue does not retry it The lease expires and the turn runs again. Delivery is at least once
Process to run bin/jobs, or the Puma plugin bundle exec solid_objects start
Databases MySQL, PostgreSQL, SQLite SQLite, PostgreSQL, MySQL
Licence MIT MIT

The Solid Queue facts come from the Solid Queue 1.7.0 README (checked October 15, 2026).

Choose Solid Queue when

Choose Solid Objects when

The same terms in each

Solid Queue and Active Job Solid Objects
The key: of limits_concurrency The actor ID
A job class with perform An actor method (an operation)
perform_later ref(id).async.method(...)
retry_on with wait: Effect retries (max_attempts, retry_delay), then on_failure and a reminder
set(wait_until:) ref(id).async(available_at:), or schedule(at:, key:) inside the actor
A row in your own table attribute on the actor
config/recurring.yml No equivalent. Keep recurring tasks in Solid Queue

One billing sync for each customer

The task: a Rails 8 app on PostgreSQL syncs each customer's subscription with an external billing API. The updates for one customer must run one at a time and in the order they arrived. Different customers run in parallel. Each customer has state: the last synced version, a retry count, and the next retry time. A failed sync must retry with backoff and survive a restart.

With Solid Queue

This job follows the Solid Queue and Active Job documentation. It was not run by the Solid Objects test suite.

class SyncSubscriptionJob < ApplicationJob
  BACKOFF = ->(executions) { (executions**4) + 2 }

  limits_concurrency to: 1, key: ->(customer_id, *) { customer_id }, duration: 15.minutes
  retry_on BillingApi::TransientError, wait: BACKOFF, attempts: 10

  def perform(customer_id, version)
    sync = BillingSyncState.find_or_create_by!(customer_id:)
    return if version <= sync.last_synced_version

    BillingApi.push_subscription(customer_id, version)
    sync.update!(last_synced_version: version, retry_count: 0, next_retry_at: nil)
  rescue BillingApi::TransientError
    failures = exception_executions["[BillingApi::TransientError]"].to_i + 1
    sync.update!(retry_count: sync.retry_count + 1, next_retry_at: BACKOFF.call(failures).seconds.from_now)
    raise
  end
end

BillingSyncState is a plain Active Record model. Its last_synced_version and retry_count columns are integers with a default of 0, and next_retry_at is a timestamp. exception_executions counts the earlier failures for one error class, and retry_on uses the same count for its delay.

What this job gets from Solid Queue:

What it does not get:

Solid Queue also advises against mixing concurrency controls with delayed jobs, and it says that concurrency controls "introduce significant overhead".

With Solid Objects

There is one CustomerBillingSync actor for each customer ID.

class CustomerBillingSync < SolidObjects::Actor
  MAXIMUM_BACKOFF_MINUTES = 60

  attribute :last_synced_version, default: 0
  attribute :pending, default: -> { [] }
  attribute :pushing, default: false
  attribute :retry_count, default: 0
  attribute :next_retry_at, default: nil

  def update_subscription(version:, plan:)
    return newest_version if version <= newest_version

    self.pending = pending + [ { "version" => version, "plan" => plan, "key" => "billing-#{actor_id}-v#{version}" } ]
    push_next unless pushing || next_retry_at
    version
  end

  def pushed(effect_id:, arguments:, result:)
    self.pushing = false
    self.last_synced_version = arguments.fetch("version")
    self.pending = pending.drop(1)
    self.retry_count = 0
    self.next_retry_at = nil
    push_next
  end

  def push_failed(effect_id:, arguments:, error:)
    self.pushing = false
    self.retry_count += 1
    retry_at = [ 2**retry_count, MAXIMUM_BACKOFF_MINUTES ].min.minutes.from_now
    self.next_retry_at = retry_at.iso8601
    schedule(at: retry_at, key: "retry").retry_push
  end

  def retry_push
    self.next_retry_at = nil
    push_next unless pushing
  end

  private

  def newest_version
    pending.last&.fetch("version") || last_synced_version
  end

  def push_next
    update = pending.first
    return unless update

    self.pushing = true
    emit :push_subscription,
      idempotency_key: update.fetch("key"),
      customer_id: actor_id,
      version: update.fetch("version"),
      plan: update.fetch("plan"),
      on_success: :pushed,
      on_failure: :push_failed
  end
end

The initializer registers the effect that calls the billing API:

SolidObjects.register_effect(:push_subscription) do |arguments, _context|
  BillingApi.push_subscription(
    idempotency_key: arguments.fetch("idempotency_key"),
    customer_id: arguments.fetch("customer_id"),
    version: arguments.fetch("version"),
    plan: arguments.fetch("plan")
  )
end

The app sends each update with async:

CustomerBillingSync.ref(customer.id.to_s)
  .async(idempotency_key: "subscription-#{subscription.id}-v#{subscription.version}", authorization_context: Current.user)
  .update_subscription(version: subscription.version, plan: subscription.plan)

The generated policies deny every call. Allow this call in your authorize_message policy. See authorization policies.

How it works:

What the tests prove

The tests show these results:

Questions that teams ask

Is Solid Objects mature enough? Here are the facts:

Solid Queue is older and has more users. If that matters more than order for each customer, choose Solid Queue and add a sequence check, as the ordered jobs guide shows.

Does it need another process? Yes. bundle exec solid_objects start runs async messages, effects, and reminders. Solid Queue also needs a process: bin/jobs or the Puma plugin. While the Solid Objects process is down, pending work stays in SQL.

Is delivery exactly once? No. Delivery is at least once. A turn can run again after a crash. Give each external call a stable idempotency key. The effect ID (context.id) is stable across the retries of one effect. The example stores a key with each update, so the key also survives a backoff retry. See correctness and delivery semantics.

Can actor code write my tables? Not directly. An actor reads application records. To write a row in the same transaction as the actor state, use a commit action. The ordered jobs guide shows one.

What if one customer is very busy? One actor runs one turn at a time. If one customer has independent streams of work, give each stream its own actor ID, for example one for each subscription.

Can I use both? Yes. Keep your other jobs in Solid Queue. The two use different tables. By default, Solid Queue uses its own queue database, and Solid Objects uses the app database.

Sources

Checked October 15, 2026:

More information

Generated from docs/compare/solid-queue.md in cardmagic/solid-objects-ruby, v1.0.2, commit 5528659. For TypeScript and Node.js, read Virtual actors in TypeScript and Node.js.