Skip to content

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):

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

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_async for 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:

  1. 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).
  2. Referential integrity enforcement. foreign_key: true in the schema prevents a bug that writes a child with an invalid author_id. Removing the FK (necessary to enable :destroy_async without 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_destroy callbacks 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

Last updated · 470 distilled / 1,213 read