The Bug That Doesn't Look Like a Bug
The simulation says profit. The mempool feed (mental shorthand here — Solana has no mempool, more on that later) says the route is open. The transaction lands. The bundle confirms. The arithmetic says I should be a few cents richer.
The wallet says otherwise.
Nothing in the logs is red. Nothing throws. Nothing in the test suite catches it. Profit numbers are positive in simulation and negative on chain. I spend hours staring at AMM math, fee tiers, slippage assumptions, and decimals before noticing the thing that's actually wrong: somewhere upstream, a value got written before it was read, and every downstream calculation has been working from a future state pretending to be the past.
This is the family of bugs I want to talk about. They aren't exotic. They aren't language-specific. They are the same bug whether you write a React form, a Rust async task, a C# service, or an arbitrage bot scanning Solana DEX pools. They all reduce to one sentence: if you change a value before reading the original, you get the changed value, not the original.
That sentence sounds too obvious to need an article. The reason an article exists is that mutation and reads, in real software, are almost never on adjacent lines.
What Temporal Coupling Actually Means
The canonical name for this family is temporal coupling. Vladimir Khorikov's definition, written nearly a decade ago and still the cleanest formulation I've seen, is: "Temporal coupling is coupling that occurs when there are two or more members of a class that need to be invoked in a particular order" (Enterprise Craftsmanship).
The word coupling is doing a lot of work. The functions aren't coupled by their parameters — their signatures might look completely independent. They are coupled by the fact that one writes to some shared state and the other reads from it. Swap the call order and the second function silently observes the wrong world.
Khorikov's C# example shows the shape perfectly. A Process method calls CreateAddress, then CreateCustomer, then SaveCustomer. The first writes a private _address field. The second reads _address and writes _customer. The third reads _customer. Reorder them and everything still compiles. Everything still runs. CreateCustomer simply reads a null _address field and produces a customer whose mailing address is gone.
His diagnosis is the line I quote to myself when I'm staring at one of these bugs at two in the morning: "All three methods write to and read from the shared mutable state. And that is exactly what fosters the temporal coupling. In fact, without a shared state, temporal coupling becomes impossible."
That last clause is the entire prevention strategy in seven words.
Why It's So Hard to Spot
A recent Java Code Geeks piece on the same pattern notes that the bug is "silent, invisible in logs, and only detectable by auditing final state against expected state" (javacodegeeks.com). Three properties stack up to make these bugs uniquely painful.
It doesn't throw. A null check, a Result::Err, a panic — these are gifts. They tell you exactly where to look. Mutation-order bugs hand you a perfectly valid, perfectly wrong number and let you keep going.
It doesn't always reproduce. When the order is enforced by a single straight-line function, the bug is consistent. When the order depends on async scheduling, event firing, or which thread happens to win a race, the same code path produces different answers on different runs. The Java Code Geeks article describes the same idea: "When method is inadvertently called more than once, with the same parameters, it may yield different results, depending on the hidden mutation, becoming unpredictable."
It points away from itself. Because the symptom appears downstream, you start debugging where the wrong number shows up. You instrument the consumer. You print the input. The input looks fine. You print the input's input. Still fine. Each step makes the data look more correct, because the mutation happened somewhere upstream of where you're looking.
This is why I now treat any debugging session that drags past two failed hypotheses as evidence that I should stop guessing and start tracing the order of mutations on every shared field touching the suspected code path. It's almost always there, and it's almost never where I expect.
The React Family: Mutate-Then-Copy Versus Copy-Then-Mutate
The most teachable real-world version of this bug lives in React state code, because the right answer and the wrong answer differ by a single line move.
Kevin Kouomeu, writing about React mutation bugs, calls them "silent assassins in React development" that "can create unpredictable behavior that's incredibly difficult to debug" (kevinkouomeu.com). The shape he highlights is one I see in juniors' pull requests roughly every week:
// WRONG — mutation before React sees the change
colors[index] = event.target.value;
setColors([...colors]);
This looks innocent. You change the value, then spread into a new array, then call setColors. The compiler is happy. The page even seems to update most of the time. The bug is that colors is React's own state object. When you assign into it directly, you've already changed the thing React was supposed to compare against. By the time React asks "did the array reference change?" the answer is technically yes (you passed a new spread), but React's optimization machinery compares slot-by-slot in places you don't expect, and the result is, per Kouomeu, "strange, glitchy behavior" — components that update half their fields, list items that ghost, focus jumps in form inputs.
The fix is to reverse the two operations:
// CORRECT — copy first, then mutate the copy, then set state
const nextColors = [...colors];
nextColors[index] = event.target.value;
setColors(nextColors);
Same three lines. Different semantics. The shared state stays pristine until React itself replaces it.
Kouomeu's three-step rule — create a new structure, modify the new structure, set it into state — is essentially the local version of Khorikov's global rule. Don't mutate the shared thing. Mutate a private copy and hand the copy in.
setState Is a Request, Not a Command
The second React family is even more deceptive, because it looks like you did everything right. Duncan Leung's classic post on setState pitfalls gives the canonical demonstration (duncanleung.com):
this.setState({ orders: 1 });
console.log(this.state.orders); // 0
Leung's framing is the one I quote whenever a teammate hits this: "setState() is a request to update state, rather than an immediate command." The mutation has been enqueued. The read fires before the queue is drained. You see the old value.
The worst version compounds the error:
this.setState({ orders: this.state.orders + 1 });
this.setState({ orders: this.state.orders + 1 });
this.setState({ orders: this.state.orders + 1 });
// Result: orders = 1, not 3.
All three reads of this.state.orders happen before any of the writes commit. Three calls each compute 0 + 1 = 1. Three writes each clobber orders with 1. You expected an accumulator, you got an idempotent assignment. The fix is to pass the updater function so each call receives the previous queued result:
this.setState(prev => ({ orders: prev.orders + 1 }));
The React Bits compendium frames the same trap in log form: print this.state before and after setState, both prints show the same value, because the mutation was queued, not applied (vasanthk.gitbooks.io). The lesson generalizes far beyond React: any time mutation is deferred, reads between the mutation request and the actual write see the old value. Promises, message queues, batched database writes, event loops, transactional caches — same pattern, same trap.
Aliasing: Two Names, One Thing
The sneakiest variant has nothing to do with async or queues. It just relies on the fact that two variables can point at the same object.
Artem Sapegin's piece on avoiding mutation has my favorite minimal example (sapegin.me):
const dogs = ['dachshund', 'sheltie'];
const sameDogs = dogs;
dogs.push('schnoodle');
console.log(sameDogs); // ['dachshund', 'sheltie', 'schnoodle']
sameDogs was never explicitly modified. Anyone reading the code linearly would expect it to still contain two breeds. But sameDogs and dogs are the same array, and push mutated that array in place. The read happens later. The mutation already occurred. Surprise.
The canonical workplace version is hidden inside a helper function:
function printLastElement(array) {
const lastElement = array.pop();
console.log(lastElement);
}
Sapegin's note is dead-on: "the pop() array method mutates the array being passed into the function — the original array is now missing an element, which is likely not what we expect when calling a function named printLastElement()." You called something named print. You did not consent to a mutation. But pop returns the last element by removing it, and the function quietly cost you one element of your caller's array.
The sort trap is the same idea wearing a different mask. arr.sort(...) looks like it returns a new sorted array. It does return a sorted array — the very array you passed in, sorted in place. If the original is used anywhere else in the original order, you have a mutation-before-read bug separated by the entire width of the codebase. Sapegin's prescription is the ES2023 non-mutating variant, arr.toSorted(...), or the explicit [...arr].sort(...) spread-and-sort. Either way: copy first.
The deeper insight Sapegin offers is one I keep on a sticky note: "Code with mutations likely hides other issues — mutation is a good sign to look closer." Not because every mutation is wrong, but because mutations are an attractor for the next bug.
The Hardware Origin: RAW, WAR, WAW
If the React, C#, and JavaScript examples feel like local skirmishes, the formal classification reveals that this is one of the oldest known bug families in computing. CPU designers and compiler writers categorize three kinds of data dependencies, named after Arthur J. Bernstein and documented in the Wikipedia entry on data dependency:
- RAW (Read After Write) — true dependency. "An instruction refers to a result that has not yet been calculated or retrieved." Instruction 2 reads register R2 before instruction 1 has finished writing R2. Result: stale value.
- WAR (Write After Read) — anti-dependency. "i2 tries to write a destination before it is read by i1." The mutation lands before the original is consumed. This is the mutation-before-read bug in its purest form.
- WAW (Write After Write) — output dependency. Two writes to the same destination must complete in the right order or the final state is wrong.
These aren't academic curiosities. They are the reason out-of-order execution requires a reorder buffer, why compiler loop transformations have to track dependencies, why register renaming exists. The same three patterns reappear at every layer of the stack: in CPU pipelines, in compiler intermediate representations, in concurrent code with shared variables, in async UI frameworks, in your MEV bot's pool-state cache.
The nice thing about knowing the formal names is that you can pattern-match faster. When something goes wrong and a number is stale, the question is no longer "what kind of weird bug is this?" but "is this RAW, WAR, or WAW?" Usually one of the three labels fits, and the fix follows from the label.
How This Bites in a Solana Bot Specifically
On Solana there is no mempool (the network has no public pending-transaction pool the way Ethereum does — transactions go through validators directly), so the bug isn't about mempool races. But the bot still has plenty of shared mutable state, and plenty of opportunities to read it after it's been quietly invalidated.
A few categories I've stepped on, generalized so nothing here helps a competitor:
Pool snapshot reads after refresh. A scanning loop pulls a recent snapshot of a pool. A separate task hears a block update and replaces the snapshot in-place. The scanner finishes its computation against the new snapshot but reports profit relative to the price it thought it was reading. The number is plausible, the trade is unprofitable, nothing in the logs tells you which snapshot the scanner actually saw.
Reserve fields mutated during fee math. The fee model takes inputs from pool reserves. If reserves are updated by a callback while the fee math is running, the math reads r0 and r1 from different versions of the pool. Each value is internally valid; together they describe a state that never existed.
Quote caches refreshed mid-route. Multi-hop arbitrage computes a chain of hops. If the cache for hop two refreshes while hop one is being scored, the scorer ends up combining a pre-refresh price for hop one with a post-refresh price for hop two. The reported route looks great. The actual route loses money to slippage that never appeared in the math.
In each case the cure is the same: take a defensive copy of every input at the start of the computation, run the computation against the copy, and never read the live shared object again until you've decided what to do. It is the same pattern as const nextColors = [...colors]. The shared state is for other people; the calculation gets its own snapshot.
A Working Checklist
After enough self-inflicted wounds, I now run any non-trivial change through a short checklist whose only job is to surface mutation-before-read risk before the diff lands.
- Identify every shared mutable field touched by this change. Class fields, module-level variables, singletons, caches, ref cells, atomic counters. List them.
- For each one, list the writers and the readers. Not where they're declared — where they're actually called. A field with one writer and ten readers behaves nothing like a field with ten writers and one reader.
- For each reader, ask: between the moment this read fires and the moment the value was last validated, what could have written to this field? If the answer is "another thread," "another callback," or "another method earlier in the call chain," you have potential temporal coupling.
- Can the reader take a copy at entry and work from the copy? If yes, do it. The cost of a shallow copy is almost always lower than the cost of one wrong-direction arbitrage trade.
- Are there "call X before Y" comments anywhere near this code? Per the Java Code Geeks article, those comments are diagnostic: they indicate the writer of the original code already noticed temporal coupling and chose to document instead of refactor. If you see such a comment, you're standing on top of the bug.
This isn't sophisticated. It's mostly a discipline of asking the same five questions every time. The reason it works is that mutation-before-read bugs hide behind exactly one assumption — the value will still be what I expect when I read it — and the checklist forces you to make that assumption explicit, where it can be falsified.
Why Immutability Keeps Coming Up
Every good source on this topic eventually arrives at the same conclusion, which is that the most reliable prevention is to make the data unchangeable in the first place.
If the pool snapshot is immutable, no callback can replace it from under you. If the reserve struct is frozen, the fee math can't accidentally rewrite it. If the array is wrapped in a structure that returns new arrays on mutation, the original is always safe. Sapegin's list of four problems with mutation — that bugs become hard to debug, that code is harder to read, that function parameters surprise their callers, that it's easy to forget which methods mutate — collapses to one underlying remedy: don't let values change in place.
Languages give you different tools for this. Rust's borrow checker won't let you hold an immutable reference while another path holds a mutable one. ES2023 gives JavaScript toSorted, toReversed, toSpliced, with — non-mutating versions of operations that were historically mutating. C# has readonly and record types. Even C has const, even if it's frequently ignored.
The common thread is this: the cheapest fix for a mutation-before-read bug is a design in which the mutation cannot happen at all. Everything else — careful ordering, defensive copies, locking — is paying compounding interest on a debt you took on by allowing the mutation in the first place.
Khorikov's last word on the topic, almost a decade old now, has aged well: "With honest method signatures, it's extremely easy to reason about the code as we don't need to keep in mind hidden relationships." Honest signatures means the inputs are parameters, the outputs are return values, and shared mutable fields are the exception rather than the medium of communication. That alone removes most of the surface area where the bug can live.
What I Take Away From All This
Mutation-before-read bugs are not a category I'll ever fully outgrow. They appear at every layer of every system I touch, from a React form to a CPU pipeline to an on-chain arbitrage simulator. The skill is not avoiding them entirely — it's recognizing the shape quickly when the next one shows up.
The shape, every time, has the same fingerprint. There is a value. Something writes to it. Something else reads it. Between the write and the read, an assumption about timing was made that nobody told the compiler about. When the assumption holds, the code works. When it doesn't, the code works in a different sense — it returns plausible numbers from an impossible world.
The practical answer is the boring one. Take copies. Make signatures honest. Prefer immutability. Treat any "call X before Y" comment as a smell rather than a documentation. When debugging stalls, stop guessing and start tracing the order of writes to every shared field touching the failure. The bug is almost always there, and once you see it, the question stops being "why is this number wrong" and starts being "why did I ever expect it to be right."
Key Takeaways
- Mutation-before-read is one bug family with many faces. Temporal coupling, stale state reads, async setState pitfalls, aliasing surprises, and CPU data hazards (RAW/WAR/WAW) are all the same shape: a value gets changed before the original is read.
- Shared mutable state is the precondition. Without shared state, the bug cannot exist. Every prevention strategy is ultimately a way of reducing how much state is both shared and mutable.
- Copy before you mutate. Whether it's
[...colors]in React,[...arr].sort()in JavaScript, or a defensive snapshot of a pool reserve in a bot, the cheapest fix is almost always taking a local copy of the input before doing anything else. - Async deferred mutations always lie about timing.
setState, message queues, promise chains, transactional writes — any mutation that's queued rather than applied means reads in the interim get the old value. Plan for it explicitly. - "Call X before Y" comments are diagnostic. They mean the original author saw the coupling and chose to document instead of refactor. Treat them as a sign that you are standing on top of a latent bug.
Disclaimer
This article is for informational and educational purposes only and does not constitute financial, investment, legal, or professional advice. Content is produced independently and supported by advertising revenue. While we strive for accuracy, this article may contain unintentional errors or outdated information. Readers should independently verify all facts and data before making decisions. Company names and trademarks are referenced for analysis purposes under fair use principles. Always consult qualified professionals before making financial or legal decisions.