; error: E };
async function executeSafely<T>(operation: () => Promise<T>): Promise<Result<T>> {
try {
const output = await operation();
return { status: 'success', data: output };
} catch (caught) {
return { status: 'failure', error: caught instanceof Error ? caught : new Error(String(caught)) };
}
}
**Why this choice:** Discriminated unions force consumers to handle both paths explicitly. Unlike exceptions that can bubble unpredictably, Result types make failure a first-class data shape, enabling safer composition and easier testing.
### Step 2: Implement Closure-Based Memoization with Eviction
Closures provide private state, but unbounded caches cause memory leaks. A production memoizer must track access frequency and evict stale entries.
```typescript
function createMemoizer<K, V>(maxEntries: number = 100) {
const cache = new Map<string, { value: V; lastAccess: number }>();
function generateKey(args: unknown[]): string {
return JSON.stringify(args, (_, val) => typeof val === 'object' && val !== null ? Object.keys(val).sort().reduce((acc, k) => ({ ...acc, [k]: val[k] }), {}) : val);
}
return function memoize(fn: (...args: K[]) => V) {
return function (...inputs: K[]): V {
const key = generateKey(inputs);
const cached = cache.get(key);
if (cached) {
cached.lastAccess = Date.now();
return cached.value;
}
const result = fn(...inputs);
cache.set(key, { value: result, lastAccess: Date.now() });
if (cache.size > maxEntries) {
let oldestKey = '';
let oldestTime = Infinity;
for (const [k, v] of cache.entries()) {
if (v.lastAccess < oldestTime) {
oldestTime = v.lastAccess;
oldestKey = k;
}
}
cache.delete(oldestKey);
}
return result;
};
};
}
Why this choice: Map supports arbitrary key types and maintains insertion order, unlike plain objects. The LRU eviction strategy prevents unbounded growth, and key normalization prevents cache collisions caused by object property reordering.
Step 3: Wire Proxy Reactivity with Dependency Tracking
Native Proxy objects intercept property access and mutation, enabling automatic dependency tracking without manual subscription management.
type Subscriber = () => void;
function createReactiveState<T extends Record<string, unknown>>(initial: T) {
const subscribers = new Map<string, Set<Subscriber>>();
return new Proxy(initial, {
get(target, prop) {
const currentSubscriber = getCurrentSubscriber();
if (currentSubscriber && typeof prop === 'string') {
if (!subscribers.has(prop)) subscribers.set(prop, new Set());
subscribers.get(prop)!.add(currentSubscriber);
}
return target[prop as keyof T];
},
set(target, prop, value) {
const oldValue = target[prop as keyof T];
if (oldValue !== value) {
target[prop as keyof T] = value;
const listeners = subscribers.get(prop as string);
if (listeners) listeners.forEach(fn => fn());
}
return true;
}
});
}
let activeSubscriber: Subscriber | null = null;
function getCurrentSubscriber() { return activeSubscriber; }
function trackChanges(state: any, callback: Subscriber) {
activeSubscriber = callback;
callback();
activeSubscriber = null;
}
Why this choice: Proxy intercepts property additions and deletions, which Object.defineProperty cannot. The Reflect API isn't strictly necessary here because we're not forwarding to a different receiver, but in complex inheritance chains, Reflect.get/set preserves correct this binding.
Step 4: Stream Data with Async Iterators and Backpressure
Handling high-volume data requires non-blocking consumption. Async generators yield values over time while respecting consumer processing speed.
async function* streamProcessor<T>(source: AsyncIterable<T>, transformer: (item: T) => Promise<Result<T>>) {
for await (const chunk of source) {
const outcome = await transformer(chunk);
if (outcome.status === 'success') {
yield outcome.data;
}
}
}
// Usage with backpressure control
async function consumeStream(stream: AsyncIterable<string>) {
const processor = streamProcessor(stream, async (item) => {
return executeSafely(() => Promise.resolve(item.toUpperCase()));
});
for await (const processed of processor) {
console.log('Processed:', processed);
await new Promise(res => setTimeout(res, 50)); // Simulate consumer pacing
}
}
Why this choice: for await...of natively respects backpressure by pausing the producer until the consumer finishes processing. This prevents memory bloat when producers outpace consumers, a common failure point in WebSocket or ReadableStream implementations.
Step 5: Decouple with Factory-Based Dependency Injection
Hardcoded dependencies create tight coupling and hinder testing. Factory functions inject runtime-configurable services.
interface DataPipelineConfig {
cacheLimit: number;
batchSize: number;
logger: { info: (msg: string) => void };
}
function buildPipeline(config: DataPipelineConfig) {
const memoizedTransform = createMemoizer(config.cacheLimit)((item: string) => item.trim());
return {
async process(source: AsyncIterable<string>) {
config.logger.info('Pipeline initialized');
return streamProcessor(source, async (raw) => {
const cleaned = memoizedTransform(raw);
return executeSafely(() => Promise.resolve(cleaned));
});
}
};
}
Why this choice: Constructor or factory injection makes dependencies explicit, enables mock substitution during testing, and isolates configuration from business logic. This pattern scales cleanly across microservices and frontend state managers.
Pitfall Guide
1. Microtask Starvation
Explanation: Scheduling microtasks recursively (e.g., Promise.resolve().then(() => scheduleMicrotask())) prevents the event loop from reaching macrotasks or rendering steps. The UI freezes despite no synchronous blocking.
Fix: Limit microtask depth. Use setTimeout(fn, 0) or requestAnimationFrame to yield back to the macrotask queue when processing large batches.
2. Proxy Memory Leaks
Explanation: Proxies hold references to target objects and subscriber sets. Forgetting to clear subscribers or disconnect observers in SPA navigation causes cumulative memory growth.
Fix: Implement explicit cleanup methods. Call observer.disconnect() and clear subscriber Maps when components unmount or routes change.
3. Cache Key Collisions
Explanation: JSON.stringify produces different strings for objects with identical data but different key insertion orders. This fragments the cache and defeats memoization.
Fix: Normalize keys by sorting object properties before serialization, or use a dedicated hashing library that handles structural equality.
4. Ignoring Backpressure in Streams
Explanation: Producers that emit data faster than consumers process it cause unbounded queue growth, eventually triggering heap exhaustion.
Fix: Use for await...of or Web Streams API with built-in backpressure. Implement explicit pacing (await delay()) or buffer limits when building custom async generators.
5. Mixing Error Strategies
Explanation: Combining Result/Either patterns with uncaught exceptions creates unpredictable control flow. Some failures return explicit objects; others crash the call stack.
Fix: Standardize on one strategy per module. Wrap external APIs that throw in executeSafely equivalents. Reserve exceptions for truly unrecoverable system failures.
6. Over-Proxying Primitives
Explanation: Proxy only wraps objects and functions. Attempting to proxy strings, numbers, or booleans throws a TypeError.
Fix: Validate input types before wrapping. Use wrapper objects or class instances when reactive behavior is needed for primitive-like data.
7. Observer Callback Throttling
Explanation: ResizeObserver and IntersectionObserver fire synchronously during layout calculations. Heavy callbacks inside these observers trigger layout thrashing.
Fix: Batch observer updates using requestAnimationFrame or queueMicrotask. Debounce expensive computations and avoid reading layout properties inside the callback.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Real-time UI state sync | Proxy-based reactivity + microtask batching | Deterministic updates, avoids manual subscription management | Low (native API, zero dependencies) |
| High-throughput data ingestion | Async iterators + backpressure control | Prevents heap exhaustion, aligns with ReadableStream/WebSocket models | Medium (requires stream architecture) |
| Legacy codebase migration | Result/Either pattern + factory DI | Gradual adoption, isolates failure paths, enables incremental testing | Low (type-safe wrappers, no runtime overhead) |
| Performance-critical dashboards | Observer APIs + RAF-batched callbacks | Eliminates main thread blocking, respects Core Web Vitals budgets | Low (browser-native, highly optimized) |
| Cross-service API contracts | Explicit Result unions + schema validation | Guarantees error visibility, simplifies client-side error routing | Medium (requires contract enforcement) |
Configuration Template
// runtime-config.ts
export const RuntimePolicies = {
memoization: {
maxEntries: 250,
evictionStrategy: 'lru' as const,
keyNormalization: true
},
streaming: {
backpressureEnabled: true,
consumerDelayMs: 50,
maxConcurrentStreams: 10
},
observers: {
autoDisconnectOnUnmount: true,
callbackBatching: 'raf' as const,
layoutReadProtection: true
},
errorHandling: {
strategy: 'result-union' as const,
unrecoverableThreshold: 'system' as const,
telemetryCapture: true
}
};
export type RuntimeConfig = typeof RuntimePolicies;
Quick Start Guide
- Initialize the pipeline factory: Import
buildPipeline and pass a configuration object matching RuntimePolicies.
- Wrap external APIs: Replace direct
fetch or database calls with executeSafely to enforce Result union returns.
- Attach observers: Use
IntersectionObserver or ResizeObserver with RAF-batched callbacks for visibility or layout tracking.
- Stream consumption: Replace array mapping with
for await...of loops, ensuring consumer pacing matches processing capacity.
- Validate runtime behavior: Run a memory profiler and event loop monitor to confirm microtask depth stays bounded and cache eviction triggers correctly.