Databases

The retryWrites=false Trap in Firestore's MongoDB Compatibility

Firestore speaks the MongoDB wire protocol, but rejects retryable writes. If your driver defaults to on, writes fail in a way that looks random. Here is why, and the fix.

Firestore in Enterprise edition speaks the MongoDB wire protocol, so you can point a standard MongoDB driver at it and most things just work. You get MongoDB query syntax, MongoDB indexes, and a managed database with no servers to run. Then some writes start failing with an error that has nothing to do with your data, and only some of the time. The cause is retryWrites, a driver default that Firestore does not support. This post explains what the setting does, why the compatibility layer rejects it, how to recognize the failure, and what you now owe the system once you turn it off.

What retryable writes actually do

In real MongoDB, a retryable write lets the driver resend a write exactly once if the first attempt fails at the network or election layer, without the risk of applying it twice. It works by attaching a transaction number to the operation. The server records that number, so if it sees the same operation again it knows this is a retry of a write it may have already applied, and it deduplicates instead of writing a second copy.

This is a genuinely good default. Networks are unreliable, and primary elections happen. Retryable writes turn a whole class of transient failure into a silent, safe recovery. That is why every modern MongoDB driver ships with retryWrites=true and why most people never think about it. It is doing its job invisibly.

The important detail is that the safety depends entirely on the server implementing that transaction-number bookkeeping. The client cannot make a write idempotent on its own. It can only ask the server to dedupe, and trust that the server knows how.

Why Firestore rejects it

Firestore's MongoDB compatibility implements the wire protocol and the query surface, but it is a different storage engine underneath with a different transaction model. It does not implement the retryable-write bookkeeping that the protocol's transaction numbers assume. So when the driver, being helpful, attaches retryable-write metadata to your insert, the server sees a feature it does not support and refuses the operation.

The failure mode is the part that costs you an afternoon. The error does not say "retryWrites is unsupported, please disable it." It surfaces as a more generic write failure, and because it depends on which code path attached the metadata and when, it can look intermittent. You will see it on some writes and not others, or on startup but not in a unit test that uses a different connection. Every instinct sends you to inspect the document, the schema, the index. The document is fine. The connection is misconfigured.

The fix is one flag, and where you put it matters

Set retryWrites=false in the connection URI. That is the entire fix, and it has to be present on the very first connection you open.

// retryWrites=false is required. Firestore's MongoDB compatibility rejects
// the retryable-write protocol that the driver enables by default.
uri := "mongodb://USER:PASS@HOST/db?retryWrites=false"

client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
	return nil, fmt.Errorf("connect firestore: %w", err)
}

You can also disable it with a code option (options.Client().SetRetryWrites(false)), but prefer the URI. The reason is human, not technical. The URI string is the thing people copy: into a new service, into a migration script, into a one-off debugging shell, into a teammate's environment file. Every one of those copies is a place the true default would silently return if the setting lived in code that did not come along for the ride. Put it in the connection string and it travels with every use of the database.

Make it impossible to forget by validating it at startup. If the URI a service is handed does not disable retryable writes, fail loudly on boot rather than mysteriously on the first write.

if !strings.Contains(uri, "retryWrites=false") {
	return nil, errors.New("firestore URI must set retryWrites=false")
}

Turning it off does not make your writes safe

Here is the part that catches people after they have fixed the error and moved on. Disabling retryWrites did not remove the need to retry. Networks still drop, timeouts still happen, and your own code will still, somewhere, resend a write that may or may not have landed. What changed is that nothing deduplicates that resend anymore. The safety net you had in real MongoDB is gone, and the responsibility moved to you.

If any write path in your system can be retried, and in a distributed system every write path can, then those writes must be idempotent by construction. Running the same write twice has to leave the database in the same state as running it once.

The clean way to get there is to stop using random document ids for anything you might re-send. Derive the id from the content that identifies the document, so that a retried insert targets the same id and turns into a harmless overwrite instead of a duplicate row.

// Same logical document, same id, every time. A retried upsert overwrites
// in place instead of inserting a second copy.
id := PostID(slug, lang, publishedAt)

_, err := coll.UpdateByID(ctx, id,
	bson.M{"$set": doc},
	options.Update().SetUpsert(true),
)

An UpdateByID with upsert and a deterministic id is idempotent on its own, with no server-side dedupe required. The first call inserts, every retry overwrites identical data, and the row count never grows. The full derivation, including the one field you must never change afterward or you will orphan the old document, is covered in deterministic ULIDs for idempotent upserts.

The other compatibility edges worth knowing

retryWrites is the one that bites first because it fails on ordinary writes, but the same principle applies across the compatibility layer: the wire protocol is supported, but not every server-side feature behind it is. Before you lean on something, confirm the compatible engine implements it rather than just accepting the command. In practice that means treating the compatibility layer as its own database with its own limits, not as a drop-in MongoDB. Read the supported-features documentation once, up front, instead of discovering each gap through a production incident.

The mindset shift is the real lesson. A compatibility layer gives you the protocol and the query language, which is most of the ergonomics. It does not promise every operational guarantee of the original, and the guarantees it omits tend to be exactly the invisible ones you were relying on without noticing, like automatic write dedup.

The checklist before you trust the connection

  • retryWrites=false is in the URI itself, not only in one client's code options, so it travels with every copy of the connection string.
  • Startup validation fails loudly if the flag is missing, so a misconfigured environment cannot reach the first write.
  • Every write path is an idempotent upsert keyed by a stable, content-derived id, not an insert with a random id.
  • Your retry logic assumes at-least-once delivery, so replaying any write leaves the same state.

Do the first two and the compatibility layer stops throwing errors that look random. Do the last two and your own retries stop quietly duplicating data once the server's safety net is gone. The first pair makes the symptom disappear. The second pair fixes the thing the symptom was warning you about.

Related posts