Patterns for concurrency in Effect-TS. Use when working with concurrent workflows in Effect-TS applications.
This skill provides 20 curated Effect-TS patterns for concurrency. Use this skill when working on tasks related to:
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:
Effect.fail, effectively creating a timeout mechanism.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:
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:
Without Semaphore:
With Semaphore:
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.
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.
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:
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:
Unlike Deferred (one producer signals once), Latch:
countDown())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:
npx skills add PaulJPhilp/effect-patterns-concurrency下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer