Effect.ts: Absence as First-Class
State Management • Lesson 50 of 51
31. Ref: Why It's Safe
Understanding how Ref prevents race conditions automatically
With regular variables, concurrent updates cause bugs:
let counter = 0;
// Three fibers increment at the same time
const program = Effect.all([
Effect.sync(() => { counter = counter + 1; }),
Effect.sync(() => { counter = counter + 1; }),
Effect.sync(() => { counter = counter + 1; })
], { concurrency: 'unbounded' });
await Effect.runPromise(program);
console.log(counter); // Might be 1, 2, or 3!
Why it breaks:
- Fiber A reads counter (0)
- Fiber B reads counter (0) ← Still 0!
- Fiber A writes counter + 1 (1)
- Fiber B writes counter + 1 (1) ← Overwrites A's change!
- Result: 1 instead of 2
This is called a race condition.
const program = Effect.gen(function* () {
const counter = yield* Ref.make(0);
yield* Effect.all([
Ref.update(counter, n => n + 1),
Ref.update(counter, n => n + 1),
Ref.update(counter, n => n + 1)
], { concurrency: 'unbounded' });
return yield* Ref.get(counter); // Always 3!
});
Why it works:
Ref operations are atomic - they happen as one indivisible unit:
- Fiber A calls
update - Ref locks, reads (0), adds 1, writes (1), unlocks
- Fiber B calls
update - Ref locks, reads (1), adds 1, writes (2), unlocks
- Fiber C calls
update - Ref locks, reads (2), adds 1, writes (3), unlocks
- Result: 3
No race conditions possible!
Unsafe (regular variable)
let balance = 100;
// Two withdrawals at the same time
const withdrawal1 = Effect.sync(() => {
if (balance >= 50) {
balance = balance - 50; // Might happen
}
});
const withdrawal2 = Effect.sync(() => {
if (balance >= 50) {
balance = balance - 50; // Both pass the check!
}
});
await Effect.runPromise(Effect.all([
withdrawal1,
withdrawal2
], { concurrency: 'unbounded' }));
console.log(balance); // -0 or 0 or 50
// We just lost money!
Safe (Ref)
const program = Effect.gen(function* () {
const balance = yield* Ref.make(100);
const withdrawal = (amount: number) =>
Ref.update(balance, b =>
b >= amount ? b - amount : b
);
yield* Effect.all([
withdrawal(50),
withdrawal(50)
], { concurrency: 'unbounded' });
return yield* Ref.get(balance); // Always 50
// Only one withdrawal succeeds!
});
1. Atomic Operations
Each update is indivisible - read and write happen together.
2. Serialized Access
Even with concurrent updates, Ref ensures they happen one at a time.
3. No Lost Updates
Every update sees the most recent value.
Think of Ref like a single-threaded queue:
Fiber A: update(n => n + 1) ┐
Fiber B: update(n => n + 2) ├─→ [Queue] → Execute one at a time
Fiber C: update(n => n + 3) ┘
Even if multiple fibers call update simultaneously, Ref processes them sequentially to guarantee safety.
Concurrent updates - anytime multiple fibers modify the same state:
- Counters with parallel operations
- Shared caches
- Connection pools
- Rate limiters
- Any mutable state accessed by multiple fibers
You don't have to think about locks, mutexes, or synchronization.
Just use Ref.update() and Effect handles the safety automatically!
// This is ALWAYS safe, no matter how many fibers:
yield* Ref.update(ref, transform);
Ref makes concurrent state management safe by default
Part 50 of 51 in the Effect.ts Absence Modeling series