PATTERN Cited by 1 source
Foreign-key cascade vs dependent destroy-async¶
A layer-choice pattern for parent-child cascade deletion in
Rails applications: push the cascade into the database
layer via ON DELETE CASCADE foreign-key constraint, or
keep it in the application layer via
dependent: :destroy_async.
Both guarantee eventual cleanup; they differ on atomicity,
boundedness, observability, and failure-mode profile.
The two layers¶
DB-layer cascade (ON DELETE CASCADE):
ALTER TABLE books
ADD CONSTRAINT fk_books_author
FOREIGN KEY (author_id) REFERENCES authors(id)
ON DELETE CASCADE;
When DELETE FROM authors WHERE id = 1 runs, MySQL
atomically deletes the author row and every child row
with author_id = 1 inside the same transaction.
App-layer async cascade
(dependent: :destroy_async):
When author.destroy runs, Rails deletes the author row,
returns to the caller, then enqueues an ActiveJob that
deletes the children on a Sidekiq worker later.
Comparison¶
| Dimension | ON DELETE CASCADE |
:destroy_async |
|---|---|---|
| Atomicity | yes (one transaction) | no (parent + async job) |
| Bounded transaction size | no (O(children)) | yes (per-record delete in job) |
| Request-path cost | O(children) — same trap | O(1) |
| Referential integrity window | always consistent | orphan children during job lag |
| Invokes Ruby callbacks | no (bypasses ActiveRecord) | yes (per child, in job) |
| Observability of cascade errors | DB error → request error | silent → Sidekiq error queue |
| Ruby-side logging / audit | none | full callback chain |
The canonical PlanetScale recommendation¶
From sources/2026-04-21-planetscale-ruby-on-rails-3-tips-for-deleting-data-at-scale:
"We recommend replacing any usage of foreign key constraints with
:destroy_asyncfor safer deletes at scale."
The framing: FK cascade has the same unbounded-work
failure mode as dependent: :destroy, just one layer
deeper. Moving the cascade from Ruby to SQL doesn't fix
the
cascade-risk
problem; it hides it. Both produce a single DB transaction
whose size scales with child-row count; both block the
request that triggered them; both spike
replication lag through a
large binlog event.
Only :destroy_async gives the parent delete an O(1)
request-path cost by moving the cascade work to a
separate execution path.
The trade-off PlanetScale accepts¶
Switching from FK cascade to :destroy_async loses two
properties that FK constraints provide:
- Atomicity of parent + child deletion. An FK cascade is one transaction; async cascade is parent- delete + later job-runs. Between the two, orphan children exist (child rows referencing a non-existent parent).
- Referential integrity enforcement.
foreign_key: truein the schema prevents a bug that writes a child with an invalidauthor_id. Removing the FK (necessary to enable:destroy_asyncwithout the FK synchronously cascading first) removes this guarantee.
PlanetScale's framing: on a high-scale production MySQL, the orphan-window is short (seconds to minutes) and observability-manageable. The request-path latency and replication-lag spikes from FK cascade are operationally worse than the orphan-row class of bugs, so the trade is worth making.
When to prefer FK cascade¶
- Small child-row counts. If a parent has at most a dozen children, the cascade transaction is small and fast. FK cascade's atomicity + integrity beat async cascade's complexity.
- Strict referential-integrity requirements. Financial, audit, or regulated applications where orphan rows are unacceptable.
- Child rows are infrequently created / deleted. If the cascade path is exercised rarely, its latency profile rarely matters; keep the DB-layer guarantee.
- No async-job infrastructure. Applications without
Sidekiq / ActiveJob in production can't use
:destroy_async; FK cascade is the only option.
When to prefer :destroy_async¶
- Unbounded child-row growth. Parent entities (user, account, tenant) whose children accumulate over years and can reach thousands to millions of rows.
- Request-path latency SLO. Every user-triggered delete must return within a bounded time regardless of cascade size.
- Replication-lag sensitivity. Read-heavy clusters where a large cascade event on the primary would stall replicas.
- Callbacks matter. Application has
before_destroy/after_destroycallbacks on children (audit logging, associated-record cleanup, external-system notifications) that must fire.
Hybrid approach¶
Some teams keep FK constraints without ON DELETE
CASCADE (FK alone forces ON DELETE RESTRICT) plus
:destroy_async. The FK guarantees referential
integrity for new inserts but rejects parent deletion
while children exist — which :destroy_async would
then fail on.
This combination doesn't actually compose: the FK's
RESTRICT blocks the parent delete, so the async cascade
never enqueues. Teams wanting async cascade must drop
the FK constraint (or use a deferred constraint, which
MySQL doesn't support — Postgres does via
DEFERRABLE INITIALLY DEFERRED).
Practical path: accept the orphan-window, remove the
FK, ship :destroy_async, harden application code to
tolerate orphan children (filter by parent existence in
reads).
Observability asymmetry¶
FK cascade failures surface as DB errors on the request — the user sees a 500; operators see the error in request-tier logs/APM. Visible, actionable, hard to miss.
:destroy_async failures surface as Sidekiq errors
on a background worker — the user sees a 200
(parent deleted); operators see the error only if they
watch the specific job's error stream. Easy to miss
unless instrumented.
Canonical mitigation: alert on
ActiveRecord::DestroyAssociationAsyncJob failures per
parent class. The destroy-
async concept page covers the silent-failure
problem in more detail.
Relationship to dependent: :delete_all¶
dependent: :delete_all is the synchronous middle
ground: app-layer cascade (not DB-layer) that skips
callbacks (like delete_all). Same unbounded-work
problem as :destroy at the DB transaction altitude;
no atomicity guarantee (it's app-layer); faster than
:destroy per row because callbacks skipped.
Rarely the right choice — if you want atomicity use
FK cascade; if you want bounded-work use :destroy_async.
:delete_all combines the downsides.
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 the
substitution: "replace any usage of foreign key
constraints with
:destroy_asyncfor safer deletes at scale." Framing: both layers have the same unbounded-work problem; only async cascade escapes it.