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?
answer
- read in Ruby, write a literal
- two requests, one lost increment
- increment! sends COALESCE + 1
- update_counters for ids you hold
- no validations, callbacks or updated_at
basics
~20 supdate 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 sThe 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# 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
Recall that increment! writes a counter straight to the database while increment only changes the object.
Explain why a value computed in Ruby loses concurrent updates and what SQL increment! and update_counters send instead.
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.
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.