Skip to content

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.

// 1. Instance form — read-modify-write on the model
user.increment("loginCount");
await user.save();
// 2. Query-builder form — single atomic UPDATE
await User.whereId(user.id).increment("loginCount");

They look almost identical. They behave very differently under concurrency:

AspectInstance formQuery-builder form
Mechanicsreads current value into memory, adds, calls .save()single UPDATE ... SET col = col + 1 statement
Round-trips2 (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 forSingle-writer flows where the model is already loadedMulti-writer counters, rate limits, view counts
// Process A
const user = await User.find(123); // loginCount = 5
user.increment("loginCount"); // user.data.loginCount becomes 6 in memory
// (Process B does the same thing here)
await user.save(); // writes 6
// Process B
const user = await User.find(123); // loginCount = 5 (still!)
user.increment("loginCount"); // user.data.loginCount becomes 6 in memory
await user.save(); // writes 6 — A's increment is lost

Two 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.

await User.whereId(123).increment("loginCount");
// → UPDATE users SET loginCount = loginCount + 1 WHERE id = 123

One 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 1
await User.whereId(id).increment("loginCount");
await Product.whereId(id).decrement("stock", 1);
// Many rows
await 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);
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.

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

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.

// Give every active user a daily login bonus
await 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 use
const 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 listeners

For 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.

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.

The query-builder atomic story extends to other shapes too:

  • Atomic upsert — Model.atomic / Model.findOneAndUpdate (above) — single-statement insert-or-update.
  • whereColumn updates — 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).