<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hugo Vantighem</title>
    <description>The latest articles on DEV Community by Hugo Vantighem (@hugo_vantighem).</description>
    <link>https://dev.to/hugo_vantighem</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3947999%2Fe0a53736-f880-455e-9965-c3b4bf950342.jpg</url>
      <title>DEV Community: Hugo Vantighem</title>
      <link>https://dev.to/hugo_vantighem</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hugo_vantighem"/>
    <language>en</language>
    <item>
      <title>When "it works in prod" becomes worse than "it works on my laptop"</title>
      <dc:creator>Hugo Vantighem</dc:creator>
      <pubDate>Mon, 01 Jun 2026 09:28:16 +0000</pubDate>
      <link>https://dev.to/hugo_vantighem/when-it-works-in-prod-becomes-worse-than-it-works-on-my-laptop-1aji</link>
      <guid>https://dev.to/hugo_vantighem/when-it-works-in-prod-becomes-worse-than-it-works-on-my-laptop-1aji</guid>
      <description>&lt;p&gt;Every engineering team accumulates technical debt. That's normal.&lt;/p&gt;

&lt;p&gt;The dangerous moment isn't when the debt appears. It's when everyone agrees it's there — and decides to keep building on top of it anyway.&lt;/p&gt;

&lt;p&gt;I've seen that shift happen in almost every engineering team I've worked with. It doesn't announce itself. It arrives quietly, dressed as pragmatism. And by the time you notice it, it's already become the default way the team operates.&lt;/p&gt;

&lt;p&gt;It starts with a gap in knowledge. And that's completely fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first error is innocent
&lt;/h2&gt;

&lt;p&gt;Making design or architecture mistakes isn't a problem. We all do the best we can with the knowledge we have at the time. That's not a failure — it's how engineers grow. You learn by building, and sometimes you build something wrong before you know what right looks like.&lt;/p&gt;

&lt;p&gt;The first bad design decision is usually just that: a knowledge gap. Nobody in the room knew better. They shipped with what they had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then someone learns something
&lt;/h2&gt;

&lt;p&gt;Time passes. The team gains experience. Someone reads the right book, works through a gnarly bug, or joins from a company that had solved this exact problem already. The gap closes.&lt;/p&gt;

&lt;p&gt;And with it comes the moment that defines a team's culture.&lt;/p&gt;

&lt;p&gt;That person — maybe you — points to the design flaw. Not to criticize, not to reopen old wounds, but because they can see it clearly now and want the team to move forward. They bring the argument. They show why it matters.&lt;/p&gt;

&lt;p&gt;And here's where things split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two possible answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The first answer&lt;/strong&gt; is a technical discussion. Maybe the team agrees immediately. Maybe they push back, challenge the framing, propose a different fix. Maybe they decide the cost of changing is too high right now and park it for later. Any of that is fine. The conversation is alive. The team is reasoning together about its own code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second answer&lt;/strong&gt; is: &lt;em&gt;"Yeah but it already works like this in prod. Why change it?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The conversation stops. The technical debate is over before it started. And something quietly shifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real tipping point
&lt;/h2&gt;

&lt;p&gt;When that second answer happens, the error hasn't disappeared. It's now fully visible — known, understood, and acknowledged by everyone in the room. But instead of becoming a problem to solve, it becomes a fact to live with.&lt;/p&gt;

&lt;p&gt;Known.&lt;br&gt;
Understood.&lt;br&gt;
Accepted.&lt;br&gt;
Normalized.&lt;/p&gt;

&lt;p&gt;That's the tipping point. Not the original mistake — that was just a knowledge gap. The tipping point is the moment the team decides, consciously, that a known flaw is the way things are.&lt;/p&gt;

&lt;p&gt;We've moved from "we didn't know better" to "we know, and we're choosing not to act." Those are fundamentally different things. The first is human. The second is a choice about what kind of team you want to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shield protects everything — including what it shouldn't
&lt;/h2&gt;

&lt;p&gt;The pattern is corrosive because "it works in prod" gets used across the entire spectrum — and the severity of what it's shielding rarely changes the answer.&lt;/p&gt;

&lt;p&gt;Sometimes it's a structural decision that's now load-bearing: real cost, real risk, the resistance is at least understandable. The mistake is using the phrase to close the conversation rather than as a factor in a real discussion about when and how to address it.&lt;/p&gt;

&lt;p&gt;But then there's the case that should never be shielded.&lt;/p&gt;

&lt;p&gt;Design flaws with real business risk attached. A service that writes to two stores. Someone points out the write isn't atomic — a crash between the two leaves them permanently out of sync. The answer comes back: &lt;em&gt;"We've been running it for two years."&lt;/em&gt; Not because the concern is wrong. Because it hasn't exploded yet.&lt;/p&gt;

&lt;p&gt;A data model that creates silent desync under concurrent load. A sequence that can leave the database in a half-committed state on crash. An architecture that, under the right conditions — a spike, a timeout, a race — loses data.&lt;/p&gt;

&lt;p&gt;It works in prod. &lt;em&gt;For now.&lt;/em&gt; Because the conditions that trigger it haven't aligned yet — or because they have, quietly, and nobody noticed.&lt;/p&gt;

&lt;p&gt;At this level, "it works in prod" isn't pragmatism. It's a decision to accept an unquantified risk indefinitely, on behalf of users who don't know they're carrying it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The system isn't stable.&lt;/p&gt;

&lt;p&gt;It's stable &lt;em&gt;so far&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The team knows the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it compounds
&lt;/h2&gt;

&lt;p&gt;What makes this dangerous isn't any single decision. It's what comes after.&lt;/p&gt;

&lt;p&gt;Because the next feature gets built on top of the flaw. Then the next one. Each decision taken in isolation is defensible — you're adapting to what exists. But you're also adding weight to a foundation that everyone now knows is wrong.&lt;/p&gt;

&lt;p&gt;And when the cost of fixing finally becomes impossible to ignore, the same logic applies again: &lt;em&gt;"It would cost too much to redo now."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A new trade-off. Based on the previous one. Which was itself based on the one before.&lt;/p&gt;

&lt;p&gt;This is how absurd architectures are born — not from one catastrophic decision, but from a temporary shortcut that got normalized, then depended upon, then treated as untouchable. Accidental complexity doesn't arrive all at once. It compounds, layer by layer, until it becomes the team's technical culture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "it works in prod" is more dangerous than "it works on my laptop"
&lt;/h2&gt;

&lt;p&gt;Every developer knows what "it works on my laptop" means: the work isn't finished. Nobody treats it as a conclusion — everyone understands it as the beginning of the real work.&lt;/p&gt;

&lt;p&gt;"It works in production" carries the opposite social weight. It sounds like evidence. It carries the authority of real users, real load, real money. And it gets used for exactly the same purpose — to stop the conversation.&lt;/p&gt;

&lt;p&gt;The difference is that one is obviously incomplete. The other gets mistaken for proof.&lt;/p&gt;

&lt;p&gt;That's what makes it more dangerous. Something that breaks locally is obviously broken. Something that works in production has inertia on its side — real users depend on it, changing it carries risk, the system resists being touched. That inertia is real. But the moment it becomes a reason to close a technical conversation rather than inform one, the phrase has shifted from a statement about stability to a shield against growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you lose — and who you lose
&lt;/h2&gt;

&lt;p&gt;The real cost isn't code quality. That's the surface.&lt;/p&gt;

&lt;p&gt;What you lose is the team's ability to reason honestly about its own system. The questions that are supposed to stay open get closed by precedent. And once the culture of closing them is established, it applies to the next problem too. And the one after.&lt;/p&gt;

&lt;p&gt;When "it works in prod" becomes the team's default posture, you can map it exactly onto what "it works on my laptop" reveals about an individual developer: someone who has stopped asking whether it's right, and started asking only whether it's currently not broken. At the individual level, that's a conversation. At the team level, it's a culture.&lt;/p&gt;

&lt;p&gt;The tell is simple: watch what happens when someone raises a known problem. Does the team ask &lt;em&gt;"how do we address this?"&lt;/em&gt; or &lt;em&gt;"why are we even talking about this?"&lt;/em&gt; The first compounds knowledge. The second compounds debt — and has decided that's fine.&lt;/p&gt;

&lt;p&gt;The engineers who care most are also the ones most likely to leave when the answer is always a wall. Not because of the debt itself. Because of what the wall says about the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only thing that's not negotiable
&lt;/h2&gt;

&lt;p&gt;Every team carries debt they can't pay down immediately. Every team makes trade-offs under pressure. That's not the failure.&lt;/p&gt;

&lt;p&gt;The failure is letting the conversation die.&lt;/p&gt;

&lt;p&gt;The technique is learnable. You can hire your way out of a knowledge gap, train your way out of a skill gap, refactor your way out of a design gap. None of those require the team to be perfect — just willing.&lt;/p&gt;

&lt;p&gt;The willingness to keep questioning, to hear "we built this wrong" as useful information rather than a threat — that's not a technical skill. It can't be reviewed, tested, or measured. But it's what determines whether a team compounds its knowledge or just compounds its debt.&lt;/p&gt;

&lt;p&gt;Every team accumulates debt. The difference is whether it remains a conscious trade-off or becomes an unquestionable fact.&lt;/p&gt;

&lt;p&gt;The day a team stops discussing known flaws isn't the day the architecture fails.&lt;/p&gt;

&lt;p&gt;It's the day learning stops.&lt;/p&gt;

</description>
      <category>craftsmanship</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Read-Modify-Write isolation in NoSQL: the distributed-lock hell.</title>
      <dc:creator>Hugo Vantighem</dc:creator>
      <pubDate>Thu, 28 May 2026 06:36:00 +0000</pubDate>
      <link>https://dev.to/hugo_vantighem/read-modify-write-isolation-in-nosql-the-distributed-lock-hell-3jo0</link>
      <guid>https://dev.to/hugo_vantighem/read-modify-write-isolation-in-nosql-the-distributed-lock-hell-3jo0</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/hugo_vantighem/read-modify-write-is-where-nosql-concurrency-bugs-begin-1ala"&gt;part 1&lt;/a&gt;, the single-document case was easy. In &lt;a href="https://dev.to/hugo_vantighem/read-modify-write-isolation-in-nosql-part-2-when-the-invariant-spans-multiple-aggregates-29cn"&gt;part 2&lt;/a&gt;, two documents brought Write Skew, and we saw that even a native ACID transaction — snapshot isolation — lets it through.&lt;/p&gt;

&lt;p&gt;So teams reach for the reflex fix: a &lt;strong&gt;distributed lock&lt;/strong&gt; — Redis-based, often a Redlock-style implementation. Acquire a lock on a key, do your Read → Modify → Write, release. On paper, you've finally serialized the critical section — operationally, at least. In practice, you've stepped on three mines.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Network latency
&lt;/h2&gt;

&lt;p&gt;Every guarded transaction now makes extra round-trips to Redis — before &lt;em&gt;and&lt;/em&gt; after hitting your NoSQL store. &lt;/p&gt;

&lt;p&gt;You've doubled your coordination surface and taken a hard dependency on a second system being up, reachable, and fast on the hot path of every write. &lt;/p&gt;

&lt;p&gt;The "fast" database is now gated by the lock service. And the coupling bites harder than the average latency suggests: every Redis tail-latency spike becomes &lt;em&gt;your&lt;/em&gt; write-latency spike — your p99 inherits Redis's p99 — and if Redis fails over mid-transaction, the lock you think you're holding can effectively vanish on the new primary, dropping you straight into the corruption case below.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Deadlock
&lt;/h2&gt;

&lt;p&gt;You can dodge deadlock entirely with a single coarse lock — but then every writer serializes on it, and you've thrown away the very concurrency you reached for NoSQL to get. &lt;/p&gt;

&lt;p&gt;So to keep throughput you go fine-grained, one lock per resource — and the moment an invariant touches more than one key (across this series, it always does), deadlock is back on the table: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transaction A locks key X, then needs Y. &lt;/li&gt;
&lt;li&gt;Transaction B locks Y, then needs X.&lt;/li&gt;
&lt;li&gt;Both block until timeout or intervention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The textbook cure — &lt;em&gt;real&lt;/em&gt; deadlock detection, maintaining a wait-for graph across every lock holder and breaking cycles as they form — is a distributed-systems project in its own right: not something you bolt onto a cache you reached for precisely to &lt;em&gt;save&lt;/em&gt; engineering time. &lt;/p&gt;

&lt;p&gt;So nobody builds it.&lt;/p&gt;

&lt;p&gt;Instead teams impose a standing discipline: &lt;em&gt;always acquire locks in the same canonical order&lt;/em&gt; — sort the keys by document ID, lock them in that order, in every code path, every time, forever.&lt;/p&gt;

&lt;p&gt;One writer that grabs them out of order — a refactor, a new feature, a teammate who never got the memo — and the deadlock is back. That convention, plus the hopeful timeouts bolted on for when it slips, is the exact accidental complexity you adopted NoSQL to escape.&lt;/p&gt;

&lt;p&gt;The cognitive load you tried to delete just came back wearing a Redis sticker.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The TTL dilemma
&lt;/h2&gt;

&lt;p&gt;A distributed lock needs a TTL, or a crashed holder blocks everyone forever. &lt;/p&gt;

&lt;p&gt;But the moment you set one, you've broken the one guarantee that mattered: &lt;strong&gt;you lose any coordination between the lease and the real execution time.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;The lock expires on a wall clock; your transaction finishes whenever it finishes — GC pause, slow disk, a network hiccup — and those two clocks have no idea the other exists. &lt;/p&gt;

&lt;p&gt;So there is no safe value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTL too short&lt;/strong&gt; → the lock expires &lt;em&gt;mid-transaction&lt;/em&gt;. A second writer walks in, and you get exactly the corruption the lock was supposed to prevent — now with a false sense of safety.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTL too long&lt;/strong&gt; → a crashed pod blocks the resource until expiry, and your throughput tanks under the very contention you were trying to handle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No TTL value alone can guarantee correctness across all operating conditions.&lt;/p&gt;

&lt;p&gt;No single value is correct across all loads — because correctness constraints and throughput constraints are in direct tension, and the TTL is the single knob you're forced to trade them off against. &lt;/p&gt;

&lt;p&gt;This is the heart of the well-known Kleppmann-vs-Redlock debate: a lock that can &lt;em&gt;expire on its own&lt;/em&gt; is not a sound mutual-exclusion primitive when you need it for correctness, only for efficiency. &lt;/p&gt;

&lt;p&gt;Using it to protect an invariant means betting your data integrity on a timeout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj7e4lf6whd5hdtlau62f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj7e4lf6whd5hdtlau62f.png" alt=" " width="800" height="797"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So are we doomed?
&lt;/h2&gt;

&lt;p&gt;Are we really condemned to choose between the latency and deadlocks of locks, and the silent corruption of going without?&lt;/p&gt;

&lt;p&gt;No. There is a fourth path — &lt;strong&gt;still within NoSQL, with no distributed lock and no external coordination service&lt;/strong&gt; — that delivers strict isolation against inter-document Write Skew. &lt;/p&gt;

&lt;p&gt;An approach where either everything commits, or everything rolls back, with the invariant intact. It doesn't make the contention disappear — nothing does — it just stops bolting a Redis layer on top to manage it, and collapses the coordination back into the database itself. &lt;/p&gt;

&lt;p&gt;That trade is the whole point, and I'll lay it out honestly.&lt;/p&gt;

&lt;p&gt;I laid out the full mechanics — the pure-NoSQL pattern almost nobody reaches for — in the next article.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>distributedsystems</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Read-Modify-Write isolation in NoSQL, part 2: When the invariant spans multiple aggregates.</title>
      <dc:creator>Hugo Vantighem</dc:creator>
      <pubDate>Wed, 27 May 2026 05:45:00 +0000</pubDate>
      <link>https://dev.to/hugo_vantighem/read-modify-write-isolation-in-nosql-part-2-when-the-invariant-spans-multiple-aggregates-29cn</link>
      <guid>https://dev.to/hugo_vantighem/read-modify-write-isolation-in-nosql-part-2-when-the-invariant-spans-multiple-aggregates-29cn</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/hugo_vantighem/read-modify-write-is-where-nosql-concurrency-bugs-begin-1ala"&gt;part 1&lt;/a&gt; we saw the single-document case, where optimistic locking saves you with a simple &lt;code&gt;version&lt;/code&gt; field. Now we cross the line that breaks that comfort.&lt;/p&gt;

&lt;p&gt;Let me make it concrete. Your product sells &lt;strong&gt;seats&lt;/strong&gt;, and an organization buys a license capped at &lt;strong&gt;100 seats&lt;/strong&gt;. Those seats are spread across many &lt;strong&gt;Teams&lt;/strong&gt;, and each Team is its own aggregate with its own lifecycle. You can't stuff the list of all teams into one document: it grows unbounded and violates every aggregate-design instinct you have.&lt;/p&gt;

&lt;p&gt;So your invariant is a &lt;strong&gt;sum across aggregates&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The sum of &lt;code&gt;seats&lt;/code&gt; over all Team aggregates must never exceed 100.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To enforce that on every "add seats to a team" operation, the honest move is to read the current state across &lt;strong&gt;every&lt;/strong&gt; Team and sum it — a fan-out that scans N Team documents and gets slower as the org grows. Painful, yes. But surely reading the &lt;em&gt;real, current&lt;/em&gt; teams inside a transaction is at least &lt;strong&gt;correct&lt;/strong&gt;? That intuition is the trap.&lt;/p&gt;

&lt;p&gt;Now the operation is a textbook Read → Modify → Write — but watch &lt;em&gt;what&lt;/em&gt; each step touches. You &lt;strong&gt;read and sum the Team documents&lt;/strong&gt; as your guard, confirm there's room, then &lt;strong&gt;write the 8 seats onto one Team aggregate&lt;/strong&gt; (&lt;code&gt;Team Alpha&lt;/code&gt;, &lt;code&gt;Team Beta&lt;/code&gt; — each its own document). The check reads a &lt;em&gt;set&lt;/em&gt; of documents; the mutation lands on &lt;em&gt;one&lt;/em&gt; of them. And that's where it gets dangerous.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anomaly: Write Skew
&lt;/h2&gt;

&lt;p&gt;Two requests run concurrently. The teams currently sum to &lt;strong&gt;90&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;guard: Σ seats over all Team docs = 90   (max 100)

  Tx A   reads all teams → Σ = 90 → 90+8 ≤ 100 ✅ → writes Team Alpha (+8) → COMMIT
  Tx B   reads all teams → Σ = 90 → 90+8 ≤ 100 ✅ → writes Team Beta  (+8) → COMMIT
         └─ each reads its OWN snapshot — neither sees the other's in-flight
            write. Two DIFFERENT Team docs → no write-write conflict to abort.

  Σ seats across Team aggregates = 106   &amp;gt;   license cap 100
  Nothing collided, nothing was overwritten.
  The invariant dies between the documents. That's Write Skew.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each transaction read a valid state and made a locally correct decision — yet the real total is now &lt;code&gt;106&lt;/code&gt;, and you've oversold a 100-seat license. The subtle part the trace makes visible: the damage isn't an overwrite. It lives &lt;em&gt;between&lt;/em&gt; the documents, because both transactions validated against a &lt;code&gt;90&lt;/code&gt; that didn't yet include the other's write.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;Write Skew&lt;/strong&gt;, and it's exactly where naive optimistic locking gives up. You'd put a &lt;code&gt;version&lt;/code&gt; on the Team document — but Alpha and Beta were never contended; they're different documents. The contended truth is the &lt;em&gt;seat invariant itself&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Lost update vs. Write Skew — the line this whole series turns on.&lt;/strong&gt;&lt;br&gt;
Part 1's bug was an &lt;em&gt;overwrite&lt;/em&gt;: two writes hit the &lt;strong&gt;same&lt;/strong&gt; document, one erased the other, and the stored value itself came out wrong (10 instead of 11). This is the opposite. Nothing is overwritten — the two writes land on &lt;strong&gt;different&lt;/strong&gt; documents, both commit cleanly, and every document stays internally consistent. The invariant breaks in the &lt;em&gt;gap between&lt;/em&gt; them. Same Read → Modify → Write race, a strictly nastier failure: there's no torn document for a code review, a unit test, or the database to catch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A precise word on isolation levels
&lt;/h2&gt;

&lt;p&gt;Wrap the whole thing in a native MongoDB multi-document transaction (≥ 4.0 on replica sets, 4.2+ on sharded clusters) and you get &lt;strong&gt;snapshot isolation&lt;/strong&gt;: every transaction sees a consistent point-in-time snapshot of the database. That genuinely eliminates the classic ANSI read anomalies — &lt;strong&gt;dirty reads&lt;/strong&gt;, &lt;strong&gt;non-repeatable reads&lt;/strong&gt;, and even &lt;strong&gt;phantoms&lt;/strong&gt; (the snapshot is frozen, so rows born after it are simply invisible) — plus single-document lost updates.&lt;/p&gt;

&lt;p&gt;What it does &lt;strong&gt;not&lt;/strong&gt; give you, on its own, is full &lt;strong&gt;serializability&lt;/strong&gt; — so it can't stop Write Skew. The subtle part most people miss: your clean snapshot isn't enough, because the &lt;em&gt;concurrent&lt;/em&gt; transaction is reading its &lt;strong&gt;own&lt;/strong&gt; snapshot — stale relative to yours — and writing based on it. Two consistent snapshots, two valid decisions, one broken invariant.&lt;/p&gt;

&lt;p&gt;Here's the mental key most devs skip: &lt;strong&gt;the invariant is never encoded as a write conflict.&lt;/strong&gt; WiredTiger (MongoDB's storage engine) only aborts a transaction when two of them write the &lt;em&gt;same&lt;/em&gt; document. Tx A wrote &lt;code&gt;Team Alpha&lt;/code&gt;, Tx B wrote &lt;code&gt;Team Beta&lt;/code&gt; — different documents, so there's nothing for WiredTiger to abort. The constraint lives in your head and your validation check, not in the data the engine is watching for conflicts. Snapshot isolation protects the bytes; it can't protect a rule it was never told about.&lt;/p&gt;

&lt;p&gt;So the fix — whatever you reach for — has to do the one thing the engine never did for you: &lt;strong&gt;make the invariant part of what it watches for conflicts.&lt;/strong&gt; And here's the uncomfortable preview of everything that follows: &lt;em&gt;every&lt;/em&gt; way of doing that ends up serializing these writes somehow, and every one sends you a bill.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the rule    Σ(team.seats) ≤ 100              ← the engine never watches this

the move    materialize it into one doc every writer must also touch:
            license = { usedSeats: 90, maxSeats: 100 }
            → the invisible skew becomes a write-write conflict it CAN catch
              (doing it right — and what it costs — is the rest of this series)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pattern underneath
&lt;/h2&gt;

&lt;p&gt;Strip it back and the lesson is one sentence. Write skew isn't a flaw in transactions, and MongoDB isn't "wrong." &lt;strong&gt;Write skew is what happens when an invariant never becomes part of the database's conflict-detection model&lt;/strong&gt; — a mismatch between the &lt;em&gt;scope you validate&lt;/em&gt; (all the Team docs) and the &lt;em&gt;scope the engine watches for conflicts&lt;/em&gt; (the one doc you write). The same read scope ≠ write scope gap from the guard above, all the way up.&lt;/p&gt;

&lt;p&gt;Hold that sentence — it's the lens for everything that follows. Every cure from here is just a different answer to one question: &lt;em&gt;how do I drag the invariant into something that serializes these writes?&lt;/em&gt; And not one of them is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leads
&lt;/h2&gt;

&lt;p&gt;So we reach for the reflex everyone tries first: a distributed lock. Freeze the world around the read and the write with Redis, and on paper you're finally serializable. In practice it's a minefield — latency, deadlocks, and a TTL dilemma with no good answer.&lt;/p&gt;

&lt;p&gt;That's where things get ugly. Part 3.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>distributedsystems</category>
      <category>nosql</category>
      <category>ddd</category>
    </item>
    <item>
      <title>Invariant-Driven Architecture: 20M transactions on a €80/mo Cloud VM.</title>
      <dc:creator>Hugo Vantighem</dc:creator>
      <pubDate>Mon, 25 May 2026 06:27:00 +0000</pubDate>
      <link>https://dev.to/hugo_vantighem/invariant-driven-architecture-20m-transactions-on-a-eu80mo-cloud-vm-47b4</link>
      <guid>https://dev.to/hugo_vantighem/invariant-driven-architecture-20m-transactions-on-a-eu80mo-cloud-vm-47b4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📎 This is Part 2. &lt;strong&gt;&lt;a href="https://dev.to/hugo_vantighem/postgres-grade-serializable-at-20k-opss-on-a-laptop-dont-try-this-at-home-f27"&gt;Part 1 — Postgres-grade serializable at 20k ops/s on a laptop (don't try this at home)&lt;/a&gt;&lt;/strong&gt; presented 20,000+ durable, invariant-validated transactions per second — on a MacBook Air M3, 8 cores, fan barely audible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A laptop number is only half the story. The natural next test is whether the same architecture holds on a small, cheap public VM with &lt;strong&gt;network-attached storage&lt;/strong&gt; and &lt;strong&gt;strict &lt;code&gt;fsync(2)&lt;/code&gt; durability&lt;/strong&gt; — three constraints that each, on its own, tend to move the bottleneck by an order of magnitude on most stacks. So that's what I ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VM:&lt;/strong&gt; Scaleway POP2-2C-8G — 2 vCPUs AMD EPYC 7543 @ 2.8 GHz, 8 GiB RAM. Yes — &lt;em&gt;two&lt;/em&gt; vCPUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; SBS Block storage, 100 GB, 15,000 provisioned IOPS. Network-attached, &lt;em&gt;not&lt;/em&gt; local NVMe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OS:&lt;/strong&gt; Ubuntu 22.04, kernel 5.15. Linux strict &lt;code&gt;fsync(2)&lt;/code&gt; — every commit hits the SSD for real, no Apple-style controller-cache shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Software:&lt;/strong&gt; the exact same codebase that ran on the M3, with NATS + Mongo + Mongo Express as the side stack in Docker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price:&lt;/strong&gt; ~€80/month order-of-magnitude — ~€54 of compute (POP2-2C-8G at €0.0735/h) plus ~€20–25 of SBS volume with provisioned IOPS. Hourly that's €0.11.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Two names, one stack
&lt;/h3&gt;

&lt;p&gt;Two terms surface across this series. They sit at different layers, so it's worth pinning them now.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Invariant-Driven Architecture (IDA)&lt;/strong&gt; — the &lt;em&gt;design philosophy&lt;/em&gt;. The system, end to end (ingress, sequencer, system, storage), is engineered around a single obsession: validating and enforcing business invariants on every commit, with no compromise on throughput. It's a DDD-based philosophy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic State Platform&lt;/strong&gt; — the &lt;em&gt;concrete implementation&lt;/em&gt;. The software we're benchmarking right here — that just put down 33,091 sustained items per second on a €80/mo Scaleway VM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;IDA is &lt;strong&gt;how&lt;/strong&gt;. Atomic State is &lt;strong&gt;what&lt;/strong&gt;. The €80/mo VM is &lt;strong&gt;where&lt;/strong&gt;. The rest of this post is &lt;strong&gt;how much&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Visual Punch — 10 minutes non-stop, 20 million items 📈
&lt;/h2&gt;

&lt;p&gt;The first test is the one that closes the &lt;em&gt;"but does it sustain?"&lt;/em&gt; question forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BENCH_DURATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10m &lt;span class="nv"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 make cloud-bench-broker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result, over 600 consecutive seconds on the POP2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput sustained&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33,091 items/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total items committed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19,855,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAL bytes written&lt;/td&gt;
&lt;td&gt;7.6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 round-trip&lt;/td&gt;
&lt;td&gt;71 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integrity audit&lt;/td&gt;
&lt;td&gt;✅ INTEGRITY_OK (1,000 aggregates)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durability&lt;/td&gt;
&lt;td&gt;FSYNC-ON (Linux strict)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yes, that's &lt;em&gt;higher&lt;/em&gt; than the laptop — and on network-attached storage, not local NVMe. SBS is block storage over the data-center fabric; every &lt;code&gt;fsync&lt;/code&gt; round-trips to the SBS backend before it returns. Measured &lt;code&gt;fsync&lt;/code&gt; latency on this volume: &lt;strong&gt;2.0 ms&lt;/strong&gt; (vs 130 µs on the laptop's local NVMe — 15× slower per call). The 33,091 items/s holds &lt;em&gt;despite&lt;/em&gt; the network disk, not because of a fast one. Pebble's group commit amortises that 2 ms across the whole batch — roughly 2 µs of effective &lt;code&gt;fsync&lt;/code&gt; cost per item. That ratio is the architectural lever.&lt;/p&gt;

&lt;p&gt;More on why the cloud beats the laptop on this same scenario in §4. For now, the headline isn't the number. &lt;strong&gt;The headline is the steadiness.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I sampled the engine's &lt;code&gt;/metrics&lt;/code&gt; and the host's resource counters every 15 s through the entire run. Three things every senior engineer should care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pebble_health.l0_files&lt;/code&gt; stays in &lt;code&gt;[0, 6]&lt;/code&gt; the whole 10 minutes. Never higher. Compaction kicks in the moment L0 hits 6, completes before the next batch needs the slot.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compactions_in_progress&lt;/code&gt; oscillates &lt;code&gt;0 ↔ 1&lt;/code&gt; — Pebble is keeping up in real time, never queueing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;estimated_debt_bytes&lt;/code&gt; never crosses 100 MB — less than 1% of the WAL written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this scale — 34 MB/s sustained ingestion, ~20 GB of raw data committed — a mis-tuned LSM-tree triggers a &lt;strong&gt;Write Stall&lt;/strong&gt;: the engine has to pause writes while compaction catches up, and the curve falls off a cliff. We see none of that. The &lt;code&gt;puts&lt;/code&gt; counter climbs linearly from 0 to 19.85 M across the whole 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1xvt5eg9wsbb9jzuyssu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1xvt5eg9wsbb9jzuyssu.webp" alt=" " width="800" height="489"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;CPU%, L0 files and debt_MB never drift outside the bands shown above during the active 10-minute window.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[preflight] measured: lat_avg=2 051 µs iops=487
[preflight] Mac ref:  lat_avg=131 µs   iops=7 568 (M-series NVMe, same fio command)
[preflight] gate:     lat_avg &amp;lt;= 5 000 µs (override via SCW_FSYNC_MAX_LAT_US)
✅ PASS - instance is fsync-fast enough for a meaningful bench.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what "industrial-grade" looks like on a VM that costs €0.11/h.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Ceiling Demo — batch=1000 vs batch=2000
&lt;/h2&gt;

&lt;p&gt;Now the test that tells us where the wall is. Same VM, same 1-minute window, double the batch:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7vlegtgmb1xl82infyu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh7vlegtgmb1xl82infyu.png" alt=" " width="800" height="109"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;+2.8% throughput for +59% tail latency. Past 1,000 items per batch, more batching is just queueing. We've squeezed the disk dry.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let's do the math on what the bottleneck is. The cloud-up preflight measured 2.0 ms average &lt;code&gt;fsync&lt;/code&gt; latency on this SBS volume (&lt;code&gt;fio --rw=randwrite --bs=4k --direct=1 --sync=1&lt;/code&gt;, the same command everywhere). At 33k items/s and one &lt;code&gt;fsync&lt;/code&gt; per batch, that's 60 ms of &lt;code&gt;fsync&lt;/code&gt; wall-time per second — 6% of the budget. Pebble's group commit already amortises &lt;code&gt;fsync&lt;/code&gt; across the whole batch.&lt;/p&gt;

&lt;p&gt;The remaining 94% of wall-time is CPU:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP/JSON serialization on the producer&lt;/li&gt;
&lt;li&gt;1,000 invariant evaluations per chunk&lt;/li&gt;
&lt;li&gt;Pebble's batch building + commit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The network-attached disk is no longer the wall. &lt;strong&gt;Two 2.8 GHz AMD EPYC vCPUs at sustained 70% CPU are.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fact that doubling the batch buys 2.8% is the experimental proof: there's no more disk to amortise, only CPU to share.&lt;/p&gt;
&lt;h3&gt;
  
  
  The full numbers, side by side
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzorzta4ewfha4nqibv30.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzorzta4ewfha4nqibv30.png" alt=" " width="800" height="374"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Full side-by-side numbers for batch=1000 vs batch=2000.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read the bold rows together: we halved the number of &lt;code&gt;fsync&lt;/code&gt;s (33.3 → 17.1 per second) and throughput barely moved (+2.8%). If the disk were the wall, cutting &lt;code&gt;fsync&lt;/code&gt;s in half would have bought a lot more. It didn't — because CPU holds a flat ~70% plateau across both runs. That's the ceiling, in two numbers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv68udx3620q8qwrm0fk6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv68udx3620q8qwrm0fk6.png" alt=" " width="799" height="327"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Engine + system samples, every 5 s (abridged — start / mid / end). Same CPU band, same L0 transient peak (10), and acks (= fsyncs) running at exactly half the rate on bs=2000 for the same puts — one fsync per batch, batches twice as big, no throughput dividend.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The Proof by Absurdity — 64 Workers on 2 Cores
&lt;/h2&gt;

&lt;p&gt;This is the test every junior engineer expects to &lt;em&gt;help&lt;/em&gt; throughput, and every senior engineer expects to &lt;em&gt;kill&lt;/em&gt; it. The reflex: &lt;em&gt;"if the engine is slow, scale out the producer."&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make cloud-bench-perf-dense   &lt;span class="c"&gt;# 64 workers, batch=100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same VM. Same disk. Same system. The only change is the producer pattern.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0n4hrcb8d6btc6ekmap5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0n4hrcb8d6btc6ekmap5.png" alt=" " width="800" height="143"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that twice. &lt;strong&gt;CPU usage drops from 70% to 30% — and throughput collapses 5.5×.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That asymmetry is the signature of context-switching hell. The Linux scheduler spends its cycles swapping 64 producer goroutines + 64 HTTP request handlers + the system + 3 Docker containers across 2 physical cores. The real work-per-cycle drops; the cores &lt;em&gt;look&lt;/em&gt; idle because they spend their time saving and restoring register state. The engine's single-writer queue becomes the rendezvous point — workers pile up — p99 explodes to 1.7 seconds.&lt;/p&gt;

&lt;p&gt;This is the design principle of the engine stated as a measurement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The engine is &lt;strong&gt;single-writer by design&lt;/strong&gt; — one writer to Pebble, no lock contention.&lt;/li&gt;
&lt;li&gt;The ingress &lt;strong&gt;must batch upstream&lt;/strong&gt; of the engine to amortise &lt;code&gt;fsync&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;More producer threads = &lt;strong&gt;less&lt;/strong&gt; throughput on a CPU-constrained host. Mathematically, not ideologically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqoka1aidt0tkvyn9dza2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqoka1aidt0tkvyn9dza2.png" alt=" " width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The Mac Parallel — More Cores Buy More Headroom
&lt;/h2&gt;

&lt;p&gt;The same three scenarios, on the MacBook Air M3 from Part 1 (8 cores, 16 GB):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5gb32sjh8fgvyds15z15.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5gb32sjh8fgvyds15z15.png" alt=" " width="800" height="143"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three readings:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) On a single-worker pattern, the cloud Linux wins.&lt;/strong&gt; Mac &lt;code&gt;fsync(2)&lt;/code&gt; is ~15× faster than the cloud SBS per call (~130 µs vs ~2 ms), but at batch=1000 the per-batch &lt;code&gt;fsync&lt;/code&gt; is amortised over 1,000 items — and the rest of the pipeline (HTTP serialization, internal sequencing, scheduler) now dominates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) When the workload has CPU work to spare and concurrency is contained, the extra cores cash in.&lt;/strong&gt; Going from batch=1000 to batch=2000 adds compute per batch but releases parallelism inside the engine (more items concurrently invariant-checked by the system). The Mac has 6 extra cores to spend on it, so its throughput climbs +54% (23,755 → 36,549). The cloud, pinned at 2 vCPUs, gains only +2.8% on the identical change — it has no spare core to convert the extra parallelism into work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(c) The Mac does not flinch at 64 workers&lt;/strong&gt; — it has the cores to absorb them. This is the exact scenario where the 2-vCPU cloud VM collapsed to 5,992 (§3). The 8-core Mac runs the identical 64-worker, batch=100, payload=0 B workload at 43,392 items/s — 7.2× the cloud, and above its own single-worker broker run (23,755). Context-switching only becomes hell when threads vastly outnumber cores; with 8 cores the scheduler keeps up and the engine's single writer stays fed. The perf-dense collapse was never about the workload — it was about core count.&lt;/p&gt;

&lt;p&gt;The hierarchy of constraints is universal, regardless of OS, disk brand, or vendor SKU:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;batch size &amp;gt; producer concurrency &amp;gt; raw fsync speed&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Match those three to your hardware and your real ingress pattern, and the throughput follows. Get them wrong and a top-end laptop loses to a €80/month VM — or wins against one — depending on the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers, at a Glance
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F92rjqrv5yf0zkio0osjf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F92rjqrv5yf0zkio0osjf.png" alt=" " width="799" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;€80/month is the order of magnitude — ~€54 of compute, ~€20–25 of provisioned-IOPS SBS volume. Hourly: €0.11. The whole bench session that produced the cloud rows of this table cost ~€0.05 of cloud time — at this scale, validating an architecture decision on a representative VM is essentially free.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the runs were captured
&lt;/h2&gt;

&lt;p&gt;Each row above came from the same harness: a fresh VM with a clean Pebble, system + Mongo projection started as systemd units, NATS + Mongo + Mongo Express brought up in Docker, and the engine's &lt;code&gt;/metrics&lt;/code&gt; endpoint sampled every 5–15 seconds during the run. An &lt;code&gt;fio&lt;/code&gt; pre-flight (&lt;code&gt;--rw=randwrite --bs=4k --direct=1 --sync=1&lt;/code&gt;, 5 s) gates the run on a configurable &lt;code&gt;fsync&lt;/code&gt; latency threshold; on this VM it measured 2,051 µs average, well under the 5 ms gate. Every number in the tables above is either a direct read from &lt;code&gt;/metrics&lt;/code&gt;, a count from the bench JSON output, or a delta between consecutive samples — nothing synthetic, no extrapolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;20 million durable, invariant-validated transactions in 10 minutes, on a public-cloud VM that costs less per month than a SaaS subscription. Every run ends with &lt;code&gt;INTEGRITY_OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What Atomic State Platform does on the M3 in Part 1, it does unchanged on a €80/mo Linux VM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The single-writer engine means the &lt;strong&gt;disk stops being the wall&lt;/strong&gt; as soon as you batch upstream.&lt;/li&gt;
&lt;li&gt;Smaller, slower-&lt;code&gt;fsync&lt;/code&gt; CPUs reach the &lt;strong&gt;same throughput envelope&lt;/strong&gt; on cheap cloud as a powerful laptop — provided the producer pattern is cooperative.&lt;/li&gt;
&lt;li&gt;Bigger machines buy &lt;strong&gt;headroom for concurrency&lt;/strong&gt;, not raw throughput.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need a 64-core server. You don't need an NVMe array. You don't need a datacenter rack. &lt;strong&gt;You need the right pattern, applied to the right SKU.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 3 unpacks the deeper claim: how Invariant-Driven Architecture lets Atomic State Platform sidestep the classical database stack outright — no Postgres, no Redis, no event-sourcing scaffolding. Just a system with a single &lt;code&gt;fsync&lt;/code&gt; per batch, doing what nothing else does on a €80/mo box.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>go</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Read Modify Write Is Where NoSQL Concurrency Bugs Begin.</title>
      <dc:creator>Hugo Vantighem</dc:creator>
      <pubDate>Sun, 24 May 2026 12:11:46 +0000</pubDate>
      <link>https://dev.to/hugo_vantighem/read-modify-write-is-where-nosql-concurrency-bugs-begin-1ala</link>
      <guid>https://dev.to/hugo_vantighem/read-modify-write-is-where-nosql-concurrency-bugs-begin-1ala</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of 3 — the single-document case.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's a class of bug that every backend engineer ships at least once, usually&lt;br&gt;
without noticing for months. It hides inside the most innocent-looking operation:&lt;br&gt;
read a document, decide something, write it back.&lt;/p&gt;

&lt;p&gt;Take a concrete invariant: &lt;em&gt;a team can hold at most 10 seats.&lt;/em&gt; To add a seat you&lt;br&gt;
read the team document, count the seats, check &lt;code&gt;count &amp;lt; 10&lt;/code&gt;, and write. A textbook&lt;br&gt;
&lt;strong&gt;Read → Modify → Write&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now run it twice at the same instant. Request A reads &lt;code&gt;count = 9&lt;/code&gt;, decides "9 &amp;lt; 10,&lt;br&gt;
fine", and writes 10. Request B, a millisecond apart, also read &lt;code&gt;count = 9&lt;/code&gt;,&lt;br&gt;
decided "fine", and writes 10. You now have a team that thinks it has 10 seats but&lt;br&gt;
actually granted 11. Neither request did anything wrong on its own. One write&lt;br&gt;
silently erased the premise of the other. This is a &lt;strong&gt;lost update&lt;/strong&gt;, and it's the&lt;br&gt;
core anomaly of the single-document case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T0   A reads count = 9
T1   B reads count = 9
T2   A writes count = 10   ("9 &amp;lt; 10, fine")
T3   B writes count = 10   ("9 &amp;lt; 10, fine")

Reality:        11 seats granted
Database state: 10
Invariant:      violated, silently
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what teams actually reach for, and exactly what each option leaves on the&lt;br&gt;
table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fat aggregate (atomic operators)
&lt;/h2&gt;

&lt;p&gt;If you can express the whole mutation as a single atomic operator — &lt;code&gt;$inc&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;$push&lt;/code&gt; with &lt;code&gt;$slice&lt;/code&gt;, or a conditional &lt;code&gt;findAndModify&lt;/code&gt; — MongoDB applies it&lt;br&gt;
atomically on the document. There's no read-then-write window, so no lost update.&lt;br&gt;
For invariants that fit a single atomic expression, this is genuinely the right&lt;br&gt;
tool, and you should reach for it first.&lt;/p&gt;

&lt;p&gt;The catch: not every invariant fits. The moment your check needs branching ("if&lt;br&gt;
the plan is free &lt;em&gt;and&lt;/em&gt; count ≥ 5, reject") you're back to reading, deciding in&lt;br&gt;
application code, and writing — and the window reopens. Embedding related data is&lt;br&gt;
a perfectly good modeling choice; the trap is different. It's the temptation to keep&lt;br&gt;
stretching one document's &lt;em&gt;consistency boundary&lt;/em&gt; — folding in unrelated rules just&lt;br&gt;
to keep the write atomic — which is exactly how you end up with 16 MB documents and a saturated network.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anomaly status: ✅ lost update handled — for the subset of rules expressible as&lt;br&gt;
one atomic op.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pessimistic lock (Redis)
&lt;/h2&gt;

&lt;p&gt;Grab a distributed lock before the read, release after the write. It works — but&lt;br&gt;
for a single document it's a sledgehammer. You've added a network round-trip, a&lt;br&gt;
brand-new failure mode (the lock service), and a whole class of distributed&lt;br&gt;
coordination failures — lease expiry, lock drift, fencing, split-brain — all to&lt;br&gt;
guard one document the database could have guarded itself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anomaly status: ✅ everything — at the cost of latency and distributed coordination&lt;br&gt;
failures. (Part 3 is dedicated to why that bill is steep.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimistic locking (a version field)
&lt;/h2&gt;

&lt;p&gt;Carry a &lt;code&gt;version&lt;/code&gt; on the document. Read it, run your logic, then write with a&lt;br&gt;
guard: &lt;code&gt;findAndModify({_id, version: v}, {$set: {...}, $inc: {version: 1}})&lt;/code&gt;. If&lt;br&gt;
anyone wrote in between, &lt;code&gt;version&lt;/code&gt; moved, your guard matches nothing, and you&lt;br&gt;
retry. This is the clean default for single-document RMW that doesn't fit an&lt;br&gt;
atomic operator — it kills lost update with no external system.&lt;/p&gt;

&lt;p&gt;The catch: under contention it's a retry machine. The more concurrent writers, the&lt;br&gt;
more losers re-run their logic, burning CPU and tail latency.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anomaly status: ✅ lost update — at the cost of app-side retries.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pray
&lt;/h2&gt;

&lt;p&gt;Bet that two requests never touch the same document in the same millisecond. They&lt;br&gt;
will. &lt;em&gt;Anomaly status: ❌ lost update, in production, at 3 a.m.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;For a single document, you're actually well served: atomic operators or optimistic&lt;br&gt;
locking close the gap cleanly, without external machinery. The single-document&lt;br&gt;
case is the &lt;em&gt;easy&lt;/em&gt; one.&lt;/p&gt;

&lt;p&gt;The real pain begins the instant your invariant spans &lt;strong&gt;two&lt;/strong&gt; documents — a&lt;br&gt;
workspace budget gating a user debit, for example. There, optimistic locking stops&lt;br&gt;
being &lt;em&gt;sufficient&lt;/em&gt;: it still guards each document on its own, but it can no longer&lt;br&gt;
guarantee an invariant that lives &lt;em&gt;between&lt;/em&gt; them. And a nastier anomaly walks in —&lt;br&gt;
the database stays perfectly "consistent" while your business invariant quietly&lt;br&gt;
dies.&lt;/p&gt;

&lt;p&gt;Welcome to &lt;strong&gt;write skew&lt;/strong&gt;. That's part 2.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>distributedsystems</category>
      <category>mongodb</category>
      <category>nosql</category>
    </item>
    <item>
      <title>Postgres-grade Serializable at 20k+ ops/s — on a laptop. Don’t try this at home.</title>
      <dc:creator>Hugo Vantighem</dc:creator>
      <pubDate>Sat, 23 May 2026 17:14:52 +0000</pubDate>
      <link>https://dev.to/hugo_vantighem/postgres-grade-serializable-at-20k-opss-on-a-laptop-dont-try-this-at-home-f27</link>
      <guid>https://dev.to/hugo_vantighem/postgres-grade-serializable-at-20k-opss-on-a-laptop-dont-try-this-at-home-f27</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;They didn't know it was impossible, so they did it.&lt;/em&gt; — Mark Twain&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the software industry, we've been raised with a dogma: you must choose between &lt;strong&gt;Massive Performance&lt;/strong&gt; (NoSQL, eventual consistency) and &lt;strong&gt;Domain Rigor&lt;/strong&gt; (SQL, strong consistency, serializable).&lt;/p&gt;

&lt;p&gt;We are told that locks, latencies, and ACID properties are the natural enemies of speed. That if you want to scale, you have to let go of your business invariants.&lt;/p&gt;

&lt;p&gt;I decided to test another hypothesis. And I broke the myth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result: 20,000+ Validated Transactions per Second
&lt;/h2&gt;

&lt;p&gt;This isn't a "fire and forget" ingestion log.&lt;/p&gt;

&lt;p&gt;This isn't a volatile cache experiment.&lt;/p&gt;

&lt;p&gt;What you see here is &lt;strong&gt;Business Transaction Durability&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Invariants validated&lt;/strong&gt; — every business rule is checked before commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State persisted&lt;/strong&gt; — every change is durably written to disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strong Consistency&lt;/strong&gt; — Serializable-level isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 20,000+ ops/s, we are not just talking about speed. We are talking about the ability to maintain &lt;strong&gt;absolute domain integrity under massive load&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the kicker: this is running on a &lt;strong&gt;MacBook Air M3&lt;/strong&gt; — 8 cores, 16 GB of RAM, the same machine I write the code on. No 64-core server. No NVMe array. No datacenter rack. One laptop, fan barely audible, doing the work of a small cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why General-Purpose Databases Hit a Ceiling
&lt;/h2&gt;

&lt;p&gt;Most databases are built for general cases. They treat every row the same way because they don't know your business.&lt;/p&gt;

&lt;p&gt;This &lt;strong&gt;"Domain Ignorance"&lt;/strong&gt; leads to generic row locks, MVCC bookkeeping, cross-table coordination, and massive overhead — costs you pay on &lt;em&gt;every single transaction&lt;/em&gt;, whether your domain needs them or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not Magic — Discipline
&lt;/h2&gt;

&lt;p&gt;For the skeptics: this isn't sorcery. It's discipline applied to the right layer — designing the system so the hardware does exactly what it's good at, and nothing else.&lt;/p&gt;

&lt;p&gt;I'm not reinventing the storage wheel. The foundation is &lt;strong&gt;Pebble&lt;/strong&gt;, the same proven LSM-tree engine that powers CockroachDB. But the engine is just the floor. The real lever is the &lt;strong&gt;orchestration of the domain logic on top of it&lt;/strong&gt; — and that's what Part 2 puts a name on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on the Benchmark Scope
&lt;/h2&gt;

&lt;p&gt;I know what you're thinking. &lt;em&gt;"20k+ ops/s? That must be an internal memory trick."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It isn't. To ensure these numbers reflect real-world usage, the benchmark covers the &lt;strong&gt;entire lifecycle&lt;/strong&gt; of a business transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client-side serialization&lt;/strong&gt; — the payload starts from the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local communication&lt;/strong&gt; — end-to-end roundtrip.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server-side deserialization &amp;amp; parsing.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business Invariants validation.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk persistence&lt;/strong&gt; with full durability guarantees — &lt;code&gt;fsync&lt;/code&gt; on every commit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The workload: &lt;code&gt;batch=1000&lt;/code&gt;, &lt;code&gt;payload=1KB&lt;/code&gt;, single-node, single laptop. Here's the run, with the system-level disk stats captured live during the bench:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[23755.87 items/s] | items=1424000 | batch=1000 | payload=1KB | durability=FSYNC-ON
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv5i5y85as7v7b7dk0kc9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv5i5y85as7v7b7dk0kc9.png" alt=" " width="800" height="516"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Live capture during the bench (batch=1000, 1KB, fsync ON). Disk on fire, CPU bored.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things jump out of that stats panel — and together they're the whole point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The disk is screaming.&lt;/strong&gt; Sustained 100–200 MB/s with the ⚡ markers firing almost every second. This is real &lt;code&gt;fsync&lt;/code&gt;'d traffic hitting the SSD, not a memory cache pretending to be durable. If you pulled the power cord mid-run, every committed transaction would still be there on reboot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CPU is bored&lt;/strong&gt; (~18% on an 8-core M3). The compute is idle while the disk pegs out — that asymmetry is the whole story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And this isn't the ceiling. With bigger batches the same laptop pushes further; even at &lt;code&gt;batch=1&lt;/code&gt;, it doesn't fall off a cliff. The full envelope is Part 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;This is just Part 1. In a few days, &lt;strong&gt;Part 2&lt;/strong&gt; finishes the picture and lands the real punchline: business rules aren't a tax on performance — they're the contract that lets the machine fly. And the whole thing runs on hardware your team could expense, not a cloud bill that needs board approval.&lt;/p&gt;

&lt;p&gt;Stay tuned. The era of the "Impossible Trade-off" is over.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
