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¶
When author.destroy runs:
- ActiveRecord opens a transaction.
- (Skips the per-child destroy cascade.)
- Issues
DELETE FROM authors WHERE id = ?. - Commits.
- Enqueues an
ActiveRecord::DestroyAssociationAsyncJobwith the child IDs + class name. - Returns to caller.
Later (on a background worker):
- The job picks up.
- Loads children.
- Calls
child.destroyon 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_asyncemits 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'sbefore_destroycallback aborts, the job fails silently and ends up in the error queue. - Transactional scope is per-child. Each
child.destroyruns in its own transaction. If 500 children exist and 250 succeed, 1 fails, and the job errors — 250 are gone. This is weaker thandependent: :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_destroycallback 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¶
- sources/2026-04-21-planetscale-ruby-on-rails-3-tips-for-deleting-data-at-scale —
canonical wiki introduction. Mike Coutermarsh
(PlanetScale, 2022-08-01) canonicalises
:destroy_asyncas the Rails 6.1+ answer to cascade risk, names the silent-error failure mode, recommends replacing foreign-key cascades with:destroy_asyncat scale.