Effect-TS Patterns: Concurrency
This skill provides 20 curated Effect-TS patterns for concurrency. Use this skill when working on tasks related to:
- concurrency
- Best practices in Effect-TS applications
- Real-world patterns and solutions
🟡 Intermediate Patterns
Race Concurrent Effects for the Fastest Result
Rule: Use Effect.race to get the result from the first of several effects to succeed, automatically interrupting the losers.
Good Example:
A classic use case is checking a fast cache before falling back to a slower database. We can race the cache lookup against the database query.
import { Effect, Option } from "effect";
type User = { id: number; name: string };
// Simulate a slower cache lookup that might find nothing (None)
const checkCache: Effect.Effect<Option.Option<User>> = Effect.succeed(
Option.none()
).pipe(
Effect.delay("200 millis") // Made slower so database wins
);
// Simulate a faster database query that will always find the data
const queryDatabase: Effect.Effect<Option.Option<User>> = Effect.succeed(
Option.some({ id: 1, name: "Paul" })
).pipe(
Effect.delay("50 millis") // Made faster so it wins the race
);
// Race them. The database should win and return the user data.
const program = Effect.race(checkCache, queryDatabase).pipe(
// The result of the race is an Option, so we can handle it.
Effect.flatMap((result: Option.Option<User>) =>
Option.match(result, {
onNone: () => Effect.fail("User not found anywhere."),
onSome: (user) => Effect.succeed(user),
})
)
);
// In this case, the database wins the race.
const programWithResults = Effect.gen(function* () {
try {
const user = yield* program;
yield* Effect.log(`User found: ${JSON.stringify(user)}`);
return user;
} catch (error) {
yield* Effect.logError(`Error: ${error}`);
throw error;
}
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logError(`Handled error: ${error}`);
return null;
})
)
);
Effect.runPromise(programWithResults);
// Also demonstrate with logging
const programWithLogging = Effect.gen(function* () {
yield* Effect.logInfo("Starting race between cache and database...");
try {
const user = yield* program;
yield* Effect.logInfo(
`Success: Found user ${user.name} with ID ${user.id}`
);
return user;
} catch (error) {
yield* Effect.logInfo("This won't be reached due to Effect error handling");
return null;
}
}).pipe(
Effect.catchAll((error) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Handled error: ${error}`);
return null;
})
)
);
Effect.runPromise(programWithLogging);
Anti-Pattern:
Don't use Effect.race if you need the results of all the effects. That is the job of Effect.all. Using race in this scenario will cause you to lose data, as all but one of the effects will be interrupted and their results discarded.
import { Effect } from "effect";
const fetchProfile = Effect.succeed({ name: "Paul" });
const fetchPermissions = Effect.succeed(["admin", "editor"]);
// ❌ WRONG: This will only return either the profile OR the permissions,
// whichever resolves first. You will lose the other piece of data.
const incompleteData = Effect.race(fetchProfile, fetchPermissions);
// ✅ CORRECT: Use Effect.all when you need all the results.
const completeData = Effect.all([fetchProfile, fetchPermissions]);
Rationale:
When you have multiple effects that can produce the same type of result, and you only care about the one that finishes first, use Effect.race(effectA, effectB).
Effect.race is a powerful concurrency primitive for performance and resilience. It starts all provided effects in parallel. The moment one of them succeeds, Effect.race immediately interrupts all the other "losing" effects and returns the winning result. If one of the effects fails before any have succeeded, the race is not over; the remaining effects continue to run. The entire race only fails if all participating effects fail.
This is commonly used for:
- Performance: Querying multiple redundant data sources (e.g., two API replicas) and taking the response from whichever is faster.
- Implementing Timeouts: Racing a primary effect against a delayed
Effect.fail, effectively creating a timeout mechanism.
Concurrency Pattern 2: Rate Limit Concurrent Access with Semaphore
Rule: Use Semaphore to limit concurrent access to resources, preventing overload and enabling fair resource distribution.
Good Example:
This example demonstrates limiting concurrent database connections using a Semaphore, preventing connection pool exhaustion.
import { Effect, Semaphore, Fiber } from "effect";
interface QueryResult {
readonly id: number;
readonly result: string;
readonly duration: number;
}
// Simulate a database query that holds a connection
const executeQuery = (
queryId: number,
connectionId: number,
durationMs: number
): Effect.Effect<QueryResult> =>
Effect.gen(function* () {
const startTime = Date.now();
yield* Effect.log(
`[Query ${queryId}] Using connection ${connectionId}, duration: ${durationMs}ms`
);
// Simulate query execution
yield* Effect.sleep(`${durationMs} millis`);
const duration = Date.now() - startTime;
return {
id: queryId,
result: `Result from query ${queryId}`,
duration,
};
});
// Pool configuration
interface ConnectionPoolConfig {
readonly maxConnections: number;
readonly queryTimeout?: number;
}
// Create a rate-limited query executor
const createRateLimitedQueryExecutor = (
config: ConnectionPoolConfig
): Effect.Effect<
(queryId: number, durationMs: number) => Effect.Effect<QueryResult>
> =>
Effect.gen(function* () {
const semaphore = yield* Semaphore.make(config.maxConnections);
let connectionCounter = 0;
return (queryId: number, durationMs: number) =>
Effect.gen(function* () {
// Acquire a permit (wait if none available)
yield* Semaphore.acquire(semaphore);
const connectionId = ++connectionCounter;
// Use try-finally to ensure permit is released
const result = yield* executeQuery(queryId, connectionId, durationMs).pipe(
Effect.ensuring(
Semaphore.release(semaphore).pipe(
Effect.tap(() =>
Effect.log(`[Query ${queryId}] Released connection ${connectionId}`)
)
)
)
);
return result;
});
});
// Simulate multiple queries arriving
const program = Effect.gen(function* () {
const executor = yield* createRateLimitedQueryExecutor({
maxConnections: 3, // Only 3 concurrent connections
});
// Generate 10 queries with varying durations
const queries = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
duration: 500 + Math.random() * 1500, // 500-2000ms
}));
console.log(`\n[POOL] Starting with max 3 concurrent connections\n`);
// Execute all queries with concurrency limit
const results = yield* Effect.all(
queries.map((q) =>
executor(q.id, Math.round(q.duration)).pipe(Effect.fork)
)
).pipe(
Effect.andThen((fibers) =>
Effect.all(fibers.map((fiber) => Fiber.join(fiber)))
)
);
console.log(`\n[POOL] All queries completed\n`);
// Summary
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
const avgDuration = totalDuration / results.length;
console.log(`[SUMMARY]`);
console.log(` Total queries: ${results.length}`);
console.log(` Avg duration: ${Math.round(avgDuration)}ms`);
console.log(` Total time: ${Math.max(...results.map((r) => r.duration))}ms (parallel)`);
});
Effect.runPromise(program);
This pattern:
- Creates a Semaphore with fixed permit count
- Acquires permit before using connection
- Executes operation while holding permit
- Releases permit in finally block (guaranteed)
- Fair queuing of waiting queries
Rationale:
When you need to limit how many operations can run concurrently (e.g., max 10 database connections, max 5 API calls per second), use Semaphore. A Semaphore tracks a pool of permits; operations acquire a permit before proceeding and release it when done. Waiting operations are queued fairly.
Resource constraints require limiting concurrency:
- Connection pools: Database limited to N connections
- API rate limits: Service allows only M requests per second
- Memory limits: Large operations can't all run simultaneously
- CPU constraints: Too many threads waste cycles on context switching
- Backpressure: Prevent downstream from being overwhelmed
Without Semaphore:
- All operations run simultaneously, exhausting resources
- Connection pool overflows, requests fail
- Memory pressure causes garbage collection pauses
- No fair ordering (first-come-first-served)
With Semaphore:
- Fixed concurrency limit
- Fair queuing of waiting operations
- Backpressure naturally flows upstream
- Clear ownership of permits
Manage Shared State Safely with Ref
Rule: Use Ref to manage shared, mutable state concurrently, ensuring atomicity.
Good Example:
This program simulates 1,000 concurrent fibers all trying to increment a shared counter. Because we use Ref.update, every single increment is applied atomically, and the final result is always correct.
import { Effect, Ref } from "effect";
const program = Effect.gen(function* () {
// Create a new Ref with an initial value of 0
const ref = yield* Ref.make(0);
// Define an effect that increments the counter by 1
const increment = Ref.update(ref, (n) => n + 1);
// Create an array of 1,000 increment effects
const tasks = Array.from({ length: 1000 }, () => increment);
// Run all 1,000 effects concurrently
yield* Effect.all(tasks, { concurrency: "unbounded" });
// Get the final value of the counter
return yield* Ref.get(ref);
});
// The result will always be 1000
const programWithLogging = Effect.gen(function* () {
const result = yield* program;
yield* Effect.log(`Final counter value: ${result}`);
return result;
});
Effect.runPromise(programWithLogging);
Anti-Pattern:
The anti-pattern is using a standard JavaScript variable for shared state. The following example is not guaranteed to produce the correct result.
import { Effect } from "effect";
// ❌ WRONG: This is a classic race condition.
const programWithRaceCondition = Effect.gen(function* () {
let count = 0; // A plain, mutable variable
// An effect that reads, increments, and writes the variable
const increment = Effect.sync(() => {
const current = count;
// Another fiber could run between this read and the write below!
count = current + 1;
});
const tasks = Array.from({ length: 1000 }, () => increment);
yield* Effect.all(tasks, { concurrency: "unbounded" });
return count;
});
// The result is unpredictable and will likely be less than 1000.
Effect.runPromise(programWithRaceCondition).then(console.log);
Rationale:
When you need to share mutable state between different concurrent fibers, create a Ref<A>. Use Ref.get to read the value and Ref.update or Ref.set to modify it. All operations on a Ref are atomic.
Directly using a mutable variable (e.g., let myState = ...) in a concurrent system is dangerous. Multiple fibers could try to read and write to it at the same time, leading to race conditions and unpredictable results.
Ref solves this by wrapping the state in a fiber-safe container. It's like a synchronized, in-memory cell. All operations on a Ref are atomic effects, guaranteeing that updates are applied correctly without being interrupted or interleaved with other updates. This eliminates race conditions and ensures data integrity.
Run Independent Effects in Parallel with Effect.all
Rule: Use Effect.all to execute a collection of independent effects concurrently.
Good Example:
Imagine fetching a user's profile and their latest posts from two different API endpoints. These are independent operations and can be run in parallel to save time.
import { Effect } from "effect";
// Simulate fetching a user, takes 1 second
const fetchUser = Effect.succeed({ id: 1, name: "Paul" }).pipe(
Effect.delay("1 second")
);
// Simulate fetching posts, takes 1.5 seconds
const fetchPosts = Effect.succeed([{ title: "Effect is great" }]).pipe(
Effect.delay("1.5 seconds")
);
// Run both effects concurrently - must specify concurrency option!
const program = Effect.all([fetchUser, fetchPosts], {
concurrency: "unbounded",
});
// The resulting effect will succeed with a tuple: [{id, name}, [{title}]]
// Total execution time will be ~1.5 seconds (the duration of the longest task).
const programWithLogging = Effect.gen(function* () {
const results = yield* program;
yield* Effect.log(`Results: ${JSON.stringify(results)}`);
return results;
});
Effect.runPromise(programWithLogging);
Anti-Pattern:
The anti-pattern is running independent tasks sequentially using Effect.gen. This is inefficient and unnecessarily slows down your application.
import { Effect } from "effect";
import { fetchUser, fetchPosts } from "./somewhere"; // From previous example
// ❌ WRONG: This is inefficient.
const program = Effect.gen(function* () {
// fetchUser runs and completes...
const user = yield* fetchUser;
// ...only then does fetchPosts begin.
const posts = yield* fetchPosts;
return [user, posts];
});
// Total execution time will be ~2.5 seconds (1s + 1.5s),
// which is a full second slower than the parallel version.
Effect.runPromise(program).then(console.log);
Rationale:
When you have multiple Effects that do not depend on each other's results, run them concurrently using Effect.all. This will execute all effects at the same time and return a new Effect that succeeds with a tuple containing all the results.
Running tasks sequentially when they could be done in parallel is a common source of performance bottlenecks. Effect.all is the solution. It's the direct equivalent of Promise.all in the Effect ecosystem.
Instead of waiting for Task A to finish before starting Task B, Effect.all starts all tasks simultaneously. The total time to complete is determined by the duration of the longest running effect, not the sum of all durations. If any single effect in the collection fails, the entire Effect.all will fail immediately.
Concurrency Pattern 3: Coordinate Multiple Fibers with Latch
Rule: Use Latch to coordinate multiple fibers awaiting a common completion signal, enabling fan-out/fan-in and barrier synchronization patterns.
Good Example:
This example demonstrates a fan-out/fan-in pattern: spawn 5 worker fibers that process tasks in parallel, and coordinate to know when all are complete.
import { Effect, Latch, Fiber, Ref } from "effect";
interface WorkResult {
readonly workerId: number;
readonly taskId: number;
readonly result: string;
readonly duration: number;
}
// Simulate a long-running task
const processTask = (
workerId: number,
taskId: number
): Effect.Effect<WorkResult> =>
Effect.gen(function* () {
const startTime = Date.now();
const duration = 100 + Math.random() * 400; // 100-500ms
yield* Effect.log(
`[Worker ${workerId}] Starting task ${taskId} (duration: ${Math.round(duration)}ms)`
);
yield* Effect.sleep(`${Math.round(duration)} millis`);
const elapsed = Date.now() - startTime;
yield* Effect.log(
`[Worker ${workerId}] ✓ Completed task ${taskId} in ${elapsed}ms`
);
return {
workerId,
taskId,
result: `Result from worker ${workerId} on task ${taskId}`,
duration: elapsed,
};
});
// Fan-out/Fan-in with Latch
const fanOutFanIn = Effect.gen(function* () {
const numWorkers = 5;
const tasksPerWorker = 3;
// Create latch: will countdown from (numWorkers) when all workers complete
const workersCompleteLatch = yield* Latch.make(numWorkers);
// Track results from all workers
const results = yield* Ref.make<WorkResult[]>([]);
// Worker fiber that processes tasks sequentially
const createWorker = (workerId: number) =>
Effect.gen(function* () {
try {
yield* Effect.log(`[Worker ${workerId}] ▶ Starting`);
// Process multiple tasks
for (let i = 1; i <= tasksPerWorker; i++) {
const result = yield* processTask(workerId, i);
yield* Ref.update(results, (rs) => [...rs, result]);
}
yield* Effect.log(`[Worker ${workerId}] ✓ All tasks completed`);
} finally {
// Signal completion to latch
yield* Latch.countDown(workersCompleteLatch);
yield* Effect.log(`[Worker ${workerId}] Signaled latch`);
}
});
// Spawn all workers as background fibers
console.log(`\n[COORDINATOR] Spawning ${numWorkers} workers...\n`);
const workerFibers = yield* Effect.all(
Array.from({ length: numWorkers }, (_, i) =>
createWorker(i + 1).pipe(Effect.fork)
)
);
// Wait for all workers to complete
console.log(`\n[COORDINATOR] Waiting for all workers to finish...\n`);
yield* Latch.await(workersCompleteLatch);
console.log(`\n[COORDINATOR] All workers completed!\n`);
// Join all fibers to ensure cleanup
yield* Effect.all(workerFibers.map((fiber) => Fiber.join(fiber)));
// Aggregate results
const allResults = yield* Ref.get(results);
console.log(`[SUMMARY]`);
console.log(` Total workers: ${numWorkers}`);
console.log(` Tasks per worker: ${tasksPerWorker}`);
console.log(` Total tasks: ${allResults.length}`);
console.log(
` Avg task duration: ${Math.round(
allResults.reduce((sum, r) => sum + r.duration, 0) / allResults.length
)}ms`
);
});
Effect.runPromise(fanOutFanIn);
This pattern:
- Creates Latch with count = number of workers
- Spawns worker fibers as background tasks
- Each worker processes tasks independently
- Signals Latch when work completes (countDown)
- Coordinator awaits until all workers signal
- Aggregates results from all workers
Rationale:
When you need multiple fibers to coordinate and wait for a shared completion condition, use Latch. A Latch is a countdown synchronization object: you initialize it with N, each fiber calls countDown(), and all waiting fibers are released when the count reaches zero. This enables fan-out/fan-in patterns and barrier synchronization.
Multi-fiber coordination requires synchronization:
- Parallel initialization: Wait for all services to start before proceeding
- Fan-out/fan-in: Spawn multiple workers, collect results when all done
- Barrier synchronization: All fibers wait at a checkpoint before proceeding
- Graceful shutdown: Wait for all active fibers to complete
- Aggregation patterns: Process streams in parallel, combine when ready
Unlike Deferred (one producer signals once), Latch:
- Supports multiple signalers (each
countDown()) - Used with known count of participants (countdown from N to 0)
- Enables barrier patterns (all wait for all)
- Fair queuing of waiting fibers
Concurrency Pattern 5: Broadcast Events with PubSub
Rule: Use PubSub to broadcast events to multiple subscribers, enabling event-driven architectures where publishers and subscribers are loosely coupled.
Good Example:
This example demonstrates a multi-subscriber event broadcast system with independent handlers.
import { Effect, PubSub, Fiber, Ref } from "effect";
interface StateChangeEvent {
readonly id: string;
readonly oldValue: string;
readonly newValue: string;
readonly timestamp: number;
}
interface Subscriber {
readonly name: string;
readonly events: StateChangeEvent[];
}
// Create subscribers that react to events
const createSubscriber = (
name: string,
pubsub: PubSub.PubSub<StateChangeEvent>,
events: Ref.Ref<StateChangeEvent[]>
): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Effect.log(`[${name}] ✓ Subscribed`);
// Get subscriber handle
const subscription = yield* PubSub.subscribe(pubsub);
// Listen for events indefinitely
while (true) {
const event = yield* subscription.take();
yield* Effect.log(
`[${name}] Received event: ${event.oldValue} → ${event.newValue}`
);
// Simulate processing
yield* Effect.sleep("50 millis");
// Store event (example action)
yield* Ref.update(events, (es) => [...es, event]);
yield* Effect.log(`[${name}] ✓ Processed event`);
}
});
// Publisher that broadcasts events
const publisher = (
pubsub: PubSub.PubSub<StateChangeEvent>,
eventCount: number
): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Effect.log(`[PUBLISHER] Starting, publishing ${eventCount} events`);
for (let i = 1; i <= eventCount; i++) {
const event: StateChangeEvent = {
id: `event-${i}`,
oldValue: `state-${i - 1}`,
newValue: `state-${i}`,
timestamp: Date.now(),
};
// Publish to all subscribers
const size = yield* PubSub.publish(pubsub, event);
yield* Effect.log(
`[PUBLISHER] Published event to ${size} subscribers`
);
// Simulate delay between events
yield* Effect.sleep("200 millis");
}
yield* Effect.log(`[PUBLISHER] ✓ All events published`);
});
// Main: coordinate publisher and multiple subscribers
const program = Effect.gen(function* () {
// Create PubSub with bounded capacity
const pubsub = yield* PubSub.bounded<StateChangeEvent>(5);
// Create storage for each subscriber's events
const subscriber1Events = yield* Ref.make<StateChangeEvent[]>([]);
const subscriber2Events = yield* Ref.make<StateChangeEvent[]>([]);
const subscriber3Events = yield* Ref.make<StateChangeEvent[]>([]);
console.log(`\n[MAIN] Starting PubSub event broadcast system\n`);
// Subscribe 3 independent subscribers
const sub1Fiber = yield* createSubscriber(
"SUBSCRIBER-1",
pubsub,
subscriber1Events
).pipe(Effect.fork);
const sub2Fiber = yield* createSubscriber(
"SUBSCRIBER-2",
pubsub,
subscriber2Events
).pipe(Effect.fork);
const sub3Fiber = yield* createSubscriber(
"SUBSCRIBER-3",
pubsub,
subscriber3Events
).pipe(Effect.fork);
// Wait for subscriptions to establish
yield* Effect.sleep("100 millis");
// Start publisher
const publisherFiber = yield* publisher(pubsub, 5).pipe(Effect.fork);
// Wait for publisher to finish
yield* Fiber.join(publisherFiber);
// Wait a bit for subscribers to process last events
yield* Effect.sleep("1 second");
// Shut down
yield* PubSub.shutdown(pubsub);
yield* Fiber.join(sub1Fiber).pipe(Effect.catchAll(() => Effect.void));
yield* Fiber.join(sub2Fiber).pipe(Effect.catchAll(() => Effect.void));
yield* Fiber.join(sub3Fiber).pipe(Effect.catchAll(() => Effect.void));
// Print summary
const events1 = yield* Ref.get(subscriber1Events);
const events2 = yield* Ref.get(subscriber2Events);
const events3 = yield* Ref.get(subscriber3Events);
console.log(`\n[SUMMARY]`);
console.log(` Subscriber 1 received: ${events1.length} events`);
console.log(` Subscriber 2 received: ${events2.length} events`);
console.log(` Subscriber 3 received: ${events3.length} events`);
});
Effect.runPromise(program);
This pattern:
- Creates PubSub for event distribution
- Multiple subscribers listen independently
- Publisher broadcasts events to all
- Each subscriber processes at own pace
Rationale:
When multiple fibers need to react to the same events, use PubSub:
- Publisher sends events once
- Subscribers each receive a copy
- Decoupled: Publisher doesn't know about subscribers
- Fan-out: One event → multiple independent handlers
PubSub variants: bounded (backpressure), unbounded, sliding.
Event distribution without PubSub creates coupling:
- Direct references: Publisher calls subscribers directly (tight coupling)
- Ordering issues: Publisher blocks on slowest subscriber
- Scalability: Adding subscribers slows down publisher
- Testing: Hard to mock multiple subscribers
PubSub enables:
- Loose coupling: Publishers emit, subscribers listen independently
- Parallel delivery: All subscribers notified simultaneously
- Scalability: Add subscribers without affecting publisher
- Testing: Mock single PubSub rather than all subscribers
Real-world example: System state changes
- Direct: StateManager calls UserNotifier, AuditLogger, MetricsCollector (tight coupling)
- PubSub: StateManager publishes
StateChangedevent; subscribers listen independently
Process a Collection in Parallel with Effect.forEach
Rule: Use Effect.forEach with the concurrency option to process a collection in parallel with a fixed limit.
Good Example:
Imagine you have a list of 100 user IDs and you need to fetch the data for each one. Effect.forEach with a concurrency of 10 will process them in controlled parallel batches.
import { Clock, Effect } from "effect";
// Mock function to simulate fetching a user by ID
const fetchUserById = (id: number) =>
Effect.gen(function* () {
yield* Effect.logInfo(`Fetching user ${id}...`);
yield* Effect.sleep("1 second"); // Simulate network delay
return { id, name: `User ${id}`, email: `user${id}@example.com` };
});
const userIds = Array.from({ length: 10 }, (_, i) => i + 1);
// Process the entire array, but only run 5 fetches at a time.
const program = Effect.gen(function* () {
yield* Effect.logInfo("Starting parallel processing...");
const startTime = yield* Clock.currentTimeMillis;
const users = yield* Effect.forEach(userIds, fetchUserById, {
concurrency: 5, // Limit to 5 concurrent operations
});
const endTime = yield* Clock.currentTimeMillis;
yield* Effect.logInfo(
`Processed ${users.length} users in ${endTime - startTime}ms`
);
yield* Effect.logInfo(
`First few users: ${JSON.stringify(users.slice(0, 3), null, 2)}`
);
return users;
});
// The result will be an array of all user objects.
// The total time will be much less than running them sequentially.
Effect.runPromise(program);
Anti-Pattern:
The anti-pattern is using Effect.all to process a large or dynamically-sized collection. This can lead to unpredictable and potentially catastrophic resource consumption.
import { Effect } from "effect";
import { userIds, fetchUserById } from "./somewhere"; // From previous example
// ❌ DANGEROUS: This will attempt to start 100 concurrent network requests.
// If userIds had 10,000 items, this could crash your application or get you blocked by an API.
const program = Effect.all(userIds.map(fetchUserById));
Rationale:
To process an iterable (like an array) of items concurrently, use Effect.forEach. To avoid overwhelming systems, always specify the { concurrency: number } option to limit how many effects run at the same time.
Running Effect.all on a large array of tasks is dangerous. If you have 1,000 items, it will try to start 1,000 concurrent fibers at once, which can exhaust memory, overwhelm your CPU, or hit API rate limits.
Effect.forEach with a concurrency limit solves this problem elegantly. It acts as a concurrent processing pool. It will start processing items up to your specified limit (e.g., 10 at a time). As soon as one task finishes, it will pick up the next available item from the list, ensuring that no more than 10 tasks are ever running simultaneously. This provides massive performance gains over sequential processing while maintaining stability and control.
Concurrency Pattern 6: Race and Timeout Competing Effects
Rule: Use race to compete effects and timeout to enforce deadlines, enabling cancellation when operations exceed time limits or complete.
Good Example:
This example demonstrates racing competing effects and handling timeouts.
import { Effect, Fiber } from "effect";
interface DataSource {
readonly name: string;
readonly latencyMs: number;
}
// Simulate fetching from different sources
const fetchFromSource = (source: DataSource): Effect.Effect<string> =>
Effect.gen(function* () {
yield* Effect.log(
`[${source.name}] Starting fetch (latency: ${source.latencyMs}ms)`
);
yield* Effect.sleep(`${source.latencyMs} millis`);
const result = `Data from ${source.name}`;
yield* Effect.log(`[${source.name}] ✓ Completed`);
return result;
});
// Main: demonstrate race patterns
const program = Effect.gen(function* () {
console.log(`\n[RACE] Competing effects with race and timeout\n`);
// Example 1: Simple race (fastest wins)
console.log(`[1] Racing 3 data sources:\n`);
const sources: DataSource[] = [
{ name: "Primary DC", latencyMs: 200 },
{ name: "Backup DC", latencyMs: 150 },
{ name: "Cache", latencyMs: 50 },
];
const raceResult = yield* Effect.race(
fetchFromSource(sources[0]),
Effect.race(fetchFromSource(sources[1]), fetchFromSource(sources[2]))
);
console.log(`\nWinner: ${raceResult}\n`);
// Example 2: Timeout - succeed within deadline
console.log(`[2] Timeout with fast operation:\n`);
const fastOp = fetchFromSource({ name: "Fast Op", latencyMs: 100 }).pipe(
Effect.timeout("500 millis")
);
const fastResult = yield* fastOp;
console.log(`✓ Completed within timeout: ${fastResult}\n`);
// Example 3: Timeout - exceed deadline
console.log(`[3] Timeout with slow operation:\n`);
const slowOp = fetchFromSource({ name: "Slow Op", latencyMs: 2000 }).pipe(
Effect.timeout("500 millis"),
Effect.either
);
const timeoutResult = yield* slowOp;
if (timeoutResult._tag === "Left") {
console.log(`✗ Operation timed out after 500ms\n`);
}
// Example 4: Race with timeout fallback
console.log(`[4] Race with fallback on timeout:\n`);
const primary = fetchFromSource({ name: "Primary", latencyMs: 300 });
const fallback = fetchFromSource({ name: "Fallback", latencyMs: 100 });
const raceWithFallback = primary.pipe(
Effect.timeout("150 millis"),
Effect.catchAll(() => {
yield* Effect.log(`[PRIMARY] Timed out, using fallback`);
return fallback;
})
);
const fallbackResult = yield* raceWithFallback;
console.log(`Result: ${fallbackResult}\n`);
// Example 5: Race all (collect all winners)
console.log(`[5] Race all - multiple sources:\n`);
const raceAllResult = yield* Effect.raceAll(
sources.map((s) =>
fetchFromSource(s).pipe(
Effect.map((data) => ({ source: s.name, data }))
)
)
);
console.log(`First to complete: ${raceAllResult.source}\n`);
});
Effect.runPromise(program);
Rationale:
Race and timeout coordinate competing effects:
- race: Multiple effects compete, first to succeed wins
- timeout: Effect fails if not completed in time
- raceAll: Race multiple effects, collect winners
- timeoutFail: Fail with specific error on timeout
Pattern: Effect.race(effect1, effect2) or effect.pipe(Effect.timeout(duration))
Without race/timeout, competing effects create issues:
- Deadlocks: Waiting for all to complete unnecessarily
- Hanging requests: No deadline enforcement
- Wasted resources: Slow operations continue indefinitely
- No fallback: Can't switch to alternative on timeout
Race/timeout enable:
- Fastest-wins: Take first success
- Deadline enforcement: Fail after time limit
- Resource cleanup: Cancel slower operations
- Fallback patterns: Alternative if primary times out
Real-world example: Multi-datacenter request
- Without race: Wait for slowest response
- With race: Get response from fastest datacenter
Concurrency Pattern 1: Coordinate Async Operations with Deferred
Rule: Use Deferred for one-time async coordination between fibers, enabling multiple consumers to wait for a single producer's result.
Good Example:
This example demonstrates a service startup pattern where multiple workers wait for initialization to complete before starting processing.
import { Effect, Deferred, Fiber } from "effect";
interface ServiceConfig {
readonly name: string;
readonly port: number;
}
interface Service {
readonly name: string;
readonly isReady: Deferred.Deferred<void>;
}
// Simulate a service that takes time to initialize
const createService = (config: ServiceConfig): Effect.Effect<Service> =>
Effect.gen(function* () {
const isReady = yield* Deferred.make<void>();
return { name: config.name, isReady };
});
// Initialize the service (runs in background)
const initializeService = (service: Service): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Effect.log(`[${service.name}] Starting initialization...`);
// Simulate initialization work
yield* Effect.sleep("1 second");
yield* Effect.log(`[${service.name}] Initialization complete`);
// Signal that service is ready
yield* Deferred.succeed(service.isReady, undefined);
});
// A worker that waits for service to be ready before starting
const createWorker = (
id: number,
services: Service[]
): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Effect.log(`[Worker ${id}] Starting, waiting for services...`);
// Wait for all services to be ready
yield* Effect.all(
services.map((service) =>
Deferred.await(service.isReady).pipe(
Effect.tapError((error) =>
Effect.log(
`[Worker ${id}] Error waiting for ${service.name}: ${error}`
)
)
)
)
);
yield* Effect.log(`[Worker ${id}] All services ready, starting work`);
// Simulate worker processing
for (let i = 0; i < 3; i++) {
yield* Effect.sleep("500 millis");
yield* Effect.log(`[Worker ${id}] Processing task ${i + 1}`);
}
yield* Effect.log(`[Worker ${id}] Complete`);
});
// Main program
const program = Effect.gen(function* () {
// Create services
const apiService = yield* createService({ name: "API", port: 3000 });
const dbService = yield* createService({ name: "Database", port: 5432 });
const cacheService = yield* createService({ name: "Cache", port: 6379 });
const services = [apiService, dbService, cacheService];
// Start initializing services in background
const initFibers = yield* Effect.all(
services.map((service) => initializeService(service).pipe(Effect.fork))
);
// Start workers that wait for services
const workerFibers = yield* Effect.all(
[1, 2, 3].map((id) => createWorker(id, services).pipe(Effect.fork))
);
// Wait for all workers to complete
yield* Effect.all(workerFibers.map((fiber) => Fiber.join(fiber)));
// Cancel initialization fibers (they're done anyway)
yield* Effect.all(initFibers.map((fiber) => Fiber.interrupt(fiber)));
yield* Effect.log(`\n[MAIN] All workers completed`);
});
Effect.runPromise(program);
This pattern:
- Creates Deferred instances for each service's readiness
- Starts initialization in background fibers
- Workers wait for all services via
Deferred.await - Service signals completion via
Deferred.succeed - Workers resume when all dependencies ready
Rationale:
When you need multiple fibers to wait for a single async event (e.g., service initialization, data availability, external signal), use Deferred. A Deferred is a one-shot promise that exactly one fiber completes, and many fibers can wait for. This avoids polling and provides clean async signaling.
Many concurrent systems need to coordinate on events:
- Service initialization: Wait for all services to start before accepting requests
- Data availability: Wait for initial data load before processing
- External events: Wait for webhook, signal, or message
- Startup gates: All workers wait for leader to signal start
Without Deferred:
- Polling wastes CPU (check repeatedly)
- Callbacks become complex (multiple consumers)
- No clean semantics for "wait for this one thing"
- Error propagation unclear
With Deferred:
- Non-blocking wait (fiber suspends)
- One fiber produces, many consume
- Clear completion or failure
- Efficient wakeup when ready
Concurrency Pattern 4: Distribute Work with Queue
Rule: Use Queue to distribute work between producers and consumers with built-in backpressure, enabling flexible pipeline coordination.
Good Example:
This example demonstrates a producer-consumer pipeline with a bounded queue for buffering work items.
import { Effect, Queue, Fiber, Ref } from "effect";
interface WorkItem {
readonly id: number;
readonly data: string;
readonly timestamp: number;
}
interface WorkResult {
readonly itemId: number;
readonly processed: string;
readonly duration: number;
}
// Producer: generates work items
const producer = (
queue: Queue.Enqueue<WorkItem>,
count: number
): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Effect.log(`[PRODUCER] Starting, generating ${count} items`);
for (let i = 1; i <= count; i++) {
const item: WorkItem = {
id: i,
data: `Item ${i}`,
timestamp: Date.now(),
};
const start = Date.now();
// Enqueue - will block if queue is full (backpressure)
yield* Queue.offer(queue, item);
const delay = Date.now() - start;
if (delay > 0) {
yield* Effect.log(
`[PRODUCER] Item ${i} enqueued (waited ${delay}ms due to backpressure)`
);
} else {
yield* Effect.log(`[PRODUCER] Item ${i} enqueued`);
}
// Simulate work
yield* Effect.sleep("50 millis");
}
yield* Effect.log(`[PRODUCER] ✓ All items enqueued`);
});
// Consumer: processes work items
const consumer = (
queue: Queue.Dequeue<WorkItem>,
consumerId: number,
results: Ref.Ref<WorkResult[]>
): Effect.Effect<void> =>
Effect.gen(function* () {
yield* Effect.log(`[CONSUMER ${consumerId}] Starting`);
while (true) {
// Dequeue - will block if queue is empty
const item = yield* Queue.take(queue).pipe(Effect.either);
if (item._tag === "Left") {
yield* Effect.log(`[CONSUMER ${consumerId}] Queue closed, stopping`);
return;
}
const workItem = item.right;
const startTime = Date.now();
yield* Effect.log(
`[CONSUMER ${consumerId}] Processing ${workItem.data}`
);
// Simulate processing
yield* Effect.sleep("150 millis");
const duration = Date.now() - startTime;
const result: WorkResult = {
itemId: workItem.id,
processed: `${workItem.data} [processed by consumer ${consumerId}]`,
duration,
};
yield* Ref.update(results, (rs) => [...rs, result]);
yield* Effect.log(
`[CONSUMER ${consumerId}] ✓ Completed ${workItem.data} in ${duration}ms`
);
}
});
// Main: coordinate producer and consumers
const program = Effect.gen(function* () {
// Create bounded queue with capacity 3
const queue = yield* Queue.bounded<WorkItem>(3);
const results = yield* Ref.make<WorkResult[]>([]);
console.log(`\n[MAIN] Starting producer-consumer pipeline with queue size 3\n`);
// Spawn producer
const producerFiber = yield* producer(queue, 10).pipe(Effect.fork);
// Spawn 2 consumers
const consume
…(truncated)