Fiber's Double Buffering Teaches Agent State Three Moves: workInProgress, Commit, Rollback
1. The Pit
Last Wednesday afternoon I gave my main agent a fairly concrete job: pull the three utility functions related to “TOC anchors” out from under the blog’s src/utils/, merge them into a new module src/utils/toc/, and update every call site in TableOfContents.astro along the way.
It started off brisk and competent. Twenty minutes later I came back to this scene: the three original files already deleted, the new directory half built (toc/index.ts existed, but toc/anchor.ts had function signatures and no bodies), and TableOfContents.astro already rewritten to import { getAnchor } from '../utils/toc' — an import that now resolves to nothing. Run tsc once: seven red squiggles. Worse, it had already git add . && git commit -m "refactor: extract toc utils"-ed this half-finished state.
Ask it to “undo” now, and what do you get? “I can run git reset --hard HEAD~1 for you.” — technically not wrong, but notice that it has already touched external state three times in this wrong direction: deleted files, rewritten imports, written a commit. Each one an irreversible atomic operation. reset can restore git history; it cannot restore the tmux windows I had open during those twenty minutes, the editor buffers, the dev server mid hot-reload — all of them still hanging on that broken intermediate state.
This pit isn’t agent-specific. Pre-Fiber React lived in the same pit: setState triggered reconciliation synchronously, one recursive descent mutating the real DOM as it went — no graceful way to interrupt halfway when new data arrived, no way to roll the whole thing back when it went wrong. Users actually saw half-finished pages like “table header already showing the new data, table body still showing the old.” The React team spent years walking from the stack reconciler to Fiber to solve exactly this one thing.
Agents are now stepping into this pit all over again. Tool calls mutate the disk directly; one polluted prompt cache costs you a whole round; a spawned subagent edits the very same working directory — for an agent with no draft/canonical boundary, any “halfway through a change” is a disaster. The root cause of that refactor accident wasn’t a dumb model; it’s that the architecture never drew a line between “I’m trying this out” and “I mean it this time.”
2. The Bridge
The React team’s solution in Fiber is extremely clean. One sentence: maintain two trees at once — one the user looks at, one you paint in the background — and when the painting is done, swap a pointer atomically.
- The
currenttree: the Fiber nodes behind the version currently on screen — the only thing the user can see - The
workInProgresstree (wip for short): the Fiber tree of the next version React is “trying to become” in the background. The user can’t see it; it can be interrupted at any moment by a higher-priority update, thrown away and rebuilt from scratch, or have an error thrown inside it without touching a hair on current
The key is that both trees coexist. The old stack reconciler had one tree — mutations landed wherever they landed, and a throw halfway through left the page rotting in an intermediate state. With Fiber split into two, wip is the dry-run area: if the dry run fails, all you do is mark the memory behind the wip pointer as reclaimable. current doesn’t move an inch.
The pseudocode fits in a few lines:
// The core of React Fiber's double buffering (pseudocode, not the real source)
let current = initialFiberTree; // what's on screen right now
let workInProgress = null; // what's being painted in the background
function scheduleUpdate(update) {
workInProgress = createWipFrom(current); // derive a mutable copy from current
try {
beginWork(workInProgress); // recursive render, may be interrupted many times
completeWork(workInProgress); // recursive wrap-up, builds the effect list
commitRoot(workInProgress); // atomic commit — it's just the one line below
current = workInProgress; // pointer swap; only now does the user "see" the new version
} catch (e) {
workInProgress = null; // dry run failed: discard wip, current untouched
scheduleRetry(update); // the real implementation goes through error boundaries / high-priority interrupt-and-restart; simplified here
}
}
Three motions in that snippet are worth memorizing: derive the wip, swap the pointer (the current = workInProgress line), discard the wip. Those are the three moves in this post’s title — workInProgress, commit, rollback. The model an agent needs is structurally isomorphic to this.
blog205 already covered “double buffering” once, as the first of its three principles; that post landed on the separation of “proposed state vs applied state” (maker and verifier each holding their own context). This post digs one layer deeper: the separation itself isn’t the destination — the three motions after the separation, dry run, commit, rollback, are the actual bones agent state management should steal.
3. The Real
Move 1: workInProgress — all “still exploring” state goes into one physically isolated draft namespace
In memory, Fiber’s wip tree and current are two independent sets of objects, referencing each other only through the alternate pointer. That “physical isolation” is the bottommost guarantee that makes double buffering work at all — no bad write on wip can ever leak onto current.
The first thing an agent should copy from this: give “I’m trying this out” a physically isolated namespace. Not “I’ll keep it in my head” — an actual separate directory on disk. For a refactor like the one above, the right form is:
- The main agent doesn’t touch the working tree directly; it first derives an isolated copy with
git worktree add ../wip-refactor-toc - Every tool call runs inside that copy: reading files, editing files, running tsc, running tests
- The copy crashed, or the direction turned out wrong?
git worktree remove --force ../wip-refactor-tocis a clean exit — the main working tree hasn’t lost a single hair
Claude Code’s official Agent tool has an isolation: "worktree" parameter that does exactly this — auto-cleans after the run, and if there are changes, hands you back the path + branch. That parameter is not an “optional efficiency optimization”; it’s the architectural choice that should be on by default for any multi-step refactor in the agent era.
Drawing this line comes with one counterintuitive discipline: during the draft, no externally visible side effects at all. No API calls out, no writes to shared databases, no notifications to users, no webhooks fired. Because rollback rests on the premise that “discarding the wip costs nothing” — the moment the draft does even one externally visible thing, rollback stops being a clean discard and becomes saga compensation (you now have to write inverse operations). Fiber’s wip tree writing only to memory and never to the DOM is the extreme version of this same discipline.
Move 2: commit — only past the gate check do you get the atomic switch, and the switch is “change one pointer”
Fiber’s commit phase has a property that’s easy to overlook: it is synchronous, uninterruptible, atomic. The render phase before it can be sliced into 5ms chunks that yield to user events, but once commit starts it runs to the end — the current = workInProgress line must either fully take effect or not take effect at all. The intermediate state is forbidden by the architecture.
The reason is plain: interrupt a commit midway and you get “half the DOM new, half old” — the state frontend fears most. React would rather let commit block the main thread for a few milliseconds than let a user see that dirty state.
For agents, the key to copying this is to put a gate in front of commit, and make the post-gate switch atomic. What goes in the gate? The mainline’s blog210, covering memory, dropped the line “record conclusions, not the process” — at the gate, that discipline has to grow into a concrete set of checks. Take my blog publishing pipeline: for one post going from draft to published, the gate looks like this:
- A subagent reviews across 6 dimensions (structure, logic, terminology consistency, fact-checking, tone, quotable-line density) and produces a report
- I hand-grade it through an A/B/C scale — A passes straight through, B goes back to the subagent for one revision round against the report, C gets bounced for a rewrite
- Once the gate passes, flip three lines of frontmatter:
# before commit
draft: true
reviewed: false
approved: false
# after commit
draft: false
reviewed: true
approved: true
Note the atomicity here — the three flags flip together or not at all. No “reviewed: true but approved: false” intermediate state is allowed, because downstream the astro build looks only at draft: false: if draft flips first and the others lag behind, a half-reviewed post gets built into the static site. This is the agent edition of Fiber’s “synchronous, uninterruptible” commit-phase discipline: from the moment the subagent report comes out to the moment all three frontmatter lines are flipped, no other tool call is allowed to cut in.
Dig one layer further: why did Fiber pick “pointer swap” over “field-by-field diff merge”? Because a pointer swap is O(1) and atomic by nature, while a diff merge is O(n) and can fail halfway through. Agent commits are the same — if your commit logic is “merge the draft’s 20 memory entries into the main memory file one by one,” you’re decomposing one atomic problem into 20 non-atomic operations. The right form is to change one pointer (say, rename a directory, or switch the active version field in a config) and let downstream readers naturally see the new version.
Move 3: rollback — discarding a draft is one rm -rf or git branch -D, provided Move 1 was done right
The third move is really a corollary of the first — if the draft namespace is physically isolated and produced no external side effects along the way, rollback is naturally a one-liner.
- A worktree draft?
git worktree remove --force ../wip-refactor-toc - Intermediate artifacts from one subagent exploration?
rm -rf ~/.claude/projects/<hash>/wip-* - A blog post that failed the gate? The three frontmatter flags stay at
draft: true / reviewed: false / approved: false, and you just rerun next time
Conversely, when rollback gets complicated — you need a “reverse script,” you need “compensating transactions,” you need to “manually clean up three or four places” — the problem is not the rollback logic itself; it’s that Move 1’s physical isolation wasn’t done right, and things that belonged in the draft leaked into canonical state. This is the most painful kind of pit I’ve stepped in myself: at first I figured “draft is just a flag, flip it and done,” then discovered that a subagent run during the draft had already written three memories into shared memory that had no business being there. The rollback forgot to clean them; the next round retrieved and backfilled them — one fake rollback contaminated a whole week of judgment afterward.
Fiber’s rollback is that crisp because every fiber node in the wip tree is derived from current but exists independently — discarding the wip just marks that patch of memory reclaimable, and GC handles the rest. For an agent to get an equally crisp rollback, the architecture has to guarantee that every artifact produced during the draft (files, memories, config, subagent state) lives in one isolation container with a common ancestor, so rollback deletes that container whole.
4. The Work: My Blog Publishing Pipeline Is My Own Double Buffer
All three moves above have run end-to-end in my own blog publishing pipeline — not designed up front, but the converged shape that stepping in pits produced.
The pipeline is five steps, none skippable (this one is written into my auto-memory and sits in context at every session start): subagent review after writing → build and push → SSH deploy to the server → tweet + Juejin markdown → update last-deploy-commit. Broken down against the three moves:
The workInProgress layer: every post starts drafting at draft: true, physically living at src/data/blog/zh/blogNNN_*.md — the file really is on disk, the path really is scannable by the build system, but because of draft: true it gets blocked by the postFilter I pass to astro’s getCollection — no sitemap, no RSS, no homepage. That is the “physically isolated namespace”: exists on disk, invisible to the outside. During the draft I allow myself to rewrite repeatedly, allow the drafting subagent to iterate, allow tearing it all up and starting over — because I know none of those changes has any external side effect.
The commit gate: the subagent’s six-dimension review report + my A/B/C grade. After grading, the three frontmatter lines flip together (the code from Move 2 above). This is the single most un-splittable atomic operation in my pipeline — I’ve since written a pre-commit hook that checks “are the three flags consistent,” and any detected inconsistency gets the commit rejected outright. Effectively an architecture-level guardrail bolted onto the gate.
Rollback: I’ve genuinely used this move. The discipline in blog211 §4.4 — “either finish, or nothing gets changed” — was erected out of a real crash: an early version of my publishing cron scanned for posts due, pushed them to the deploy script, and updated last-deploy-commit on success. One deploy, the SSH connection dropped midway, scp transferred half a file, the file on the server was corrupted — and last-deploy-commit had already been updated (the script never considered scp failing). The next cron run looked, saw “already deployed,” and skipped that post. Canonical state (last-deploy-commit) had been contaminated by a failure that happened during the draft.
The fix was adding the rollback logic: when the cron detects a failed deploy, it knocks the three frontmatter flags back to draft: true / reviewed: false / approved: false and does not update last-deploy-commit. Equivalent to removing the attempt from canonical state entirely, retreating to the workInProgress state, and retrying next round. This time it’s genuinely clean — because apart from that one failed scp, the draft produced no other external side effect that needed compensating.
The deepest layer of the dogfooding is this: why do I insist on “three separate flags, draft/reviewed/approved” instead of “one published: true/false and done”? Precisely so the state transitions around the gate are observable in multiple stages — when something breaks you can see which stage it’s stuck at, and when you roll back you can retreat to exactly the right stage. That’s another design implicitly copied from Fiber: every node on the wip tree carries a flags field marking Placement / Update / Deletion — not one boolean deciding “change or not,” but multi-dimensional markers recording “changed up to which step.”
5. The Boundary
This far into the analogy, the reverse boundary is mandatory — otherwise this is just template-stamping. Fiber’s double buffering and agent double buffering differ structurally in three places, and copying them blindly will crash you.
First: the time scales differ by three to eight orders of magnitude. Fiber’s wip tree lives on the millisecond scale — one render from begin to commit usually takes a few to a few dozen milliseconds; after commit, the wip is promoted in place and the old current is demoted to alternate, waiting to be reused next round. An agent’s workInProgress can span subagents, span sessions, even span days — the time last month when I batch-drafted 7 fe2agent posts in parallel, all 7 drafts coexisted in draft: true for over a week, with two or three revision rounds along the way. Which means an agent’s wip needs genuine persistence to disk (Fiber’s wip is a pure in-memory object), and conflicts between wips become a real concern (if two of the 7 drafts reference the same piece of memory, one committing can leave the other’s reference stale).
Second: the fate of the old canonical after commit differs. After a Fiber commit, the old current is demoted to alternate and waits to be reused next round — React preserves no traceable history semantics at all, because UI state needs no audit; users only care what things look like now. After an agent commit, the old canonical usually must keep its full history: after a blog post publishes, the old version stays in git; after a memory update, the old memory is best archived into an archive directory; after a config switch, you need to be able to look up when the old config changed. This isn’t a “keep it if you feel like it” choice — it’s a hard requirement of auditing and debugging: in the agent era, every externally visible commit is the equivalent of a production deploy, and without history there is no postmortem.
Third: Fiber has a single DOM buffer; an agent has multiple heterogeneous buffers. Fiber’s double buffering faces exactly one “external world that must stay consistent” — the DOM. An agent faces at least four: memory (markdown files on disk), context (the in-memory messages array), external APIs (third-party services’ state), and subagent state (each parallel agent’s own progress). Four buffer lanes need a workInProgress each, plus one overall commit coordination mechanism — one lane committing while the others lag is exactly the “half-atomic” accident from Move 2. This is the part Fiber cannot teach an agent; the agent has to install its own “distributed commit coordination layer” in the architecture (somewhat like a database’s two-phase commit, but rougher and more practical).
My most recent fall into the third pit was a few weeks ago: I had the main agent update one memory entry and push one blog update in the same motion. The memory lane committed fine; the blog lane hit a network failure mid-push — leaving the memory already pointing at a blog link that wasn’t live yet. The fix was adding a “staging area” for this kind of cross-buffer commit — both lanes flip together only after both are ready in staging, and if either lane fails, both roll back together. In databases this pattern is closer to two-phase commit’s prepare/commit; in frontend it’s called optimistic update rollback — at bottom, both are the double-buffering idea generalized to the multi-buffer case.
6. The Hook
One warning up front: if you’re designing an agent system and haven’t drawn the draft/canonical boundary yet, the next crash is only a matter of time. Not “it might crash” — “the shape and severity of the crash depend on when it happens”: crash early and it’s a refactor accident; crash late and it’s a production incident.
One action item: before you touch the agent tomorrow, first draw on paper (actually get a piece of paper) where draft and canonical divide in your system. Three questions: what is the draft’s physical isolation container (a directory? a branch? a namespace?)? What does the commit gate check, and who triggers it? What triggers a rollback, and how many lines of code is the action? If any of the three has no answer, don’t write the next tool call yet.
A closing salute for the series — this is extra C, and the last of all 13 posts in “From useEffect to Agent Loop”: 10 mainline + 3 extras. The biggest unexpected takeaway from writing this series wasn’t the conclusion that “frontend experience helps with agents” (blog01 already said that). It was discovering that the old experience frontend accumulated over a decade and more isn’t used up yet in the agent era. React’s four years from stack reconciler to Fiber, three years from Fiber to Concurrent Mode, two years from Suspense to Server Components — the problem behind every one of those architectural evolutions is replaying in the agent era, in a more severe form (because the side effects are no longer confined to the DOM, but extend to the entire externally visible world). So the next step isn’t closing the old React books — it’s reading them more carefully: every architectural constraint that was ever validated is worth copying once more in the agent era.
The fe2agent series wraps here; the frontend-to-agent analogy doesn’t. When I step in the next pit, I’ll write it up.
Further reading:
- acdlite/react-fiber-architecture - the original Fiber design document, double buffering straight from the source