July 29, 2026
Kazi Ehsan Aziz
Mongoose makes subdocument arrays feel like ordinary JavaScript arrays. You load the parent document, and then:
find() the element you want from the subdocument array and update a field there,
then call parent.save(). It works efficiently, as long as the array is small.filter() and reassign its result to the
array, then call parent.save(). It works correctly until race-conditions kick in.This article works through four rules I ended up with for a
collaborative-writing backend, where a single document (named "Postboard") holds an array
of subdocuments (named "turns") and each turn's text field can be 30,000 characters.
Let's look at a simplified version of the schema for context.
const turnSchema = new mongoose.Schema(
{
text: { type: String, maxlength: 30000 },
authorId: { type: String, ref: "User" },
},
{ timestamps: true },
);
const postboardSchema = new mongoose.Schema(
{
// "Post" is another collection, we can ignore it for this discussion.
postId: { type: String, ref: "Post", unique: true, required: true },
turns: { type: [turnSchema], default: [], required: true },
},
{ timestamps: true },
);
The idea is that several users may be updating a Postboard, and different users could be working on different Turn subdocuments. Here is the code that a "update a turn" endpoint wants to be:
const updateTurn = async (
postId: string,
turnId: string,
text: string,
userId: string,
) => {
const postboard = await Postboard.findOne({ postId });
const turn = postboard.turns.find((t) => t.id === turnId);
if (turn.authorId !== userId) return forbidden();
turn.text = text;
await postboard.save();
};
The code is correct. But it can be more efficient.
$elemMatch in the filter, and $elemMatch again in the projection:
const postboard = await Postboard.findOne(
{ postId, turns: { $elemMatch: { _id: turnId } } }, // filter
{ turns: { $elemMatch: { _id: turnId } } }, // projection
);
const turn = postboard?.turns[0];
The projection is the part that saves the megabyte: projection
$elemMatch returns at most one element of the array, so the
document that comes back has a turns array of length 0 or 1 and the
element you want is always at index 0. Nothing else in the array
crosses the network.
There was an ownership check line that ran on the query result.
if (turn.authorId !== userId) return forbidden();
If we wanted to incorporate that into the query's filter stage, without $elemMatch,
the obvious flat filter would be:
await Postboard.findOne({
postId,
"turns._id": turnId,
"turns.authorId": userId,
});
But this is wrong. MongoDB evaluates those two conditions independently across the
subdocument array. The document matches when some turn has that _id and
some turn has that authorId — not necessarily the same turn. This is why the ownership
check was a separate line after the query result. However, with $elemMatch, we can keep
it in the query without any harm:
const postboard = await Postboard.findOne(
{
postId,
turns: {
// works like a boolean AND operator on subdocuments
$elemMatch: { _id: turnId, authorId: userId },
},
},
{ turns: { $elemMatch: { _id: turnId } } }, // projection
);
const turn = postboard?.turns[0];
After getting the correct element with $elemMatch, the obvious next thought is:
mutate the subdocument, save() on the parent, and let Mongoose update the timestamps.
const turn = postboard?.turns[0];
turn.text = text;
await postboard.save();
That throws:
DivergentArrayError: For your own good, using `document.save()` to update an array which was
selected using an $elemMatch projection OR populated using skip, limit, query conditions, or
exclusion of the _id field when the operation results in a $pop or $set of the entire array is
not supported. The following path(s) would have been modified unsafely:
turns.0.updatedAt
Use Model.updateOne() to update these arrays instead.
Mongoose knows the array it loaded is partial, so an index-based path
like turns.0.text would be nonsense against the real document. It
handles this by rewriting the path to turns.$ ($ is known as the
positional operator) — but this case is only successfully handled by Mongoose if
there was just one dirty path, i.e. if only text was updated.
Since we set timestamps: true on the subdocument schema,
changing one field and calling .save() dirties two paths: turns.$.text and
turns.$.updatedAt. Hence, the error. The correct way:
const now = new Date();
const result = await Postboard.updateOne(
{ postId, turns: { $elemMatch: { _id: turnId, authorId: userId } } },
{
$set: {
"turns.$.text": text,
"turns.$.updatedAt": now,
},
},
{ runValidators: true },
);
A bonus with this approach: the turn is located and rewritten on the database server in one atomic operation.
Two details that are easy to miss:
runValidators: true. Mongoose runs schema validators on
save(), not on update operators. Without this, maxlength: 30000
is not enforced on the path you just took.updatedAt is set by hand. Mongoose maintains subdocument
timestamps in save() only. A positional $set goes to the server
as a raw update — Mongoose is not tracking a document, so there is
nothing to stamp.A miss now collapses two different failures — "no such turn (404)" and "not
your turn (403)" — into matchedCount: 0. Pay for the distinction only on
the failure path, with one projected read:
if (result.matchedCount === 0) {
const probe = await Postboard.findOne(
{ postId, turns: { $elemMatch: { _id: turnId } } },
{ turns: { $elemMatch: { _id: turnId } } },
).lean();
return probe?.turns?.length ? forbidden() : notFound();
}
If you need to return the updated result, use findOneAndUpdate and project on
the way out. new: true gives you the post-write state, and the
projection option applies to the returned document — so you get the
one element, not the array:
const updated = await Postboard.findOneAndUpdate(
someFilters,
{ $set: someUpdates },
{
new: true,
projection: { turns: { $elemMatch: { _id: turnId } } },
runValidators: true,
},
);
const turn = updated?.turns[0];
One atomic operation does the match, the write, and the read-back.
Appending to a top-level array needs no positional operator, but it
has its own problem: after the $push, how do you identify the
element you just pushed so you can return it? Hence, construct
an ObjectId yourself:
const turnId = new Types.ObjectId();
const updatedPostboard = await Postboard.findOneAndUpdate(
{ postId },
{ $push: { turns: { _id: turnId, text, authorId: userId } } },
{
new: true,
projection: { turns: { $elemMatch: { _id: turnId } } },
runValidators: true,
},
);
const addedTurn = updatedPostboard?.turns[0];
$push is concurrency safe. Multiple users can be appending to the same array without
any data inconsistency.
$push is also the one update operator where Mongoose still does work for
you: it casts the pushed object through the subdocument schema, so
defaults and subdocument timestamps are applied. The projected
result comes back with createdAt and updatedAt already stamped.
And finally, the element deletion from a subdocument array.
const result = await Postboard.updateOne(
{ postId, turns: { $elemMatch: { _id: turnId, authorId: userId } } },
{ $pull: { turns: { _id: turnId } } },
);
$pull is concurrency safe. Multiple users can be removing from the same array without
any data inconsistency.