Effect.ts: Absence as First-Class

State ManagementLesson 50 of 51

31. Ref: Why It's Safe

Understanding how Ref prevents race conditions automatically

The Problem: Race Conditions

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:

  1. Fiber A reads counter (0)
  2. Fiber B reads counter (0) ← Still 0!
  3. Fiber A writes counter + 1 (1)
  4. Fiber B writes counter + 1 (1) ← Overwrites A's change!
  5. Result: 1 instead of 2

This is called a race condition.

The Solution: Ref is Atomic
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:

  1. Fiber A calls update
  2. Ref locks, reads (0), adds 1, writes (1), unlocks
  3. Fiber B calls update
  4. Ref locks, reads (1), adds 1, writes (2), unlocks
  5. Fiber C calls update
  6. Ref locks, reads (2), adds 1, writes (3), unlocks
  7. Result: 3

No race conditions possible!

Real Example: Bank Account

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!
});
What Makes Ref Safe?

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.

Mental Model

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.

When Does This Matter?

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
Key Takeaway

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