Skip to content

awaitly vs Effect

Effect is a comprehensive FP runtime and ecosystem for TypeScript. It targets full application architecture (concurrency, resource safety, dependency management, and a large stdlib). awaitly focuses on typed async workflows and orchestration, staying on native async/await (no Effect runtime or generator DSL).

Effect is infrastructure-level. It’s a full effect runtime with a capability-tracking type system: fibers, Scope, Layers, Schedule algebra. You get a runtime that manages effects — concurrency, interruption, resource lifecycles — and the type system encodes that architecture.

awaitly is application-level. It’s a minimal effect abstraction layered directly on top of JavaScript: async/await, Result types, and a step engine. No custom runtime. Architecture lives in values (deps, config, store), not in a capability graph.

That’s not “Effect minus runtime” — it’s a different layer of the stack. awaitly is a deliberately minimal computation model for TypeScript applications: one type for async + errors + environment, without a runtime. Effect is the tool when you want the runtime and the full type-level encoding of capabilities.

Effect awaitly
┌─────────────────────────┐ ┌───────────────────────────┐
│ FP runtime & ecosystem │ │ async/await + Result<T,E>│
│ ────────────────────── │ │ ──────────────────────── │
│ • Fibers & concurrency │ │ • Native Promises │
│ • Layer-based DI │ │ • Workflow orchestration │
│ • Schedule combinators │ │ • Retry/timeout/limits │
│ • Scope & resources │ │ • Automatic error union │
│ • Comprehensive stdlib │ │ • Circuit breaker, saga │
│ │ │ • Durable execution │
│ │ │ • Human-in-the-loop │
└─────────────────────────┘ └───────────────────────────┘

This table compares Effect core/stdlib with awaitly core plus optional awaitly modules (ratelimit, durable, saga, etc.). Effect can implement many of these patterns via primitives or ecosystem packages; awaitly provides some as ready-made modules.

FeatureawaitlyEffect
Learning curveLow (async/await mental model)Higher (runtime model + Effect.gen/Layers)
Bundle footprintSmall; grows with modules usedLarger baseline; grows with modules used
Result typeResult<T, E>Effect<A, E, R>
Error typingInferred from workflow deps + usageTracked in E; generally inferred through composition (flatMap, gen, etc.)
Async modelNative PromisesEffect runtime with fibers
Dependency injectioncreateWorkflow('name', deps); override per run via workflow.run(fn, { deps })Layers (Context-based DI)
Retry / schedulingConfig objectsSchedule combinators
ConcurrencyPromise.all / step.allFibers
Rate limitingawaitly/ratelimitRateLimiter
Circuit breakerawaitly/circuit-breakerNot shipped as a core feature; built from primitives or ecosystem packages
Saga / compensationawaitly/sagaNot shipped as a core feature; built from primitives or ecosystem packages
Durable executionawaitly/durableNot shipped as a core feature; built from primitives or ecosystem packages
Human-in-the-loopawaitly/hitlNot shipped as a core feature; built from primitives or ecosystem packages
Resource managementawaitly/resource (scoped cleanup)Scope (runtime-integrated)
ObservabilityEvent-to-OTel adapter at step boundariesRuntime-integrated spans/tracing APIs (OTel export requires SDK setup)
Tagged errorsTaggedError with _tag + matchingData.TaggedEnum / _tag

awaitly’s step helpers are aligned with Effect’s API so the surface feels familiar to Effect users: as close as we can get while still using async/await and not generators. They run through the full step engine (events, retry, timeout, and in createWorkflow: cache and onAfterStep).

// step.run(id, result, opts?): unwraps AsyncResult, exits on error
const user = await step.run('fetchUser', () => fetchUser('1'), { key: 'user:1' });
// step.andThen(id, value, fn, opts?)
const enriched = await step.andThen('enrich', user, (u) => enrichUser(u));
// step.match(id, result, { ok, err }, opts?): step-tracked
const msg = await step.match('handleUser', userResult, {
ok: (user) => `Hello ${user.name}`,
err: () => 'Failed',
});
// step.all(id, shape, opts?): named results, step tracking
const { user, posts } = await step.all('fetchAll', {
user: () => fetchUser('1'),
posts: () => fetchPosts('1'),
});
// step.map(id, items, mapper, opts?): parallel, step tracking
const users = await step.map('fetchUsers', ['1', '2', '3'], (id) => fetchUser(id));
import { Awaitly, type Result } from 'awaitly';
const divide = (a: number, b: number): Result<number, 'DIVIDE_BY_ZERO'> =>
b === 0 ? Awaitly.err('DIVIDE_BY_ZERO') : Awaitly.ok(a / b);
const result = divide(10, 2);
if (result.ok) {
console.log(result.value);
}
/*
Output:
5
*/
awaitly Result:
┌─── Success value type
│ ┌─── Error type
▼ ▼
Result<number, 'DIVIDE_BY_ZERO'>
Effect:
┌─── Success value
│ ┌─── Error type
│ │ ┌─── Requirements (dependencies)
▼ ▼ ▼
Effect<number, 'DIVIDE_BY_ZERO', never>
import { createWorkflow } from 'awaitly/workflow';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };
const sendEmail = async (to: string): AsyncResult<void, 'EMAIL_FAILED'> => { /* ... */ };
// Error types automatically inferred from dependencies
const workflow = createWorkflow('workflow', { fetchUser, sendEmail });
const result = await workflow.run(async ({ step, deps }) => {
const user = await step.run('fetchUser', () => deps.fetchUser('1'));
await step('sendEmail', () => deps.sendEmail(user.email));
return user;
});
// TypeScript knows: result.error is 'NOT_FOUND' | 'EMAIL_FAILED' | UnexpectedError
// step.run unwraps AsyncResult; use step('id', fn) or step.run with a getter when caching
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
return user;
});
if (!result.ok) {
switch (result.error) {
case 'NOT_FOUND':
console.log('User not found');
break;
case 'EMAIL_FAILED':
console.log('Email failed');
break;
}
}

awaitly uses retry policies while Effect uses Schedule combinators. Both achieve similar outcomes with different approaches.

const result = await workflow.run(async ({ step, deps }) => {
const data = await step.retry(
'fetchData',
() => deps.fetchData(),
{
attempts: 3,
backoff: 'exponential',
delayMs: 100,
}
);
return data;
});
/*
Retry timeline:
Attempt 1: immediate
Attempt 2: after 100ms
Attempt 3: after 200ms
*/
awaitly Exponential Backoff (delayMs: 100)
────────────────────────────────────────────
#1: 100ms ████
#2: 200ms ████████
#3: 400ms ████████████████
#4: 800ms ████████████████████████████████
const result = await workflow.run(async ({ step, deps }) => {
const data = await step.retry(
'fetchData',
() => step.withTimeout(
'fetchData',
() => deps.fetchData(),
{ ms: 5000 }
),
{ attempts: 3, backoff: 'exponential', delayMs: 100 }
);
return data;
});
const result = await step.retry(
'fetchData',
() => deps.fetchData(),
{
attempts: 5,
backoff: 'exponential',
delayMs: 100,
jitter: true, // Adds random variation
}
);
import { createRateLimiter } from 'awaitly/ratelimit';
const limiter = createRateLimiter('api', {
maxPerSecond: 2,
burstCapacity: 5,
});
const result = await workflow.run(async ({ step, deps }) => {
// Rate limit *when the step starts*
const data = await limiter.execute(() => step('fetchData', () => deps.fetchData()));
return data;
});
import { createWorkflow } from 'awaitly/workflow';
const workflow = createWorkflow('workflow', { fetchUser,
validateOrder,
chargeCard,
sendConfirmation,
});
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser(userId));
const order = await step('validateOrder', () => deps.validateOrder(orderData));
const receipt = await step('chargeCard', () => deps.chargeCard(order.total));
await step('sendConfirmation', () => deps.sendConfirmation(user.email, receipt));
return { user, order, receipt };
});
// Effect-style: step.all, named results, step tracking
const result = await workflow.run(async ({ step, deps }) => {
const { user, posts, comments } = await step.all('fetchAll', {
user: () => deps.fetchUser('1'),
posts: () => deps.fetchPosts('1'),
comments: () => deps.fetchComments('1'),
});
return { user, posts, comments };
});
// Or step('id', () => allAsync([...])) for array results
// Option 1: In-memory (simple)
const workflow = createWorkflow('workflow', deps, {
cache: new Map(),
resumeState: savedState, // Resume from previous run
});
// Option 2: Store (awaitly-mongo or awaitly-postgres) — runWithState + save/loadResumeState
import { mongo } from 'awaitly-mongo';
// or: import { postgres } from 'awaitly-postgres';
const store = mongo(process.env.MONGODB_URI!);
const { result, resumeState } = await workflow.runWithState(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });
return user;
});
await store.save('wf-1', resumeState);
// Restore: loadResumeState then run with resumeState
const loaded = await store.loadResumeState('wf-1');
if (loaded) {
await workflow.run(async ({ step, deps }) => { /* same fn */ }, { resumeState: loaded });
}

Both libraries provide ways to combine multiple Results/Effects.

import { Awaitly } from 'awaitly';
// In a workflow: step.all (named) or step.map (array)
const { u1, u2, u3 } = await step.all('fetchUsers', {
u1: () => fetchUser('1'),
u2: () => fetchUser('2'),
u3: () => fetchUser('3'),
});
// Standalone: allAsync
const users = await Awaitly.allAsync([
fetchUser('1'),
fetchUser('2'),
fetchUser('3'),
]);
// { ok: true, value: [user1, user2, user3] } or first error
import { Awaitly } from 'awaitly';
const results = [Awaitly.ok(1), Awaitly.err('A'), Awaitly.ok(3), Awaitly.err('B')];
const settled = Awaitly.allSettled(results);
/*
Output:
{
ok: false,
error: ['A', 'B'],
successes: [1, 3],
failures: ['A', 'B']
}
*/

Both libraries provide scoped resource management with automatic cleanup in LIFO order. Effect’s Scope is deeply integrated with the runtime; awaitly’s withScope is explicit and library-level.

import { withScope, createResource } from 'awaitly/resource';
const dbResource = await createResource(
() => new DatabaseClient().connect(), // acquire
(client) => client.disconnect() // release
);
const result = await withScope(async (scope) => {
const db = scope.add(dbResource); // scope.add returns the value directly
const cache = scope.add(
await createResource(
() => createCacheClient(db),
(c) => c.disconnect()
)
);
const users = await db.query('SELECT * FROM users');
return ok(users);
});
// cleanup runs automatically: cache closed first, then db (LIFO)
// Cleanup errors are collected, not swallowed
if (!result.ok && isResourceCleanupError(result.error)) {
console.log(result.error.errors); // all cleanup failures
console.log(result.error.originalResult); // the actual result before cleanup failed
}

Both libraries support streaming data processing.

import { createWorkflow } from 'awaitly/workflow';
import { createMemoryStreamStore, getStreamReader, toAsyncIterable, collect } from 'awaitly/streaming';
const streamStore = createMemoryStreamStore();
const workflow = createWorkflow('enrich', deps, { streamStore });
const result = await workflow.run(async ({ step }) => {
const writer = step.getWritable<User>({ namespace: 'users' });
for (const user of users) {
const enriched = await step('enrich', () => enrichUser(user));
await writer.write(enriched);
}
await writer.close();
});
// Consume the stream externally (outside the workflow)
const reader = getStreamReader<User>({
store: streamStore,
workflowId: 'enrich',
namespace: 'users',
});
// Collect all items
const allUsers = await collect(reader);
// Or process one at a time
for await (const user of toAsyncIterable(reader)) {
await processUser(user);
}

Both libraries provide functional composition utilities.

import { Awaitly } from 'awaitly';
// pipe: Apply functions left-to-right to a value
const result = Awaitly.pipe(
Awaitly.ok(5),
Awaitly.R.map(n => n * 2),
Awaitly.R.flatMap(n => n > 5 ? Awaitly.ok(n) : Awaitly.err('TOO_SMALL')),
Awaitly.R.mapError(e => ({ code: e }))
);
// flow: Create a new function from composed functions
const processNumber = Awaitly.flow(
Awaitly.R.map((n: number) => n * 2),
Awaitly.R.flatMap(n => n > 5 ? Awaitly.ok(n) : Awaitly.err('TOO_SMALL')),
Awaitly.R.mapError(e => ({ code: e }))
);
const result = processNumber(Awaitly.ok(5));

Both use a _tag discriminant for pattern matching on error types.

import { TaggedError } from 'awaitly/tagged-error';
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
resource: string;
}> {}
class ValidationError extends TaggedError("ValidationError", {
message: (p: { field: string }) => `Invalid ${p.field}`
}) {}
// Instances are real Error objects with instanceof support
const err = new NotFoundError({ id: '123', resource: 'User' });
err._tag; // "NotFoundError"
err instanceof Error; // true
// Exhaustive matching: compiler errors if you miss a case
type AppError = NotFoundError | ValidationError;
const msg = TaggedError.match(error as AppError, {
NotFoundError: (e) => `Missing ${e.resource}: ${e.id}`,
ValidationError: (e) => e.message,
});

awaitly includes a circuit breaker. Effect doesn’t ship one as a core feature; you’d typically build it from primitives (Ref, Schedule) or use an ecosystem package.

import { createCircuitBreaker } from 'awaitly/circuit-breaker';
const apiBreaker = createCircuitBreaker('external-api', {
failureThreshold: 5, // open after 5 failures
resetTimeout: 30_000, // try again after 30s
halfOpenMax: 3, // allow 3 test requests in half-open
});
const result = await workflow.run(async ({ step, deps }) => {
const data = await apiBreaker.executeResult(
() => step('fetchData', () => deps.fetchData())
);
return data;
});
// Check state programmatically
apiBreaker.getState(); // "CLOSED" | "OPEN" | "HALF_OPEN"
apiBreaker.getStats(); // { failures, successes, state, ... }
Circuit Breaker States
──────────────────────────────────────────────────────
CLOSED ──(failures hit threshold)──► OPEN
▲ │
│ (resetTimeout expires)
│ ▼
└────(test requests pass)──── HALF_OPEN

When a multi-step operation fails partway through, you need to undo the steps that already succeeded. awaitly ships a saga pattern that runs compensations in reverse order automatically. Effect doesn’t ship saga/compensation as a core feature; you’d compose it manually with acquireRelease or catchAll.

import { createSagaWorkflow } from 'awaitly/saga';
const checkout = createSagaWorkflow('checkout', {
reserveInventory,
chargeCard,
sendEmail,
});
const result = await checkout(async ({ saga }) => {
const reservation = await saga.step(
'reserve',
() => reserveInventory(items),
{ compensate: (res) => releaseInventory(res.id) }
);
const payment = await saga.step(
'charge',
() => chargeCard(amount),
{ compensate: (p) => refundPayment(p.txId) }
);
await saga.step('notify', () => sendEmail(userId));
return { reservation, payment };
});
// If chargeCard fails:
// 1. releaseInventory runs automatically (reverse order)
// 2. result.ok === false
// If a compensation itself fails, errors are collected:
if (!result.ok && isSagaCompensationError(result.error)) {
result.error.originalError; // what triggered the rollback
result.error.compensationErrors; // which cleanups failed
}

awaitly can checkpoint workflow state after each keyed step, so if the process crashes, the workflow resumes from the last completed step instead of re-running everything.

import { durable } from 'awaitly/durable';
const result = await durable.run(
{ fetchUser, createOrder, sendEmail },
async ({ step, deps }) => {
// Each keyed step is checkpointed; if the process crashes
// after 'createOrder', it won't re-run 'fetchUser' on resume
const user = await step('fetchUser', () => deps.fetchUser('123'), { key: 'user' });
const order = await step('createOrder', () => deps.createOrder(user), { key: 'order' });
await step('sendEmail', () => deps.sendEmail(order), { key: 'email' });
return order;
},
{
id: 'checkout-123',
store, // awaitly-postgres or awaitly-mongo
version: 1, // bump when workflow logic changes
}
);
// Query pending workflows
const pending = await durable.listPending(store);
// Clean up old state
await durable.deleteState(store, 'checkout-123');

awaitly can pause a workflow mid-execution to wait for a human approval, then resume from where it left off.

import { createHITLOrchestrator, createMemoryApprovalStore, createMemoryWorkflowStateStore } from 'awaitly/hitl';
const orchestrator = createHITLOrchestrator({
approvalStore: createMemoryApprovalStore(),
workflowStateStore: createMemoryWorkflowStateStore(),
notificationChannel: slackNotifier, // optional: notify reviewers
});
// Start workflow: it pauses when it hits an approval gate
const execution = await orchestrator.execute(
'large-refund',
workflowFactory,
async ({ step, deps, args }) => {
const refund = await step('calculate', () => deps.calculateRefund(args.orderId));
// Workflow pauses here until approved
await step('approve', () => deps.requireApproval(refund), { key: `refund:${refund.id}` });
await step('process', () => deps.processRefund(refund));
return refund;
},
{ orderId: '456' }
);
if (execution.status === 'paused') {
console.log('Waiting for:', execution.pendingApprovals);
}
// Later: manager approves via API/webhook
await orchestrator.grantApproval('refund:789', { approvedBy: 'manager@co.com' });
const resumed = await orchestrator.resume(execution.runId, workflowFactory, fn);
import { createAutotelAdapter } from 'awaitly/otel';
import { createWorkflow } from 'awaitly/workflow';
const otel = createAutotelAdapter({
serviceName: 'checkout',
createStepSpans: true,
recordMetrics: true,
});
const workflow = createWorkflow('checkout', deps, {
onEvent: otel.handleEvent, // plug into the event system
});
// After execution, inspect metrics
const metrics = otel.getMetrics();
metrics.stepDurations; // [{ name, durationMs, success }]
metrics.retryCount;
metrics.errorCount;
metrics.cacheHits;

If you rely heavily on scoped resources and structured concurrency — Effect remains the better tool. Fibers, Scope, and interruption are built in; awaitly doesn’t model that level of lifecycle.

If your team finds the runtime and abstraction surface too heavy — awaitly offers a simpler mental model: same async/await, typed errors in the return type, deps passed as values. No generators, no Layer graph.

Spell it out: both get you typed errors and composition; the trade-off is runtime power and type-level architecture (Effect) vs. application-level simplicity (awaitly).

  • Your team knows async/await and you want typed errors without learning a new paradigm
  • You need workflow features like sagas, durable execution, circuit breakers, or human-in-the-loop
  • You want automatic error type inference from your dependencies
  • Bundle footprint matters (awaitly stays small; Effect is larger and scales with modules)
  • You want to adopt incrementally: start with ok/err in one function, add workflows later
  • You want a full functional programming system with fibers and structured concurrency
  • You need the Layer system for complex dependency injection graphs
  • You want composable Schedule types for advanced retry/repeat logic
  • Your team is comfortable with generators and functional composition
  • You want Effect’s extensive standard library (Schema, Stream, STM, etc.)
  • Fibers: Lightweight threads with structured concurrency, interruption, and forking. awaitly uses native Promises.
  • Layer system: Compile-time verified dependency graphs. awaitly’s DI is simpler: deps are passed to createWorkflow.
  • Schedule combinators: Composable scheduling algebra. awaitly uses config objects ({ attempts: 3, backoff: 'exponential' }).
  • STM: Software transactional memory. awaitly doesn’t have an equivalent.
  • Schema: Runtime validation library. awaitly doesn’t include one (use Zod, Valibot, etc.).

What awaitly provides as first-class modules

Section titled “What awaitly provides as first-class modules”
  • Circuit breaker: CLOSED/OPEN/HALF_OPEN states, presets.
  • Saga / compensation: Reverse-order rollback on failure.
  • Durable execution: Checkpoint and resume across restarts.
  • Human-in-the-loop: Pause and resume via approval store.
  • Automatic error inference: Inferred union from deps; no manual wiring.

Bottom line: Effect encodes architecture in types; awaitly keeps architecture in values.