JavaScript's async ecosystem has evolved dramatically over the yearsβfrom callbacks to Promises, from async/await to various control flow libraries. Yet, despite this evolution, critical gaps remain in how we handle asynchronous operations in production applications.
Modern applications need more than just Promises. They need:
- Cancellation: Stop long-running operations when they're no longer needed
- Composition: Build complex async workflows without callback hell or try-catch pyramids
- Control: Fine-grained management of concurrency, retries, timeouts, and fallbacks
- Safety: Handle errors explicitly without littering code with try-catch blocks
- Reusability: Define async operations once, execute them multiple times
JavaScript's native Promise API offers none of these. AbortController exists but requires verbose boilerplate. Third-party solutions are either too opaque (RxJS), too heavy, or too limited in scope.
Futurable fills this gap with two complementary primitives:
Futurable: A Promise with superpowers β cancellable, chainable, and resource-awareFuturableTask: A lazy computation model for functional async composition
Together, they provide everything you need to write robust, maintainable, production-ready async code.
Futurable extends the native Promise API with built-in cancellation support. It's a drop-in replacement for Promise that solves the resource management problem.
The core insight: When you navigate away from a page, close a modal, or change a filter, you don't just want to ignore pending operations β you want to actively stop them and clean up resources.
import { Futurable } from '@ndriadev/futurable';
// Create a cancellable fetch request
const request = Futurable.fetch('https://api.example.com/data')
.then(res => res.json())
.then(data => console.log(data));
// User navigates away? Cancel it.
request.cancel();Why this matters:
- Memory leaks: Prevented by cancelling pending operations
- Race conditions: Eliminated by cancelling stale requests
- Resource management: WebSocket connections, timers, and event listeners properly cleaned up
- User experience: No more stale data updates after navigation
Use Futurable when you need immediate execution with cancellation support:
- React/Vue component effects that need cleanup
- API requests that should be cancellable
- Any Promise-based code where you might need to cancel
- Drop-in replacement for existing Promise code
FuturableTask represents a blueprint for async work β it doesn't execute until you explicitly run it. Think of it as a recipe: you write it once, then execute it multiple times independently.
The core insight: Many async operations benefit from lazy evaluation β separating the definition of work from its execution enables powerful patterns like retry, memoization, and functional composition.
import { FuturableTask } from '@ndriadev/futurable';
// Define the work (doesn't execute yet)
const fetchUser = FuturableTask
.fetch('/api/user')
.map(res => res.json())
.retry(3)
.timeout(5000)
.memoize();
// Execute when needed
const user = await fetchUser.run();
// Execute again (uses memoized result)
const sameUser = await fetchUser.run();Why this matters:
- Reusability: Define once, execute many times
- Composition: Chain transformations before execution
- Testing: Easy to test without side effects at definition time
- Optimization: Memoization, batching, and deduplication
- Declarative: Describe what should happen, not when
Use FuturableTask when you need lazy evaluation with advanced composition:
- Building reusable async workflows
- Complex pipelines with retry/timeout/fallback logic
- Operations that should be memoized or deduplicated
- Functional programming patterns in async code
- Rate-limited or batched API calls
Stop operations and clean up resources. When cancelled, a Futurable stops silently β it neither resolves nor rejects. Use onCancel() to react to cancellation:
const request = Futurable.fetch('/api/data')
.then(res => res.json())
.onCancel(() => {
console.log('Cleanup: close connections, clear timers');
});
// Cancel anytime β onCancel callback fires, then/catch are not called
request.cancel();Native support for common patterns:
// Sleep
await Futurable.sleep({ timer: 1000 });
// Delayed execution
const result = await Futurable.resolve('value')
.delay(val => val.toUpperCase(), 2000);
// Polling
const controller = Futurable.polling(
() => checkStatus(),
{ interval: 1000, immediate: true }
);
// Stop later
controller.cancel();
// Cancellable fetch
const data = await Futurable.fetch('/api/data')
.then(res => res.json());Handle errors without try-catch:
const result = await Futurable.fetch('/api/data')
.then(res => res.json())
.safe();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}Wrap any function β synchronous or asynchronous, throwing or returning β into a Futurable safely:
// Catches synchronous throws that Futurable.resolve(fn()) would miss
const parsed = await Futurable.try(() => JSON.parse(rawInput))
.safe();
// Works equally well with async functions
const data = await Futurable.try(async () => {
const res = await fetch('/api/data');
return res.json();
});
// Unify sync and async callbacks in one interface
function execute(action: () => unknown) {
return Futurable.try(action)
.catch(err => console.error('Caught:', err));
}Build complex pipelines declaratively:
const pipeline = FuturableTask
.fetch('/api/users')
.map(res => res.json())
.map(users => users.filter(u => u.active))
.map(users => users.sort((a, b) => a.name.localeCompare(b.name)))
.tap(users => console.log(`Found ${users.length} active users`));
const users = await pipeline.run();Sophisticated error handling strategies:
const resilient = FuturableTask
.fetch('/api/data')
.timeout(5000)
.retry(3, 1000) // retry 3 times, 1s delay
.orElse(() => FuturableTask.fetch('/api/backup')) // fallback to backup
.fallbackTo(CACHED_DATA); // static fallback valueFine-grained control over parallel execution:
// Limit concurrent requests
const limiter = FuturableTask.createLimiter(5, {
onActive: () => console.log('Task started'),
onIdle: () => console.log('All done')
});
const tasks = urls.map(url =>
limiter(FuturableTask.fetch(url))
);
// Only 5 run at once
const results = await FuturableTask.parallel(tasks).run();Automatic debouncing for user input:
const searchTask = FuturableTask
.of(async (utils) => {
const res = await utils.fetch('/api/search?q=' + currentQuery);
return res.json();
})
.debounce(300);
// Rapid calls β only the last one executes after 300ms
searchTask.run(); // cancelled
searchTask.run(); // cancelled
searchTask.run(); // executes after 300msCache expensive operations:
const loadConfig = FuturableTask
.fetch('/api/config')
.map(res => res.json())
.memoize();
const config1 = await loadConfig.run(); // Fetches
const config2 = await loadConfig.run(); // Cached
const config3 = await loadConfig.run(); // CachedCreate a lazy task from any function β sync or async β with automatic error capture:
// Sync function that may throw β error captured lazily on run()
const parseTask = FuturableTask.try(() => JSON.parse(rawInput));
// Compose freely before executing
const result = await parseTask
.map(data => data.value)
.retry(2)
.runSafe();
if (result.success) {
console.log('Parsed value:', result.data);
} else {
console.error('Failed:', result.error);
}import { useEffect, useState } from 'react';
import { Futurable } from '@ndriadev/futurable';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
const request = Futurable
.fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
// Only called for genuine errors, not cancellation
setError(err);
setLoading(false);
})
.onCancel(() => {
// Called when the component unmounts or userId changes
setLoading(false);
});
// Cleanup: cancel on unmount or userId change
return () => request.cancel();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}class APIClient {
private baseURL = 'https://api.example.com';
fetchUser = (id: number) =>
FuturableTask
.fetch(`${this.baseURL}/users/${id}`)
.map(res => res.json())
.retry(3, 500)
.timeout(5000)
.memoize();
searchUsers = (query: string) =>
FuturableTask
.fetch(`${this.baseURL}/users/search?q=${query}`)
.map(res => res.json())
.timeout(10000);
async getUser(id: number) {
return this.fetchUser(id).run();
}
async search(query: string) {
return this.searchUsers(query).run();
}
}const processData = FuturableTask
.fetch('/api/raw-data')
.map(res => res.json())
.tap(data => console.log(`Received ${data.length} items`))
.map(data => data.filter(item => item.active))
.map(data => data.map(item => ({
...item,
processed: true,
timestamp: Date.now()
})))
.flatMap(data =>
FuturableTask.traverse(
data,
item => FuturableTask.of(() => enrichItem(item))
)
)
.tap(results => console.log(`Processed ${results.length} items`))
.retry(2, 1000)
.timeout(30000)
.catchError(error => {
console.error('Pipeline failed:', error);
return FuturableTask.resolve([]);
});
const results = await processData.run();async function processLargeDataset(items: Item[]) {
const limiter = FuturableTask.createLimiter(10, {
onActive: () => console.log(`Active: ${limiter.activeCount}/10`),
onCompleted: (result) => updateProgress(result),
onIdle: () => console.log('Batch complete')
});
const batches = chunk(items, 50);
const results = await FuturableTask.sequence(
batches.map(batch =>
FuturableTask.parallel(
batch.map(item =>
limiter(
FuturableTask
.of(() => processItem(item))
.retry(3, 500)
.timeout(5000)
)
)
)
)
).run();
return results.flat();
}Start simple, add complexity only when needed:
// Simple
const data = await Futurable.fetch('/api/data')
.then(res => res.json());
// Add cancellation
const request = Futurable.fetch('/api/data')
.then(res => res.json());
request.cancel();
// Add retry and timeout
const resilient = FuturableTask
.fetch('/api/data')
.map(res => res.json())
.retry(3, 500)
.timeout(5000);Full TypeScript support with inference:
const result = await FuturableTask
.of(() => 42) // FuturableTask<number>
.map(x => x.toString()) // FuturableTask<string>
.map(s => s.length) // FuturableTask<number>
.run(); // Promise<number>No external dependencies. Small bundle size. Tree-shakeable.
Futurable is a Promise. Works with async/await, Promise.all(), and any Promise-based API.
npm install @ndriadev/futurableyarn add @ndriadev/futurablepnpm add @ndriadev/futurableimport { Futurable } from '@ndriadev/futurable';
// Cancellable fetch
const request = Futurable.fetch('/api/data')
.then(res => res.json());
request.cancel(); // Cancel if neededimport { FuturableTask } from '@ndriadev/futurable';
// Define work
const task = FuturableTask
.of(() => fetch('/api/data'))
.map(res => res.json())
.retry(3, 500);
// Execute when ready
const data = await task.run();| Feature | Description |
|---|---|
| β Cancellation | Cancel operations and cleanup resources via onCancel() |
| β Promise Compatible | Drop-in Promise replacement |
| β Built-in Fetch | Cancellable HTTP requests |
| β Delays & Sleep | Timing utilities |
| β Polling | Repeated execution with cancellation control |
| β Safe Mode | Error handling without try-catch via .safe() |
| β try() | Unified sync/async entry point with error capture |
| β Full TypeScript | Complete type safety |
| Feature | Description |
|---|---|
| β Lazy Evaluation | Define once, execute when needed |
| β Reusability | Run the same task multiple times independently |
| β Functional Composition | map, flatMap, tap, fold, zip, and more |
| β Retry Logic | Configurable retries with optional fixed delay |
| β Timeout Protection | Automatic timeouts with custom reason |
| β Error Recovery | catchError, orElse, fallbackTo |
| β Concurrency Control | Rate limiting and parallelism |
| β Debouncing & Throttling | Built-in debounce and throttle |
| β Memoization | Cache expensive operations |
| β try() | Lazy sync/async entry point with automatic error capture |
| β Full TypeScript | Complete type inference |
- SPA Applications: Cancel API calls on navigation
- React/Vue/Angular: Component cleanup and effects
- Real-time Features: Polling with cancellation
- Data Processing: Complex async pipelines
- API Clients: Reusable, composable requests
- Rate Limiting: Control concurrent operations
- Form Handling: Debounced search and auto-save
- Resource Management: Proper async cleanup
- β All modern browsers (Chrome, Firefox, Safari, Edge)
- β Node.js 14+
- β TypeScript 4.5+
- β ES2015+ (ES6+)
Futurable draws inspiration from:
- Promises/A+ specification
- RxJS observables and operators
- Fluture and functional programming patterns
- Real-world production challenges in modern web apps
- Documentation: futurable.ndria.dev
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Email: info@ndria.dev
If you find Futurable useful, please consider giving it a β on GitHub!