Teaching a Postgres ledger to rewind
A play button and a load-test button, and the invariant that survives both.

The naive thing — what a ledger demo usually looks like
Most ledger demos are screenshots with a button on them.
A few balances. A transaction list. A chart that doesn't move. Maybe a "Try the API" curl block. You read it once. You bounce.
I built that version first. Three accounts, balances on the screen, a button that posts a transaction. The handler was the obvious thing — sum the entries, return the number:
func (s *Service) GetBalance(ctx context.Context, account, currency string) (int64, error) {
var balance int64
err := s.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(CASE WHEN direction = 'in' THEN amount ELSE -amount END), 0)
FROM entries
WHERE account_id = \(1 AND currency = \)2
`, account, currency).Scan(&balance)
return balance, err
}
It worked. The numbers updated. I shipped it.
Then I gave it the cold-visitor test — opened my own demo on a friend's laptop. They scrolled, clicked the button once, and looked at me waiting for instructions. The dashboard was correct. It was also inert.
It works. Until you realize a static ledger demo is just a screenshot with extra steps.
What the demo was missing wasn't more features. It was time and stress.
The history is already in the table
Double-entry has been around since Luca Pacioli wrote Summa de Arithmetica in 1494. The pattern that survived five centuries is one rule: write nothing in place. Every state change is a new row in the journal. The journal is the source of truth. Balances are a function of the journal, not a thing you store and mutate.
That means a double-entry ledger already has a history table — it's called the entries table. Every balance is a sum of entries. An entry doesn't carry its own timestamp, though — it inherits one from the transaction it belongs to. So a point-in-time balance is the same sum, joined to the transaction and bounded by when it happened:
SELECT COALESCE(SUM(CASE WHEN e.direction = 'in' THEN e.amount ELSE -e.amount END), 0)
FROM entries e
JOIN transactions t ON t.id = e.transaction_id
WHERE e.account_id = $1
AND e.currency = $2
AND t.occurred_at <= $3;
I added an optional ?as_of=<RFC3339> to the balance endpoint. When it's there, the handler adds the join and the upper bound. When it's not, you read live.
That is the rewind feature on the backend.
In production the read folds in a daily snapshot to skip re-summing every entry for old accounts. The principle stays the same: the journal is authoritative. The snapshot is a cache.
What I built — two buttons
Two interactions a ledger demo needs to stop being a screenshot: rewind and stress. The first lets you ask what was the state at this moment? The second lets you ask what does the system do under pressure? Both shipped this fortnight.
Scrubber. Drag the playhead on the bottom of the dashboard. Every balance refetches with the chosen
as_of. Drag = one extra SQL clause (AND occurred_at <= $3).Replay button. Glides the dashboard backward 5 minutes, then iterates the event queue forward at 1×, 10×, 100×, or 1000×. Pause anywhere. Snap back to LIVE.
Stress button. Bulk-posts N balanced transactions through the same
ledger.Post()primitive every other write uses. Cap of 10,000.Volume chart. 60 bars, one per minute over the past hour. Each bar = dollars moved that minute. Click a bar → the scrubber jumps to that minute → the dashboard rewinds. Audit any spike.
Mode signature. LIVE has a green dot that breathes — opacity 0.65 to 1 across 1.5 seconds. REPLAY has an amber dot that doesn't, plus a 6% sky-blue cool tint on the dashboard background. You don't have to read a label to know which mode you're in.
Lived-in seed. Every freshly created tenant gets 8 accounts and 100 historical transactions distributed across the past 24 hours, with realistic amounts and real-world descriptions. Stripe payout settlement. ACH return — Wells Fargo. Wire fee — JPMC outbound. End-to-end signup takes 194 milliseconds.
The implementation that actually mattered
The SQL was easy. The animation discipline was the work.
The spec was right. I shipped the opposite for three days. First-touch craft said LIVE = green-breathing, REPLAY = amber-static. I shipped LIVE = amber, REPLAY = cyan. Reversed. I read my own dashboard for three days without noticing. When I flipped them, the dashboard read correctly on the first glance for the first time. Green means the system is running. Amber means it's paused. That convention is already in every car dashboard and every monitoring tool a visitor has ever used. The spec was right because a hundred other tools had already trained the eye.
A balance that snaps from 4,237.81 to 4,287.94 looks like a glitch. A balance that counts across 240 milliseconds with the right easing curve looks like money moving. That's a hook on top of Framer Motion's imperative animate() — wrap the target, tween, render each frame. The first version used useSpring + useTransform. I threw it away because the balance renders as split whole/frac with different typography, and a MotionValue can't be split mid-JSX.
The scrubber was harder. The first version updated as_of on every pointer-move event during a drag — instant refetch storm. The fix was to split visual feedback from state commit: the playhead follows your cursor immediately, the balance refetch fires once on pointer release. Visual feedback is not state commit. That principle generalizes — any draggable thing that triggers expensive work should split the two.
The Replay button is a small state machine on top of the same event queue the scrubber refetches against:
const replayLast = (minutes: number) => {
setAsOf(new Date(Date.now() - minutes * 60_000));
setPhase("rewinding");
setTimeout(() => {
if (queueRef.current.length > 0) setPhase("playing");
else setPhase("buffering");
}, REWIND_MS);
};
Each replayed event applies as a forward balance delta — the same code path that handles a live capture handles a replayed one. Part 3 of this series, Streaming a ledger without melting React, landed the rule this leans on: the events are the database, and the dashboard is one view of them. Replay is just a second view of the same events, played at a cadence you choose.
The volume chart taught me the lesson that almost shipped. The first version plotted total ledger value — the sum of positive balances — over time. It was a flat line. Because in a closed double-entry tenant, that quantity is a conservation invariant. Moving money from one positive account to another doesn't change the sum of positives. The metric I was plotting couldn't move by construction. Switched the chart to dollar volume per minute. The line came alive. Then I made each bar clickable: click a peak, the scrubber jumps to that minute, the dashboard rewinds to that state. Audit any spike.
I deliberately don't reconstruct one thing: on_hold. Pending authorizations aren't journaled the same way captures are — there's no audit row for "authorization in flight." Reconstructing them historically would mean inventing data. A small honest gap reads better than a clean fiction.
The proof
Three measurements lock the feature in.
Reconstruction equality. I probed the same account at three timestamps with different transaction histories behind each. The conservation invariant — sum of all entries equals zero — held at every snapshot:
occurred_at = 09:20:00 → 0 + 0 + 0 = 0
occurred_at = 09:22:00 → +100 − 100 + 0 = 0
occurred_at = 09:25:00 → +50 − 100 + 50 = 0
If the entries table is the journal, the journal is the truth, and the math works at every point in it.
Invariant violations. Zero. Across every stress run. The Post() primitive enforces it: CheckBalanced runs in the same SQL transaction that writes the entries, before the insert. There is no code path that produces an unbalanced commit. Conservation is structural, not lucky.
Throughput under contention. I bulk-posted 10,000 balanced transactions through ledger.Post() at varying concurrency levels. Same M-series local machine, Postgres in Docker:
Concurrency TPS P50 P99 Inv. Viol. Ser. Retries
----------- --- ------- ------- ---------- ------------
1 287 3.4 ms 6.9 ms 0 0
4 491 7.9 ms 14.5 ms 0 0
8 392 19.8 ms 44 ms 0 0
16 324 48 ms 86 ms 0 0
32 282 111 ms 196 ms 0 0
Peak at concurrency 4, ~491 tps. Above that, contention degrades throughput — the pgx pool defaults to roughly NumCPU connections, and Postgres serializes through that ceiling.
The serialization retries column stays at zero across every cell. Not because nothing contended, but because ledger.Post() is READ COMMITTED + append-only. It only inserts immutable balanced entries. No read-modify-write on account rows. No FOR UPDATE. There is nothing for Postgres to abort and retry. Contention shows up as lock-wait latency, not 40001 errors. That is the curve worth naming.
What's next
This is the end of the build series. Six weeks. One ledger you can sign up to, scrub through, overload, click any peak on, and run a curl against. The demo I wish someone had built when I was learning fintech backend.
Live: acta.rithvikronaldo.dev
Repo: github.com/rithvikronaldo/acta, tagged w5-w6-launch.
A retrospective on the whole arc — what I'd carry into a production ledger I'd actually trust — comes the Sunday after.
Subscribe if you want the next one.




