Skip to content
Joey Wang
Menu

Search

Transactions, touch, and async rollups for derived data in Rails

Keeping counters and summaries accurate in Rails without slowing down writes means separating core data in a transaction from derived data updated after commit.

· 4 min read

#rails#database#architecture#performance

Audio summary

In real-world Rails applications, not all data is equal. Some columns are core truth: the values your business logic actually depends on. Others exist for convenience, performance, or observability: counters, summaries, snapshots, caches. The problem is keeping those derived fields accurate without slowing down writes or introducing correctness bugs, especially once async jobs and transactions are both in the picture.

The core problem

Student has_many Addresses
Student has summary fields derived from Addresses

Examples of derived data: addresses_count, has_verified_address, latest_country, address_summary_json, addresses_updated_at. Whenever an Address changes, the Student should reflect that, but you don’t want to scan every address on each read, recompute expensive summaries on every write, or let a retried or rolled-back job corrupt the count. That’s a consistency-versus-performance problem, not just a syntax question.

Separate core writes from derived writes

The core principle: only write core data inside the transaction. Derived data gets updated after commit. Transactions can roll back, jobs can retry, and a side effect like a counter or cache should never reflect data that never actually landed.

Anti-pattern:

ActiveRecord::Base.transaction do
  address.update!(...)
  student.update!(addresses_count: student.addresses.count)
end

If this transaction retries, deadlocks, or partially fails, you get incorrect counters, expensive queries held inside a lock, and contention you didn’t need.

Rails touch: a lightweight change signal

class Address < ApplicationRecord
  belongs_to :student, touch: true
end

touch gives you a cheap, automatic “something under me changed” signal without scanning associations to detect it, which plays well with HTTP caching, fragment caching, and snapshots. It’s good for cache invalidation, change detection, and dependency tracking. It is not a summary calculator, a counter manager, or a guarantee that derived data is correct. Treat it as a notification, not a computation.

Strategy 1: delta-based updates

Apply a small delta on each change instead of recomputing:

class Address < ApplicationRecord
  belongs_to :student

  after_commit :increment_counter, on: :create
  after_commit :decrement_counter, on: :destroy

  def increment_counter
    Student.update_counters(student_id, addresses_count: 1)
  end

  def decrement_counter
    Student.update_counters(student_id, addresses_count: -1)
  end
end

Fast reads, no full-table scans, atomic SQL updates. The cost: updates are awkward (what happens when an address becomes invalid rather than created or destroyed?), retries can double-count without careful idempotency, and drift accumulates over time. Good fit for append-only data, simple counts, and hot read paths, provided you also run reconciliation.

Strategy 2: recompute on change

Every meaningful change triggers a full rebuild:

class Address < ApplicationRecord
  belongs_to :student

  after_commit :enqueue_rollup

  def enqueue_rollup
    StudentAddressRollupJob.perform_later(student_id)
  end
end

class StudentAddressRollupJob < ApplicationJob
  def perform(student_id)
    student = Student.find(student_id)

    student.update!(
      addresses_count: student.addresses.count,
      has_verified_address: student.addresses.verified.exists?
    )
  end
end

Naturally idempotent, safe with retries, handles edits and deletes without special-casing. It costs more per update and can spam jobs under bursty writes unless you throttle or dedupe. Use it when the derived logic is complex, edits can change prior state, and correctness matters more than write cost.

Strategy 3: touch plus a dirty flag

Separate change detection from the work itself. Mark the parent dirty on every child change:

# students.needs_address_rollup :boolean

class Address < ApplicationRecord
  belongs_to :student

  after_commit :mark_student_dirty

  def mark_student_dirty
    Student.where(id: student_id)
           .update_all(needs_address_rollup: true, updated_at: Time.current)
  end
end

A worker then processes only dirty students on its own schedule:

Student.where(needs_address_rollup: true).find_each do |student|
  rebuild_address_summary(student)
  student.update!(needs_address_rollup: false)
end

This coalesces bursts of updates into one rebuild instead of one job per change, at the cost of some staleness and an extra background sweeper. It scales well precisely because it decouples “something changed” from “do the work now.”

Strategy 4: versioned touch

Instead of “something changed,” track how many times it changed:

# students.addresses_version :integer

class Address < ApplicationRecord
  after_commit do
    Student.where(id: student_id)
           .update_all("addresses_version = addresses_version + 1")
  end
end

Cache keys can include addresses_version, and jobs can carry the version they observed so a stale, out-of-order job safely no-ops instead of overwriting newer data:

def perform(student_id, version)
  student = Student.find(student_id)
  return if student.addresses_version > version

  rebuild_summary(student)
end

More moving parts than a plain dirty flag, but it’s the strategy that actually prevents stale writes under concurrent, out-of-order job execution, which makes it a good default for anything cache-adjacent running async.

Performance considerations

touch updates the parent row on every child write, and under frequent child updates that becomes row-lock contention and replication lag. Debouncing, batching imports, and preferring a dirty flag or version counter over raw touch all reduce that. On the job side, one job per child update doesn’t scale; dedupe by parent ID and let bursts collapse into a single run. Whatever strategy you pick, keep a periodic full rebuild job around: it fixes drift, catches bugs in the incremental path early, and gives you the confidence to optimize the hot path aggressively.

Use caseRecommended strategy
Simple counterDelta or counter_cache
Editable or deletable rowsRecompute
Cache invalidationtouch
Burst-heavy writesDirty flag
Async correctnessVersioned touch
High-read systemHybrid

Derived data is never free. You pay for it at write time with synchronous updates, at read time with scans, or in complexity with async processing and reconciliation. Rails gives you the primitives, transactions, after_commit, touch, but which of these strategies you pick is the architecture decision that actually matters. Keep transactions small, treat derived data as rebuildable, and reconcile periodically, and the system stays both fast and correct as it grows.