skip to content

In a Rails classified-ads app, why does listing.update(views_count: listing.views_count + 1) lose views under load, and what should bump the counter instead?

level: seniorimportance: should knowfreq 40%

answer

  1. read in Ruby, write a literal
  2. two requests, one lost increment
  3. increment! sends COALESCE + 1
  4. update_counters for ids you hold
  5. no validations, callbacks or updated_at

basics

~20 s

update computes the new count in Ruby and writes it as a literal, so concurrent requests overwrite each other. increment!(:views_count) or Listing.update_counters(id, views_count: 1) sends views_count = COALESCE(views_count, 0) + 1, which the database applies atomically.

solid answer

~40 s

The update reads `views_count` into Ruby, adds one and sends `SET views_count = 42`. Two requests that both loaded 41 both write 42, so one view vanishes; it also runs validations and callbacks and bumps `updated_at`, which can expire caches on every page view. `listing.increment!(:views_count)` instead sends `UPDATE listings SET views_count = COALESCE(views_count, 0) + 1 WHERE id = ?`, letting the database add to whatever value it holds; it skips validations and callbacks and leaves `updated_at` alone unless you pass `touch: true`. Without a loaded record, `Listing.update_counters(listing_id, views_count: 1)` sends the same statement. Note that `increment` without the bang only changes memory, and the in-memory count after `increment!` is your old value plus one, not necessarily the database value; `reload` if you need the true total.

code

ruby · 10 lines
ruby
# ListingsController#show
@listing = Listing.find(params[:id])
@listing.increment!(:views_count)
# UPDATE "listings" SET "views_count" = COALESCE("views_count", 0) + 1 WHERE "listings"."id" = 42

# A job holding only the id
Listing.update_counters(listing_id, views_count: 1)

# Wrong: computes in Ruby, writes a literal, runs callbacks, bumps updated_at
@listing.update(views_count: @listing.views_count + 1)

go deeper

for a junior

Recall that increment! writes a counter straight to the database while increment only changes the object.

for a middle

Explain why a value computed in Ruby loses concurrent updates and what SQL increment! and update_counters send instead.

for a senior

Pick the atomic call that fits the context, keep updated_at and callbacks out of hot paths, and know when the in-memory value is stale.

for a principal

Decide when per-request counter writes become a hotspot and whether to buffer counts outside the row entirely.

## The bug: read-modify-write in Ruby Every listing page in the classified-ads app shows a view count, and the controller bumps it on each visit. The first version looks harmless: ```ruby @listing.update(views_count: @listing.views_count + 1) ``` It does three things the requirement never asked for: 1. **Lost updates.** The new value is computed in Ruby and sent as a literal: `SET views_count = 42`. Two requests that both loaded 41 both write 42; a third view is lost the same way. Under load the counter drifts below the real number. 2. **The full save path.** Validations run on every page view (an unrelated invalid attribute makes the bump fail silently, since `update` returns `false`), callbacks fire, and the record is wrapped in a transaction. 3. **`updated_at` moves.** Any cache keyed on the listing's `updated_at` expires on every view, so the page cache never hits. `update_column(:views_count, @listing.views_count + 1)` fixes points 2 and 3 but **not** point 1: it still writes a value computed in Ruby. ## The fix: let the database add Active Record has calls that send an increment expression instead of a value: | Call | SQL | Needs a loaded record | |---|---|---| | `listing.increment!(:views_count)` | `SET views_count = COALESCE(views_count, 0) + 1 WHERE id = ?` | yes | | `Listing.update_counters(id, views_count: 1)` | same, for one id or an array of ids | no | | `Listing.increment_counter(:views_count, id)` | same, one counter | no | | `Listing.where(id: id).update_all("views_count = views_count + 1")` | the SQL you wrote | no | Because the database applies `views_count + 1` to the row's current value as part of the UPDATE itself, concurrent increments **add up** instead of overwriting each other. `COALESCE` also treats a `NULL` counter as zero. All of these: - skip validations and callbacks; - leave `updated_at` alone unless asked (`increment!(:views_count, touch: true)`, `update_counters(id, views_count: 1, touch: true)`); - cost one UPDATE and no SELECT. ## What the object in memory shows `increment!` updates the object too, but only from what it knew: if `listing.views_count` was 41 in memory and another request bumped the row to 42 meanwhile, after `increment!` the object says 42 while the database says 43. The SQL was correct; the in-memory copy is just a local guess. When the page must display the exact total, call `listing.reload` (one SELECT) or render the value you loaded before the bump and accept it is approximate. `increment` **without** the bang only changes the attribute in memory; nothing is written until a later `save`, which brings back the lost-update problem. ## Choosing among the options 1. **Controller has the record loaded**: `@listing.increment!(:views_count)`. 2. **Only an id is at hand**, for example in a background job: `Listing.update_counters(listing_id, views_count: 1)`. 3. **Several counters at once**: `update_counters(id, views_count: 1, contact_clicks: 1)`. 4. **Very hot rows**: every view still updates the same row, which serializes writers on it. Teams then buffer counts elsewhere and flush them periodically; that is a caching and design decision beyond the model. ## What this is not Row locks (`lock`, `with_lock`) and optimistic locking with `lock_version` also prevent lost updates, but they make each request wait or retry, which is heavy machinery for a counter. They belong to the transactions topic, and an atomic increment makes them unnecessary here. ## Testing the counter A concurrency bug is hard to reproduce in a test, but the SQL shape is easy to assert: 1. Load the same listing twice into two variables, simulating two requests. 2. Call `increment!(:views_count)` on both. 3. `reload` either one and assert the count went up by **two**. The same test written with `update(views_count: listing.views_count + 1)` fails, because the second write overwrites the first with the same literal value. Seeing that failure once is the clearest way to explain the difference to a team.

  • After listing.increment!(:views_count), why can listing.views_count differ from the value in the table?
    `increment!` adds one to the in-memory value and sends an atomic `+ 1` to the database. If other requests incremented the row since this object was loaded, the database total is higher than the object's local guess. `listing.reload` reads the real value.
  • Does increment!(:views_count) change updated_at?
    Not by default: it goes through `update_counters`, which skips callbacks and timestamps. Passing `touch: true` adds `updated_at` to the same UPDATE, which you usually avoid for view counts so caches keyed on `updated_at` stay warm.

saying these in an interview costs you the question

  • Replacing update with update_column and calling the race fixed.
  • Using listing.increment(:views_count) and expecting the database to change.
  • Saying increment! runs validations before writing the counter.
  • Expecting increment! to leave the in-memory count equal to the database total.
  • Wrapping every page view in with_lock just to count it.