Atomic operations
When two requests want to change the same row at the same time, you need atomicity — a guarantee that one operation completes before the other reads. Cascade exposes two distinct increment/decrement APIs, and they have different concurrency stories that are easy to confuse.
This guide is short and direct: it explains the two APIs, when to reach for each, and the patterns for race-safe counters, rate limits, and similar high-write hot paths.
The two APIs side-by-side
Section titled “The two APIs side-by-side”// 1. Instance form — read-modify-write on the modeluser.increment("loginCount");await user.save();
// 2. Query-builder form — single atomic UPDATEawait User.whereId(user.id).increment("loginCount");They look almost identical. They behave very differently under concurrency:
| Aspect | Instance form | Query-builder form |
|---|---|---|
| Mechanics | reads current value into memory, adds, calls .save() | single UPDATE ... SET col = col + 1 statement |
| Round-trips | 2 (read + write) | 1 (write only) |
| Race-safe | ❌ Lost-update window if another writer modifies the row between read and save | ✅ Atomic at the database level |
| Fires lifecycle events | ✅ saving/updating/saved etc. | ❌ Bypasses model events |
| Best for | Single-writer flows where the model is already loaded | Multi-writer counters, rate limits, view counts |
The lost-update problem (instance form)
Section titled “The lost-update problem (instance form)”// Process Aconst user = await User.find(123); // loginCount = 5user.increment("loginCount"); // user.data.loginCount becomes 6 in memory // (Process B does the same thing here)await user.save(); // writes 6
// Process Bconst user = await User.find(123); // loginCount = 5 (still!)user.increment("loginCount"); // user.data.loginCount becomes 6 in memoryawait user.save(); // writes 6 — A's increment is lostTwo increments, one survived. Classic concurrency bug, expensive to detect (the counter just runs a bit slow), expensive to reproduce. The instance form is fine when only one process ever writes to the row in a given window — validated user profile edits, single-writer flows — but wrong for counters under load.
The atomic form (query builder)
Section titled “The atomic form (query builder)”await User.whereId(123).increment("loginCount");// → UPDATE users SET loginCount = loginCount + 1 WHERE id = 123One statement. The database serializes concurrent updates. Two processes both calling this against the same row produce two increments, not one. The cost is one round-trip and zero events.
This is the form you want for:
- Counters — page views, login counts, request counts, anything that climbs.
- Rate limits — increment a counter per (user, minute), reject if over threshold.
- Inventory decrements —
Product.whereId(id).decrement("stock")(combine with a.where("stock", ">", 0)guard if you need to refuse oversells). - Bulk atomic adjustments —
User.where("status", "active").incrementMany("score", 1)adjusts every matching row atomically.
Variants — increment, decrement, incrementMany, decrementMany
Section titled “Variants — increment, decrement, incrementMany, decrementMany”// Single row, amount defaults to 1await User.whereId(id).increment("loginCount");await Product.whereId(id).decrement("stock", 1);
// Many rowsawait User.where("status", "active").incrementMany("score", 5);await Product.where("expired", true).decrementMany("stock", 1);The “many” variants return the count of rows modified, not the new value. The single-row variants return the new value:
const newCount = await User.whereId(id).increment("loginCount");const updatedRows = await User.where("status", "active").incrementMany("score", 5);Patterns
Section titled “Patterns”Race-safe counter
Section titled “Race-safe counter”async function recordPageView(pageId: string) { return Page.whereId(pageId).increment("viewCount");}Single statement. No race. Don’t reach for transactions or instance loads for this — they’re slower and not needed.
Rate limiter
Section titled “Rate limiter”async function tryConsume(userId: string, limit: number): Promise<boolean> { const newCount = await RateLimit .where({ userId, window: currentMinute() }) .increment("requests"); return newCount <= limit;}The increment is atomic; the check happens on the returned value. If the row didn’t exist yet, you need an upsert step before the increment — that’s the recipe in the rate-limit pattern (typically findOrCreate + increment).
Inventory decrement with guard
Section titled “Inventory decrement with guard”async function reserveStock(productId: string, qty: number): Promise<boolean> { const rows = await Product .where("id", productId) .where("stock", ">=", qty) .decrementMany("stock", qty); return rows > 0; // true = reserved; false = insufficient stock}The where("stock", ">=", qty) is part of the same UPDATE. If two processes both try to reserve the last unit, only one row matches the stock >= 1 predicate when the row gets locked. The other gets back zero rows modified.
This is much cheaper than a transaction with read-then-write — the database does the check-and-update atomically in one statement.
Bulk score adjustment
Section titled “Bulk score adjustment”// Give every active user a daily login bonusawait User.where("status", "active").incrementMany("score", 10);One statement updates every matching row. No N+1, no loop. Returns the number of rows modified.
When the instance form is the right answer
Section titled “When the instance form is the right answer”The instance form isn’t wrong — it just has a narrower use case. Reach for it when:
- You already have the model loaded and want to update a few fields atomically together.
user.increment("loginCount"),user.merge({ lastLoginAt: new Date() }), then one.save()writes both. - You need lifecycle events to fire — audit listeners, cache invalidators. The query-builder form bypasses them; the instance form fires
saving/updating/saved. - The model is in a single-writer context — request-scoped, only one process can edit this row in this window.
// Reasonable instance useconst user = await User.find(123);
user.increment("loginCount");user.merge({ lastLoginAt: new Date(), lastLoginIp: request.ip,});
await user.save();// fires saving/updating/saved with all dirty fields visible to listenersFor just the counter under load, prefer the query-builder form.
Atomic upsert with atomic() / findOneAndUpdate() / findAndUpdate()
Section titled “Atomic upsert with atomic() / findOneAndUpdate() / findAndUpdate()”Model.atomic, Model.findOneAndUpdate and Model.findAndUpdate take an options object: upsert, arrayFilters (MongoDB), and — on findOneAndUpdate only — returnDocument: "before" | "after" (default stays "after", matching the pre-5.13 behaviour of both drivers). This turns “read the counter, check a limit, increment, insert if missing” into one round-trip:
const counter = await Counter.findOneAndUpdate( { key: "signups" }, { $inc: { count: 1 }, $setOnInsert: { startedAt: new Date() } }, { upsert: true },);$setOnInsert sets fields only when upsert actually inserts a new row; it is a no-op on an existing document. $addToSet adds an array element only when absent (MongoDB; SQL drivers throw UnsupportedUpdateOperationError). Pipeline (array-form) updates are also accepted wherever AtomicUpdate is expected — MongoDB only:
await Score.atomic({ id: 1 }, [{ $set: { total: { $add: ["$likes", "$shares"] } } }]);Model.findAndUpdate’s options omit trustedFilter (it always sanitizes its filter); atomic and findOneAndUpdate accept it.
trustedFilter — conditional reservations
Section titled “trustedFilter — conditional reservations”The filter argument on these statics is sanitized by default (UnsafeFilterError on a $-prefixed key), which normally blocks a conditional read-and-write like “only increment if used < 10”. trustedFilter: true is the explicit, code-authored opt-in:
const reserved = await Quota.atomic( { id: quotaId, used: { $lt: 10 } }, { $inc: { used: 1 } }, { trustedFilter: true },);Only pass trustedFilter with a filter your own code built — never with a request body. The concurrency guarantee holds on both drivers: MongoDB evaluates the filter and the modification under the document’s write lock; Postgres runs it as a single UPDATE … WHERE used < 10, and a concurrent writer re-evaluates the WHERE against the committed row (READ COMMITTED’s EvalPlanQual), so an 11th reservation against a limit-10 quota updates nothing.
Postgres upsert
Section titled “Postgres upsert”upsert: true on Postgres compiles to INSERT … ON CONFLICT (target) DO UPDATE SET … RETURNING *. The conflict target is derived from the table’s own indexes (primary key first, then unique non-partial, non-expression indexes) — whichever one has every column covered by an equality key in the filter. No target found throws UnsupportedUpdateOperationError naming the problem, rather than guessing one. The inserted row is built from the filter’s equality values, then $setOnInsert, then $set, then $inc/$dec. Any filter predicates outside the conflict target still apply as a DO UPDATE … WHERE: if the row exists but fails them, nothing is written and nothing is returned (MongoDB would instead raise a duplicate-key error in the same situation).
returnDocument: "before" combined with upsert: true, and any unrecognised update operator, both throw UnsupportedUpdateOperationError on Postgres.
Atomicity beyond increments
Section titled “Atomicity beyond increments”The query-builder atomic story extends to other shapes too:
- Atomic upsert —
Model.atomic/Model.findOneAndUpdate(above) — single-statement insert-or-update. whereColumnupdates —Product.whereColumn("stock", ">", "reserved").update({...})— update only when a row-level invariant holds.- Transactions — wrap multiple atomic operations as one unit. See Transactions guide.
Atomicity composes: a transaction wrapping several query-builder increments is race-safe and event-firing (events still fire for any Model.create / instance.save() inside; the bare .increment() calls remain event-free regardless of transaction wrapping).
Going further
Section titled “Going further”- Lifecycle events that fire (or don’t) per API: Events and hooks guide
- Transactions for multi-step atomicity: Transactions guide
- Race-free upserts and conditional updates: Querying essentials + Query Builder API reference
- Inventory / rate-limit recipes (planned) — the productionised patterns for these