Skip to content

CONCEPT Cited by 1 source

Destroy-async (Rails 6.1+)

dependent: :destroy_async is a Rails 6.1+ association option that triggers cascade deletion of child records via a background ActiveJob job rather than inside the parent's destroy transaction. Shape-identical to dependent: :destroy at the API surface; structurally different in that the child rows are deleted after the parent destroy returns to the caller.

Shape

class Author < ApplicationRecord
  has_many :books, dependent: :destroy_async
end

When author.destroy runs:

  1. ActiveRecord opens a transaction.
  2. (Skips the per-child destroy cascade.)
  3. Issues DELETE FROM authors WHERE id = ?.
  4. Commits.
  5. Enqueues an ActiveRecord::DestroyAssociationAsyncJob with the child IDs + class name.
  6. Returns to caller.

Later (on a background worker):

  1. The job picks up.
  2. Loads children.
  3. Calls child.destroy on each — full lifecycle, callbacks, further cascades.

The architectural property it fixes

The parent destroy becomes O(1) in the user's request path. The child-cascade work becomes O(children) on a background worker, bounded to its own transaction(s) rather than the user's request transaction. This breaks the cascade- risk coupling: a small UI action no longer produces a large DB transaction on the hot request path.

From sources/2026-04-21-planetscale-ruby-on-rails-3-tips-for-deleting-data-at-scale:

"It works similarly to dependent: :destroy, except that it will run the deletion via a background job rather than happening in request. This protects you from triggering a large number of deletes within a single transaction."

Substrate assumptions

  • ActiveJob is configured. dependent: :destroy_async emits jobs via ActiveJob's adapter (Sidekiq, Delayed::Job, Que, etc.). An application with ActiveJob misconfigured or pointed at an in-process adapter (:inline, :async) falls back to synchronous cascade, defeating the purpose.
  • Children are deletable without validation. The async-cascade job calls child.destroy; if a child's before_destroy callback aborts, the job fails silently and ends up in the error queue.
  • Transactional scope is per-child. Each child.destroy runs in its own transaction. If 500 children exist and 250 succeed, 1 fails, and the job errors — 250 are gone. This is weaker than dependent: :destroy's all-or-nothing atomicity and operators need to understand the delta.

The silent-error failure mode

Because cascade happens after the user's request returns, any error during the cascade is invisible to the user and the request-tier monitoring. Canonical failure:

  • User clicks "Delete account" at 10:00.
  • Parent row deletes; request returns 200 OK at 10:00:01.
  • Async-cascade job runs at 10:00:05.
  • Child-row before_destroy callback raises because a required dependency is missing.
  • Job ends up in Sidekiq's retry or error queue.
  • User doesn't know. Operators don't know unless they watch Sidekiq's error stream for this specific job class.

Mitigation named by the post: "If any child records have validations on delete, we recommend running them from the parent model. This will stop the deletion from occurring and alert the user of the issue." I.e. hoist critical child-side validations to the parent's before_destroy so they fail the request before the async cascade can begin.

Consistency window

Between steps 4 (parent deleted) and the async-cascade job running (step 5 above), orphan children exist: rows in books referencing a non-existent authors.id. For applications with referential queries like Book.joins(:author) this looks like missing rows rather than a consistency violation, because the join filters them out. Applications that read book.author_id without joining see referential integrity gaps.

Typical window: seconds to minutes, depending on Sidekiq queue backlog. Foreign-key constraint at the DB layer would prevent the orphan-state — but would also synchronously cascade delete the children, defeating destroy_async's core value. The trade-off is canonicalised as patterns/foreign-key-cascade-vs-dependent-destroy-async.

Why not just remove the FK constraint?

Removing foreign_key: true / add_foreign_key migrations alongside switching to destroy_async is the usual compromise. Accepts the orphan-window; gains the request-path decoupling. Requires application code to tolerate orphan rows (treat author_id referring to deleted parent as equivalent to deleted child).

Relationship to dependent: :delete_all

:delete_all issues a single SQL DELETE skipping callbacks — faster than :destroy but still synchronous. Doesn't fix the cascade-risk problem (still a large unbounded DB transaction), just trims callback overhead. Complementary lever, different axis:

Option Synchronous? Callbacks?
:destroy yes yes (per child)
:delete_all yes no (raw DELETE)
:destroy_async no (background job) yes (per child, in job)
no option n/a children orphaned

Seen in

Last updated · 470 distilled / 1,213 read