RE:NODE

MongoDB transactions on a single node: replica set and limits

By RE:NODE Team
15 min read


MongoDB can run multi-document ACID transactions, but only on a replica set or a sharded cluster. A plain standalone mongod refuses them with the error Transaction numbers are only allowed on a replica set member or mongos. The fix on a single machine is a replica set with one member, which costs a config line and a one-off command and gives you transactions, change streams and the same code path you will use in production later. The other answer, often the better one, is to design the data so that you do not need a transaction at all: a single-document write is always atomic, and a well-shaped document keeps the things that must change together in one place.

This post covers what a transaction needs, what a standalone server does instead, how to turn a single node into a one-member replica set, the limits that bite (a 60 second default lifetime, write conflicts, what you cannot do inside a transaction), and the patterns for the cases where you cannot or will not use one. Version details are given where they matter; check the manual for your server version, because transaction behaviour has changed between 4.0, 4.2, 4.4 and 5.0.

What a MongoDB transaction needs#

A transaction is a group of reads and writes that either all take effect or none do, and that other clients never see half-finished. MongoDB has always had that guarantee for one document. Multi-document transactions arrived in version 4.0 for replica sets and in 4.2 for sharded clusters. Three things have to be true before one will start:

  • A replica set or a sharded cluster. The transaction machinery writes to the oplog, the replication log, and uses its timestamps to give every transaction a consistent snapshot. A standalone server has no oplog.
  • A session. Every transaction lives inside a client session. The drivers hide most of this, but you will see startSession() in every example.
  • Reasonable timing. A transaction must commit inside its lifetime limit, 60 seconds by default, and it should take milliseconds.

The usual shape is a short function that moves related data together: debit one account and credit another, decrement stock and insert an order, mark a job as taken and write its audit record. If two collections must agree, a transaction is the straightforward tool. The price is extra latency, extra memory held for the snapshot, and retry logic in your application.

Transactions also bring a vocabulary worth knowing:

TermMeaning
readConcern: "snapshot"Every read inside sees one consistent point in time
writeConcern: "majority"Commit waits until the write is durable on a majority
TransientTransactionErrorThe whole transaction can be retried from the start

Why a standalone node says no#

A standalone mongod is a server started without any replication setting. It stores data and journals writes, and that is all. It keeps no oplog, has no concept of primary or secondary, and so has nothing to hang a multi-document transaction on. Try to start one and you get this:

code
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos

The error comes from the first operation inside the transaction, not from startTransaction() itself, which is why it often surfaces somewhere confusing in an application's stack trace. It is code 20, IllegalOperation. The same limitation applies to change streams: collection.watch() also needs the oplog and fails on a standalone with a similar message.

Two things are worth being clear about. First, single-document writes on a standalone server are still atomic. An updateOne that changes five fields of one document either changes all five or none; readers never see a document with three of them changed. Second, nothing about the standalone is broken. It is a simpler server mode that trades the replication features for less to run. Plenty of production workloads use it, usually together with regular dumps, as covered in mongodump and mongorestore.

Connection strings reflect the difference: a standalone is one host with no replicaSet option, a replica set names its set. The details are in MongoDB connection strings and the URI format reference.

A single-node replica set#

A replica set with one member is a legitimate configuration. It elects itself primary, keeps an oplog, supports transactions and change streams, and behaves like a bigger set that has not grown yet. What it does not give you is redundancy: with one member there is no failover and no second copy. w: "majority" on a one-member set means "this node acknowledged it", which is the same durability as a standalone with journalling, and no more.

The conversion is the same whether you start fresh or convert an existing standalone, and the data files stay as they are. On a machine you control, set the replica set name in mongod.conf:

mongod.conf
replication:  replSetName: rs0

Or pass it on the command line for a quick local test:

bash
$ mongod --dbpath ./data --replSet rs0 --bind_ip localhost --port 27017

Restart the server, connect with mongosh, and initiate the set once:

javascript
rs.initiate({  _id: "rs0",  members: [{ _id: 0, host: "localhost:27017" }]})

Give host a name that your clients can resolve and reach, because the driver discovers members by the names stored in the set configuration, not by the address you typed. A set initiated with localhost works for an application on the same machine and fails for everyone else. Within a few seconds rs.status() should report the member as PRIMARY:

javascript
rs.status().members.map(m => ({ name: m.name, state: m.stateStr }))

There are consequences to plan for:

  • Authentication. A replica set with access control on needs an internal authentication mechanism between members, normally a key file referenced by security.keyFile. Even with one member, follow the manual's replica set authentication steps rather than guessing.
  • Disk. The oplog is a capped collection that takes real space. Its default size is derived from the free disk space, so check how big it is with db.getReplicationInfo() and size it explicitly on a small disk.
  • Connection strings. Add replicaSet=rs0 so the driver speaks replica set protocol, or directConnection=true to skip discovery and talk to one named host.
  • Restarts. The set comes back as primary after a restart without any further commands. You initiate only once.
startTransactionwritescommit entryApplicationdriver sessionmongod rs0single PRIMARYOplogreplication logCollectionsWiredTiger
What a transaction touches on a one-member set

Is it available on RE:NODE database hosting?#

RE:NODE database hosting sells MongoDB, with credentials generated for each server, reached directly on the host and port shown on the plan. We do not state on the plan page whether that server runs as a replica set, and you should not assume it does. A MongoDB server you rent as a plan gives you a connection, not a mongod.conf to edit, so the question of whether transactions work is a property of the server, not of your code.

The way to find out is to ask, and then to test:

  1. Open a ticket from the panel and ask whether your MongoDB server runs as a replica set and whether multi-document transactions are supported. Do not rely on this post for the answer.
  2. Run the short test in the next section against your own connection. A standalone fails with the error above on the first write; a replica set commits.
  3. If the answer is no, use the single-document and two-phase patterns later in this post, which work on any MongoDB. If you need real transactions, run your own mongod as a one-member replica set on a machine you administer, such as a VDS, and the section above applies to you directly.

The databases section of the guides explains how database credentials work in the panel. Check them before you debug a connection that is really an authentication problem; authentication failed and authSource covers the common traps.

Writing a transaction#

The recommended shape is the driver's callback API. It starts the transaction, runs your function, commits, and retries the whole thing on a transient error, so your code does not have to. This Node.js example moves money between two accounts and writes a ledger row, three writes that must agree:

javascript
const { MongoClient } = require("mongodb");const client = new MongoClient(process.env.MONGODB_URI);await client.connect();const db = client.db("bank");async function transfer(from, to, amount) {  const session = client.startSession();  try {    await session.withTransaction(async () => {      const debit = await db.collection("accounts").updateOne(        { _id: from, balance: { $gte: amount } },        { $inc: { balance: -amount } },        { session }      );      if (debit.modifiedCount !== 1) throw new Error("insufficient funds");      await db.collection("accounts").updateOne(        { _id: to }, { $inc: { balance: amount } }, { session });      await db.collection("ledger").insertOne(        { from, to, amount, at: new Date() }, { session });    }, {      readConcern: { level: "snapshot" },      writeConcern: { w: "majority" }    });  } finally {    await session.endSession();  }}

Three habits matter. Pass { session } to every operation inside, because an operation without it runs outside the transaction and commits on its own. Keep the callback free of side effects such as sending email or calling an API, because it can run more than once. And throw to abort: an exception from the callback aborts the transaction, and withTransaction rethrows it.

For a quick check from mongosh, which doubles as the test for your server:

javascript
const session = db.getMongo().startSession();const coll = session.getDatabase("test").getCollection("t");session.startTransaction();coll.insertOne({ probe: 1 });session.commitTransaction();session.endSession();

On a standalone the insertOne throws the Transaction numbers are only allowed error. On a replica set it commits, and you can delete the probe document afterwards.

Limits you will hit#

Transactions are meant to be short and small. The limits below are the ones that matter in practice, and several are configurable on a server you administer.

LimitDefault
Transaction lifetime60 seconds
Lock wait for a conflicting operation5 milliseconds
Single document size16 MB

The first is transactionLifetimeLimitSeconds, 60 by default. A background process aborts any transaction that runs longer, and the next operation on it fails with NoSuchTransaction. The second is maxTransactionLockRequestTimeoutMillis, which is how long a transaction waits to take a lock before giving up. On a server you do not administer, you cannot change either, so design for them.

Other behaviour to know:

  • Write conflicts fail fast. If two transactions modify the same document, the second gets a WriteConflict error straight away instead of waiting. The error carries the label TransientTransactionError, which means "retry the whole transaction". The callback API does that for you; with the core API you must catch the label yourself.
  • Commit can be uncertain. If the connection drops during commit, the driver raises UnknownTransactionCommitResult and the commit may or may not have happened. Retrying the commit is safe; retrying the whole transaction is not, unless your writes are idempotent.
  • Size. In 4.0, the whole transaction had to fit in one 16 MB oplog entry. From 4.2 the transaction can be spread over several oplog entries, so that single cap no longer applies, but the 16 MB limit on each document does, and large transactions still cost memory and lock time. If you are near the old limit, you are using a transaction as a batch job.
  • Collections. Before 4.4 the collections and indexes had to exist before the transaction started. From 4.4 you can create them inside one. Transactions cannot touch the admin, config or local databases or write to capped collections.
  • Read concern. Reads inside use the transaction's snapshot. They do not see writes committed by others after it began, which is the point, and also a reason not to hold one open while waiting for a user.
  • Memory. A snapshot keeps old versions of changed data in the cache for as long as the transaction lives. A long transaction is not just slow, it pushes other work out of memory.

Whatever the exact numbers on your version, the rule is the same: do the reads and decisions before opening the transaction where you can, open it, write, commit, and be done in milliseconds. If you find yourself raising the lifetime limit, the design is the problem, not the limit.

Atomic without a transaction#

A surprising amount of work needs no transaction, because a write to one document is atomic. MongoDB applies all the changes in a single updateOne or findOneAndUpdate as one unit, including changes to embedded documents and arrays inside it. If you can arrange for the data that must change together to live in one document, you get atomicity on any server, standalone included, with no retry logic and no snapshot.

The tools are the update operators and a filter that encodes the precondition:

javascript
// Reserve one seat: the filter is the check, the update is the actiondb.flights.findOneAndUpdate(  { _id: "LH100", seatsLeft: { $gt: 0 } },  { $inc: { seatsLeft: -1 }, $push: { passengers: { name: "Ada", at: new Date() } } },  { returnDocument: "after" })

If no document matches, nothing changes and the call returns null, so overselling is impossible. The check and the write cannot be separated by another client. The same pattern works for stock counters, rate limits, job claims ({ status: "queued" } becomes { status: "running", worker: id }) and optimistic locking with a version field that the filter compares and the update increments.

Two rules keep this reliable. Put the condition in the filter, not in application code that reads first and writes second, because that gap is exactly where a race lives. And use update operators such as $inc and $set instead of replacing the whole document from a stale copy.

Embedding and schema design#

The next question is where data should live, and it decides how often you need atomicity across documents. In a relational design, an order, its lines and its payment are three tables and a transaction. In MongoDB you can often make them one document:

json
{  "_id": "order-1042",  "customer": "c-77",  "status": "paid",  "lines": [    { "sku": "A-100", "qty": 2, "price": 1800 },    { "sku": "B-220", "qty": 1, "price": 950 }  ],  "payment": { "method": "card", "paidAt": "2026-10-22T09:14:00Z" },  "total": 4550}

That document is written and read in one operation, and changes to it are atomic. Embedding fits when the parts are read together, belong to one parent, and stay bounded in size. It fits badly when the embedded array grows without limit (comments on a popular post), when the child is shared by many parents, or when the document would approach the 16 MB limit. Then you reference by _id and accept two collections.

There is also a middle road: keep the source of truth in one document and copy the few fields other queries need. The copy can drift, so decide which field is authoritative and refresh the copies from it. The trade-offs, with indexes that support them, are in MongoDB indexes and schema design, and the question of whether a document store is right for your data at all is in Postgres or MongoDB. If your data is relational and you want transactions as the default rather than a special mode, the SQL side is covered in MySQL transactions, locking and deadlocks.

Two-phase patterns across documents#

When the data really lives in different documents and you have no transactions, the established pattern is a two-phase commit that you build yourself. The MongoDB manual describes it for the money-transfer case. You add a transactions collection that records the intent and its state, and each affected document remembers which pending transaction touched it.

  1. Insert a transaction record with state: "initial", the source, destination and amount.
  2. Set the record to pending, then apply the change to each account, adding the transaction _id to a pendingTransactions array on that account. The filter ensures each application happens once.
  3. Set the record to applied.
  4. Remove the transaction _id from both accounts' pendingTransactions arrays.
  5. Set the record to done.
javascript
// Step 2, source account: only if not already applieddb.accounts.updateOne(  { _id: "A", pendingTransactions: { $ne: tid }, balance: { $gte: 100 } },  { $inc: { balance: -100 }, $push: { pendingTransactions: tid } })

If the process dies at any point, a recovery job scans the transactions collection for records stuck in pending or applied and either finishes or reverses them using the same states. The pattern is idempotent by construction: the $ne: tid check means running a step twice changes nothing. That property is what you are building, and it is worth having even when you do have real transactions, because a retried request or a crashed worker needs the same protection.

It is also considerably more code and more states than a transaction. Use it when you cannot get a transaction, or when the operation crosses systems that no database transaction can span, such as a payment provider. For everything on one replica set, the callback API is shorter and harder to get wrong.

Choosing between them#

A short decision path covers most cases:

  • Everything that must change together fits in one document: use single-document atomic updates. No replica set needed.
  • Two or three documents, short operation, you control the server: a one-member replica set and withTransaction.
  • You cannot enable a replica set (a rented server where support says no): embed where you can, use the two-phase pattern where you cannot, and make every write idempotent.
  • The work is a long batch or touches thousands of documents: not a transaction. Use bulk writes in chunks, record progress, and make the job restartable.

Whichever you choose, test the failure paths: kill the process between steps and confirm the data is repairable. And keep backups, because neither a transaction nor a two-phase commit saves you from a bad delete. How to take and, more importantly, restore them is in database backups and restores.

FAQ#

Can I use transactions on a standalone MongoDB?

No. Multi-document transactions need a replica set or a sharded cluster. A standalone server rejects them with Transaction numbers are only allowed on a replica set member or mongos. Single-document updates are atomic on a standalone, and you can build a two-phase pattern on top.

Does a one-member replica set make my data safer?

No. It enables transactions and change streams, but it is still one machine with one copy of the data, so there is no failover and no redundancy. Your backups are what protect you.

Why did my transaction fail after about a minute?

The default transactionLifetimeLimitSeconds is 60. A transaction that is still open after that is aborted, and your next operation fails with NoSuchTransaction. Shorten the transaction by moving slow work, such as network calls, outside it.

Is there a size limit on a transaction?

Each document is still limited to 16 MB. In 4.0 the whole transaction also had to fit in one 16 MB oplog entry; from 4.2 it can span several. Even so, large transactions are slow and hold memory, so split bulk work into chunks.

Do I need transactions for a counter or a stock decrement?

Usually not. A single updateOne or findOneAndUpdate with a filter such as { stock: { $gte: 1 } } and an $inc is atomic and cannot oversell. Reach for a transaction only when more than one document has to agree.

Does my RE:NODE MongoDB server support transactions?

The plan page does not say that the server runs as a replica set, so do not assume it. Open a ticket from the panel and ask, then run the mongosh probe above against your connection to confirm.

Was this helpful?