Every caching library makes tradeoffs. Some optimize for bundle size. Others optimize for developer experience. I wanted to explore what happens when you optimize for a different axis: runtime comprehensiveness with zero external dependencies.
This article walks through the engineering decisions behind SoulCache, a TypeScript data fetching runtime that takes a different approach to caching, scheduling, and extensibility.
Why I Built SoulCache
The data fetching problem looks simple on the surface. Fetch data, cache it, update the UI. But real applications accumulate complexity that standard solutions don't address well.
I was building an application with dozens of concurrent API calls. Some were critical — authentication, payment processing. Others were background work — analytics, prefetching. The network was saturated, and I couldn't prioritize which requests went first.
I also needed to extend caching behavior. Not just cache invalidation, but custom logic that ran before and after queries. The existing plugin systems were either too simple — middleware — or too tightly coupled to specific frameworks.
Finally, my cache was growing unbounded. There was no eviction strategy. Memory usage climbed until the browser tab crashed.
These problems aren't unique to my application. They show up in any project with complex data dependencies. SoulCache was built to address them in a single runtime.
What Is SoulCache?
SoulCache is a framework-agnostic TypeScript runtime for data fetching and server-state management. It handles request deduplication, background refetching, retry logic, cache invalidation, and SSR hydration.
The project is split into four packages:
-
@soulcache/core— the framework-agnostic runtime with zero runtime dependencies -
@soulcache/react— React adapter usinguseSyncExternalStore -
@soulcache/devtools-core— framework-agnostic inspection layer -
@soulcache/devtools— React DevTools panel
The core package contains the entire runtime. Cache engine, query engine, scheduler, retry engine, storage layer, plugin system, and event bus — everything built from scratch.
This is a deliberate tradeoff. The bundle includes more code than minimal solutions, but you get complete control over behavior with no transitive dependency surprises.
Architecture Overview
The architecture follows a layered design with clear component boundaries. The application talks to the React adapter — or directly to the core — which delegates to the QueryEngine, which wraps the QueryClient and coordinates multiple independent subsystems.
The QueryEngine is the outermost orchestration layer. It owns the QueryClient and RetryEngine, handles background refetching, and manages abort controllers for in-flight requests.
The QueryClient sits inside the QueryEngine. It owns the CacheEngine, Scheduler, MutationCache, and EventBus. It coordinates lifecycle transitions but delegates storage to the cache engine, scheduling to the scheduler, and event propagation to the event bus.
Each subsystem is independent. The CacheEngine doesn't know about the Scheduler. The RetryEngine doesn't know about the EventBus. This isolation matters for testing, debugging, and reasoning about behavior. When something goes wrong, you can trace the issue to a specific subsystem without understanding the entire codebase.
Core Runtime Components
QueryClient
The QueryClient is the central coordination point. It manages cache entries, handles mutations, emits events, and manages subscriptions.
import { QueryClient } from '@soulcache/core';
const client = new QueryClient();
const users = await client.fetchQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
const unsubscribe = client.subscribe(['users'], (snapshot) => {
console.log(snapshot.data, snapshot.status);
});
The QueryClient exposes a minimal API surface. Most complexity lives in the subsystems, which keeps the public API predictable.
Query State Machine
Every query goes through a formal state machine with 8 states: idle, pending, fetching, success, error, stale, invalidated, and destroyed.
Transitions between states are validated. If you try to move a query from idle to success directly, the runtime throws a typed error.
Why a formal state machine? Because ad-hoc state management produces bugs that are hard to reproduce. A query stuck in pending forever, a query that goes from error to success without fetching, a query that never transitions to stale — these are real bugs that formal state validation catches at the source.
Priority Scheduler
The scheduler manages task execution with 5 priority levels: critical, high, normal, low, and idle.
Real applications have different urgency levels for data fetching. Authentication is more urgent than analytics. Payment processing is more urgent than prefetching. Without priority scheduling, a low-priority request might block a critical one.
The scheduler batches observer notifications to avoid redundant re-renders. If multiple queries resolve simultaneously, observers are notified once with all updates — not once per query.
Tasks can declare dependencies on other tasks. The scheduler detects circular dependencies before execution and throws an error instead of entering an infinite loop.
Cache Engine with LRU Eviction
The cache engine manages entries with O(1) lookups using a hash-based key resolution system.
When the cache reaches its maximum size, entries are evicted using an LRU score that considers three factors: recent access time, access frequency, and whether the entry has active subscribers.
Entries with active observers get a significant scoring bonus. This prevents evicting data that's currently being displayed to the user.
Why not just use recency? Because a frequently-accessed entry that hasn't been accessed in 5 minutes might still be more valuable than a one-time entry accessed 3 minutes ago. The scoring formula balances these concerns.
Cache Dependency Graph
Cache entries can declare dependencies on other entries. When an entry is invalidated, the invalidation cascades through the dependency graph with a built-in depth limit.
Why a dependency graph? Because cache invalidation in real applications isn't linear. If you update a user profile, you might need to invalidate the user list, the user's posts, the user's comments, and any derived data that depends on those. Manual invalidation is error-prone. Automatic propagation through a dependency graph is more reliable.
The depth limit prevents infinite cascading in complex dependency graphs. In practice, dependency chains rarely exceed 4–5 levels.
Error Classification
Not all errors should be retried the same way. A network timeout is different from a 403 Forbidden.
SoulCache classifies errors into 6 classes: network, timeout, server, client, abort, and unknown. Each class maps to a default retry behavior — retry with backoff for transient errors, don't retry for permanent failures.
The retry engine supports exponential, linear, and constant backoff strategies with optional jitter. Retry behavior is configurable per error class and per query.
Why classify errors? Because retrying a 403 Forbidden wastes resources. Retrying a network timeout is often successful. Automatic classification reduces the amount of retry configuration developers need to write.
Plugin System
SoulCache has a plugin system with 12 lifecycle hooks covering initialization, queries, mutations, cache updates, and application lifecycle events.
Plugins have dependency validation. If plugin A requires plugin B, the runtime checks that B is registered before A initializes.
Plugins have error isolation. If a plugin throws an error during query execution, it doesn't crash the query or other plugins. The error is logged and execution continues.
Why a plugin system? Because caching behavior varies between applications. Some need custom logging. Some need metrics collection. Some need custom invalidation logic. A plugin system lets developers extend behavior without forking the library.
Storage Layer
The storage layer handles persistence, schema migrations, and corruption recovery. It includes a persistence coordinator, a migration manager, a restore manager, and a serializer with checksum validation.
The storage adapter interface is pluggable. The default MemoryAdapter stores everything in memory. Custom adapters can implement the same interface for IndexedDB, LocalStorage, or other backends.
Why checksums? Because cached data can become corrupted. A checksum lets you detect corruption and fall back to a fresh fetch instead of serving stale or broken data.
Design Philosophy
SoulCache is designed around responsibilities rather than features.
Each runtime component — the cache engine, the scheduler, the retry engine, the plugin system — has a single, well-defined responsibility. The QueryClient coordinates these independent systems rather than implementing everything itself.
This design has practical consequences:
- Maintainability. Changing the retry strategy doesn't require understanding the cache engine. Modifying eviction logic doesn't affect the scheduler.
- Extensibility. Plugins can hook into specific subsystems without touching others. The plugin API exposes lifecycle hooks at well-defined boundaries.
-
Testing. Each subsystem can be tested in isolation. The
CacheEnginedoesn't need a scheduler to run. TheRetryEnginedoesn't need a cache to test backoff logic. - Debugging. When something goes wrong, the error can be traced to a specific subsystem. The event bus logs which component emitted which event.
This isn't always the right choice. Some libraries optimize for simplicity by implementing everything in a single, tightly-coupled module. That works well for small libraries. SoulCache chose the opposite tradeoff.
React Integration
The React adapter is a thin bridge between the core runtime and React's rendering model. It uses useSyncExternalStore for React 18+ compatibility.
import { SoulCacheProvider, useQuery } from '@soulcache/react';
import { QueryClient } from '@soulcache/core';
const queryClient = new QueryClient();
function App() {
return (
<SoulCacheProvider client={queryClient}>
<UserList />
</SoulCacheProvider>
);
}
function UserList() {
const { data, status, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
if (status === 'loading') return <p>Loading...</p>;
if (status === 'error') return <p>Error: {error.message}</p>;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
The adapter provides hooks for queries, mutations, infinite queries, prefetching, and subscription status. The React layer adds minimal overhead — it's primarily a reactivity bridge.
The core package's SubscriptionManager is specifically designed for useSyncExternalStore compatibility. It provides the bare-notification pattern required by React's external store API.
Query Lifecycle
Here's what happens when you call useQuery:
Runtime Trade-offs
Every design choice has a cost.
Bundle size. SoulCache is larger than minimal solutions because it includes a scheduler, plugin system, storage layer, and other features that smaller libraries don't have. If you need a simple fetch-and-cache solution, SoulCache is overkill.
Complexity. The formal state machine, dependency graph, and plugin system add cognitive overhead. Developers who just want useQuery might find the additional concepts unnecessary.
Maturity. SoulCache is v1.0.0. It hasn't been battle-tested in production at scale. There are likely edge cases that haven't been discovered yet.
Community. Finding help might require reading source code instead of Stack Overflow answers. The ecosystem of community plugins and extensions doesn't exist yet.
These trade-offs make SoulCache a good fit for specific use cases, not a universal replacement for existing solutions.
Current Limitations
Honesty matters more than hype.
SoulCache is v1.0.0. It hasn't been battle-tested in production at scale. There are likely edge cases that haven't been discovered yet.
The core is framework-agnostic, but only a React adapter exists. Vue, Svelte, and Angular adapters are planned but not implemented.
SoulCache doesn't have Suspense integration, streaming SSR optimization, or the ecosystem of community plugins that mature libraries have.
These are real limitations. SoulCache makes sense for specific use cases, not as a universal replacement for existing solutions.
When Should You Consider SoulCache?
Use SoulCache if:
- You need all-in-one features: priority scheduling, plugin system, storage layer, and built-in LRU eviction
- You want a cache dependency graph for automatic invalidation propagation
- You're building a complex application with many concurrent requests that need prioritization
- You want error classification with per-class retry strategies
- You're building personal projects, experiments, or learning about runtime architecture
You may prefer another library if:
- You need a small, lightweight solution for simple data fetching
- You need multi-framework support (Vue, Svelte, Angular)
- You want a large community with Stack Overflow answers and ecosystem of plugins
- You need battle-tested production reliability
- You need Suspense integration or advanced SSR features
SoulCache isn't trying to replace TanStack Query or SWR. It's offering a different set of tradeoffs for developers who need different capabilities.
Lessons Learned
Building an open-source library taught me several things.
Architecture decisions compound. The state machine, scheduler, and plugin system were designed early. Changing them later would require rewriting most of the codebase. Getting the architecture right upfront matters more than getting the API right.
Bundle size is a feature. Developers choose smaller libraries with fewer features over larger ones with more. SoulCache's size is a real adoption barrier, regardless of how many features it has.
Community is harder than code. The technical implementation is straightforward. Getting developers to trust, adopt, and contribute to a new library takes years. There are no shortcuts.
Honesty builds trust. Acknowledging limitations upfront is more effective than pretending they don't exist. Developers respect transparency.
Roadmap
Planned features — not guaranteed:
- Vue adapter
- Svelte adapter
- Streaming SSR improvements
- IndexedDB storage adapter
- LocalStorage storage adapter
- Additional DevTools features
Contributions are welcome for any of these. The contributing guide explains the development workflow.
Conclusion
SoulCache started as an exploration: what happens when you build a data fetching runtime from scratch with zero dependencies and a focus on runtime safety?
The answer is a library with capabilities that don't exist elsewhere — formal state machines, priority scheduling, cache dependency graphs, plugin systems with error isolation — but also with real limitations in bundle size, maturity, and community.
That's the nature of engineering tradeoffs. There's no universally correct answer. The right choice depends on what you're building and what you're willing to trade.
If you're working on a complex application with many concurrent requests and you need features that existing libraries don't provide, SoulCache might be worth evaluating. If you need something simple and battle-tested, the established alternatives are excellent choices.
The code is open source. The architecture is documented. The APIs are typed. If you find bugs, open an issue. If you have architectural feedback, the issue tracker is the right place. If you want to contribute, check the contributing guide.







Top comments (0)