All posts
Resilience & ScaleJuly 3, 2026

Designing Read-Only Modes Without Breaking UX

Flipping a system to read-only is the easy 20%. The hard part is the UX: a naive read-only mode either lies to the user or buries them in error modals. Here's how to make degraded write paths feel like a deliberate product state instead of a broken one.

#architecture#resilience#ux#read-only#nodejs#typescript

The flag was the easy part

In my article on graceful degradation, I told the story of the night the GTA V launch took down a game store I worked on. Sales opened at 11 p.m., and by half past midnight the single Magento box, frontend, backend, and database all on it, was buried under the rush and gone. Not slow. A blank page, on the one launch that mattered most. The fix we shipped wasn't a bigger server. It was a read-only mode. When traffic spiked, writes got shed and the catalog kept rendering from cache.

Here's what nobody tells you about that fix: flipping the system to read-only was the easy 20%. Routing reads to a replica, gating writes behind a flag, that's an afternoon. The 80% that actually decides whether the product survives is the UX. A read-only mode that's implemented correctly at the infrastructure layer and carelessly at the interface layer still ships a broken product. The user doesn't experience your architecture. They experience the button.

This article is about that button.

The two ways a naive read-only mode breaks

When teams bolt on read-only mode under pressure, they almost always fall into one of two traps, and both destroy trust faster than a clean error page would.

The silent lie. Writes are disabled server-side, but the UI doesn't know. The user clicks "Save," the frontend shows a success toast, and the write quietly hits a wall on the backend. The user walks away believing their data is safe. It isn't. This is the worst outcome of all, worse than an outage, because the user finds out later, when it's expensive.

The error storm. The opposite overcorrection. Every write path throws, so the user gets a 500 modal on "Add to cart," another on "Save preferences," another on "Post comment." The product technically works, reads are fine, but it feels dead. The user can't tell "one feature is paused" from "the whole thing is on fire," so they assume the worst and leave.

  THE SILENT LIE                    THE ERROR STORM

  [ Save ] → ✓ "Saved!"             [ Save ] → ✗ 500
       │                                 │
       ▼                                 ▼
  write silently dropped            user sees a dead product
  (user finds out too late)         (reads worked, but who's staying?)

A good read-only mode threads the needle between them: honest about what's paused, quiet about everything that still works.

Step one: make the mode a first-class contract

The root cause of both failure modes is the same, the frontend doesn't know the system is degraded. So the first design decision isn't about buttons at all. It's to make the system mode an explicit, broadcast piece of state that the client can read.

One source of truth on the server, exposed on every response and on a cheap status endpoint the client can poll or read on boot:

type SystemMode = "normal" | "read-only";
 
// One place decides the mode (a flag, a load signal, a failover event).
function currentMode(): SystemMode {
  return flags.readOnly || loadShedder.isTripped() ? "read-only" : "normal";
}
 
// Broadcast it on every response so the client is never guessing.
app.use((req, res, next) => {
  res.setHeader("X-System-Mode", currentMode());
  next();
});
 
// And expose it explicitly so the UI can render intent on first paint.
app.get("/system/status", (_req, res) => {
  res.json({ mode: currentMode(), since: modeChangedAt });
});

Now the client can render the intent of the system instead of discovering it one failed request at a time. Everything below builds on this. Without it, you're back to guessing from HTTP status codes, which is exactly how the error storm starts.

Step two: transform write affordances, don't just kill them

This is the part I'm proudest of from that night's fix, and the part most teams skip.

The lazy move is to disable every write button and gray it out. Better than a silent lie, but you've still amputated the product. On our store, the write that mattered was "Add to cart", the entire point of the visit. Greying it out on launch night would have told thousands of hyped gamers "come back later," which is just a slower way to lose them.

So instead of removing the write, we degraded it to a lighter one. Under read-only load, the heavy "Add to cart" (which hammered the quote and order tables) was swapped for a lightweight static form that captured just an email and the game ID. No cart write, no order write, just an intent we could fulfill the moment writes came back.

function BuyAction({ mode, gameId }: { mode: SystemMode; gameId: string }) {
  if (mode === "normal") {
    return <AddToCartButton gameId={gameId} />;
  }
  // Degraded: capture intent cheaply instead of amputating the action.
  return (
    <NotifyMeForm
      gameId={gameId}
      note="High demand right now, drop your email and we'll hold your spot the moment checkout reopens."
    />
  );
}

The user still did something. The store still sold something, in intent, to be converted minutes later. That's the difference between a degraded product and a broken one: the core value path stays open, just narrower.

The rule: for the writes that define your product, find the cheapest version of the action that still moves the user forward. Only fall back to disabling when there's genuinely no lighter version worth capturing.

Step three: queue what's honest, reject what's dangerous, clearly

Not every write can be degraded into a lighter one. For the rest, you have two honest options, and the choice is per-write, never global.

Queue it, when the write is safe to apply later and the user can trust "it'll sync." Accept it, tell the truth, reconcile when writes resume:

if (currentMode() === "read-only") {
  await outbox.enqueue({
    type: "savePreferences",
    userId,
    payload,
    ts: Date.now(),
  });
  return res.status(202).json({
    queued: true,
    message: "Saved, we'll sync this the moment the system catches up.",
  });
}

Reject it clearly, when applying it later would be wrong or unsafe (a payment, a balance transfer, anything where "we'll get to it" is a lie). Say so plainly, with a status code that's honest and a message that isn't scary:

return res.status(503).json({
  code: "TEMPORARILY_READ_ONLY",
  message:
    "We've paused new payments for a few minutes to stay stable. Nothing was charged.",
  retryable: true,
});

The 202 vs. 503 distinction is the whole game. A 202 promises "we've got this." A 503 admits "not right now." Sending a 202 for something you'll never process is the silent lie in a nicer costume. Only queue what you will genuinely reconcile.

Step four: reconcile without duplicating

Queued writes create a debt you have to pay when the system recovers, and paying it carelessly is its own outage. If the user retried, or the queue redelivers, you can double-apply. Make queued writes idempotent with a client-supplied key so reconciliation is safe to run more than once:

async function applyQueued(op: QueuedOp) {
  // Same key applied twice = one effect. The retry can't double-charge.
  await db.query(
    `INSERT INTO applied_ops (idempotency_key) VALUES ($1)
     ON CONFLICT (idempotency_key) DO NOTHING`,
    [op.idempotencyKey],
  );
  if (db.rowCount === 0) return; // already applied, skip
  await handlers[op.type](op.payload);
}

A read-only mode that comes back and double-processes the backlog hasn't recovered. It's created a second incident with a nicer name.

Step five: one honest, quiet banner

Finally, tell the user, once, calmly. A single scoped status banner beats a scatter of error modals every time:

Running in a lightweight mode right now. Browsing and reading are fully live, some actions are paused for a few minutes while we stay stable.

Two properties make this work. It's scoped, it names what's paused so the user doesn't assume the whole product is down. And it's calm, the tone of a system in control, not one on fire. The banner is the difference between the user thinking "they're handling it" and "it's broken."

The trade-offs, because this isn't free

Why this makes your infrastructure cheaper

Here's the cost angle, the same one that runs through everything I write. A system that can serve a genuinely usable experience from a read replica and cache alone doesn't need its write path provisioned for peak. You can size writes for the normal case and shed into read-only during the twice a year spike, instead of paying, 24/7, for write capacity you use for two hours a year.

Back at that store, the alternative on the table was a much bigger box to survive one night. A well-designed read-only mode let us survive that night on the same modest hardware, because under peak the product stopped writing hard and kept selling intent. The read-only path wasn't just a resilience feature. It was the reason we didn't have to buy capacity we'd never use again.

Does your read-only mode ship a product, or a wall?

  1. Does the frontend know the system is read-only, or is it guessing from failures?
  2. For your most important write, is there a lighter version that still moves the user forward, instead of a greyed-out button?
  3. When you accept a write in degraded mode, will you actually reconcile it, or is the 202 a lie?
  4. Are queued writes idempotent, so recovery can't double-apply them?
  5. Is there one calm, scoped status message, instead of a storm of error modals?
  6. When did you last turn read-only mode on in staging and click around?

If the honest answer to #3 is "we're not sure," turn the queue off and reject clearly. A product that says "not right now" keeps its user's trust. One that says "saved!" and loses the data doesn't get a second chance.