Appearance
ComputedOptions: Fine-Tuning Compute Methods
This document covers the advanced configuration options available for compute methods via the [ComputeMethod] attribute and ComputedOptions.
Overview
Every compute method uses ComputedOptions to control:
- How long computed values stay in memory
- When and how they auto-invalidate
- How invalidation timing works
- How multiple concurrent updates are consolidated
You configure these options via the [ComputeMethod] attribute:
cs
[ComputeMethod(MinCacheDuration = 10, AutoInvalidationDelay = 60)]
Task<UserProfile> GetProfile(string userId);How Values Are Interpreted
All [ComputeMethod] properties are double values representing seconds. They use special values to express "use default" and "infinite/disabled":
double value | Meaning |
|---|---|
double.NaN | "Use default" — inherits from ComputedOptions.Default |
double.PositiveInfinity | "Infinite" / disabled — translates to TimeSpan.MaxValue |
>= 0 | Actual duration in seconds |
< 0 | Invalid — throws ArgumentOutOfRangeException |
All attribute properties default to double.NaN, so omitting an option means "use the global default":
cs
// These are equivalent:
[ComputeMethod(MinCacheDuration = double.NaN)]
Task<Data> GetData1();
[ComputeMethod] // MinCacheDuration not specified = use default
Task<Data> GetData2();
// Explicitly disable auto-invalidation:
[ComputeMethod(AutoInvalidationDelay = double.PositiveInfinity)]
Task<Data> GetData3();Option Reference
MinCacheDuration
Type: double (seconds) Default: 0 (no minimum)
Minimum time a Computed<T> instance stays in RAM via a strong reference.
cs
[ComputeMethod(MinCacheDuration = 60)] // Keep in memory for at least 60 seconds
Task<User> Get(string id);How it works:
- When a computed value is created, Fusion holds a strong reference to it for this duration
- Without this, computed values may be garbage-collected as soon as no code references them
- Invalidation trims this time — there's no reason to cache an outdated value
When to use:
- For frequently accessed data that's expensive to compute
- For data that's shared across many UI components
- Authentication/session data that shouldn't be recomputed on every access
TransientErrorInvalidationDelay
Type: double (seconds) Default: 1
Auto-invalidation delay for computed values that store a transient error (e.g., network failures).
cs
[ComputeMethod(TransientErrorInvalidationDelay = 5)] // Retry after 5 seconds
Task<Data> FetchFromExternalApi();How it works:
- If a compute method throws a transient exception, the error is cached
- After this delay, the computed value auto-invalidates, triggering a retry
- Helps recover from temporary failures without manual intervention
When to use:
- External API calls that may fail temporarily
- Database operations that might hit connection limits
- Any operation where retry after a delay makes sense
NonTransientErrorInvalidationDelay
Type: double (seconds) Default: 30
Auto-invalidation delay for computed values that store a non-transient error — one your TransiencyResolver did not classify as transient.
cs
[ComputeMethod(NonTransientErrorInvalidationDelay = 300)] // Clear a stuck error after 5 minutes
Task<Report> BuildReport(string id);How it works:
- A non-transient error is otherwise cached until something invalidates it.
- The effective delay for
NonTransient/Unknownerrors ismin(AutoInvalidationDelay, NonTransientErrorInvalidationDelay)— so a service that lowersAutoInvalidationDelayto cap the value's lifetime also caps its error lifetime, while this 30-second default keeps a non-deterministic error that was classified non-transient (e.g. a race-inducedNullReferenceException) from staying cached indefinitely. Terminalerrors useAutoInvalidationDelayalone (ignoring this delay), since re-executing them can never help — retrying aTerminalerror is pointless.- Set it to
double.PositiveInfinityto keep non-transient errors cached until an explicit invalidation (or untilAutoInvalidationDelay, if set). - MutableState is an exception:
ComputedOptions.MutableStateDefaultoverrides both this delay andTransientErrorInvalidationDelaytoTimeSpan.MaxValue, so a value (or error) you set on aMutableStatestays put until you explicitly change it — the error horizon doesn't apply.
When to use:
- Rarely needs changing — the 30-second default is a safety net for errors that are classified non-transient but are actually non-deterministic.
- Raise it for genuinely permanent errors you want cached longer; lower it to retry sooner.
AutoInvalidationDelay
Type: double (seconds) Default: TimeSpan.MaxValue (no auto-invalidation)
Time after which a computed value automatically invalidates itself.
cs
[ComputeMethod(AutoInvalidationDelay = 30)] // Auto-refresh every 30 seconds
Task<DateTime> GetServerTime();How it works:
- The computed value schedules its own invalidation after this delay
- Useful for data that naturally becomes stale over time
- Works even if no external invalidation occurs
When to use:
- Clock/time-based data
- Polling external systems where push notifications aren't available
- Data with known expiration (e.g., cached tokens)
- Rate-limited refresh of volatile data
InvalidationDelay
Type: double (seconds) Default: 0 (immediate)
Delay before invalidation actually takes effect.
cs
[ComputeMethod(InvalidationDelay = 0.5)] // Debounce invalidations by 500ms
Task<Summary> GetSummary();How it works:
- When
Invalidate()is called, the actual invalidation is postponed - Multiple rapid invalidations during this period are coalesced into one
- Reduces recomputation storms during batch updates
When to use:
- Aggregate computations that depend on many rapidly-changing values
- Debouncing UI updates during batch operations
- Reducing server load during bulk data imports
ConsolidationDelay
Type: double (seconds) Default: -1 (no consolidation)
Eliminates "false" invalidations by only invalidating when the computed value actually changes.
cs
[ComputeMethod(ConsolidationDelay = 0)] // Invalidate only when value changes
Task<int> GetUnreadCount(string placeId);
[ComputeMethod(ConsolidationDelay = 0.5)] // Wait 500ms before checking for value changes
Task<Summary> GetSummary();How it works: When ConsolidationDelay is zero or positive, Fusion backs the method with two computed instances:
- Consolidation target — the instance everyone sees from the outside
- Consolidation source — the internal instance used to detect actual value changes
The consolidation target doesn't have any dependencies itself, but listens to consolidation source invalidations. When an invalidation occurs on the consolidation source:
- The target waits for the consolidation delay (can be zero)
- Then recomputes the consolidation source
- If the new value differs from the target's current value, the target invalidates itself
- If the value is the same, the invalidation is "swallowed" and the target continues listening to the new source
Why this matters: Without this option, every ground truth invalidation in Fusion spreads through the entire dependency graph, hitting every dependent computed. It doesn't matter if some invalidated values recompute to exactly the same result — if your dependency is invalidated, you get invalidated too.
Example — Unread counters: A single unread counter may depend on hundreds of API calls (e.g., a place counter depends on every chat counter, which depend on read/write positions). Without consolidation, a single post in any chat would invalidate the chat counter (even though it likely produces the same value), then the place counter, then the total — forcing every UI element showing these counters to refresh.
With ConsolidationDelay, the counter only invalidates when its value actually changes.
When to use:
- Counter-like aggregations (unread counts, totals, statistics)
- Any computed that frequently recomputes to the same value
- Reducing invalidation cascades in deep dependency graphs
- Preventing unnecessary UI refreshes
Restrictions — Distributed services:
| Service mode | ConsolidationDelay |
|---|---|
Local | Allowed — the method is computed locally, so it consolidates |
Server | Allowed — the method is always computed locally, and remote clients get fewer invalidations as a result |
Client | Allowed, but inert — the client only consumes the value, and the producer already consolidated it |
ServerAndClient | Allowed — see the two rows above |
Distributed | Rejected at registration time |
Distributed services are served by RemoteComputeMethodFunction even when the routing resolves to the local peer, and it always produces a plain ComputeMethodComputed — so the consolidation would be silently ignored on the shard owner too. It also can't just be "made to work": consolidation recomputes its source through a local ComputeMethodFunction (bypassing RpcMethodDef.RouteCall entirely), and its shape is fixed when the method def is built, while the routing is per-call and may flip when a shard moves.
Use a non-RPC-visible (e.g. protected virtual) compute method instead, and derive the RPC-exposed one from it:
cs
public class Presences : IPresencesBackend
{
public virtual Task<Moment> GetLastCheckIn(UserId userId, CancellationToken cancellationToken = default)
=> GetConsolidatedLastCheckIn(userId, cancellationToken);
[ComputeMethod(ConsolidationDelay = 0.5)]
protected virtual Task<Moment> GetConsolidatedLastCheckIn(UserId userId, CancellationToken cancellationToken = default)
=> ...;
}Invalidating a consolidating method:
Recall that one consolidating method is backed by two method defs and two computeds. Only the outer one is reachable: calling the method or capturing its computed always gives you the consolidation target, a ConsolidatingComputed<T>. The inner consolidation source def is synthesized from the same MethodInfo, so there is no separate method to call and no way to name its computed — yet that inner computed is the one holding the dependencies, and therefore the one that has to be invalidated for the recompute-and-compare to happen at all.
Method-based invalidation handles this for you. ConsolidatingComputed<T> implements IHasInvalidationTarget, pointing at its source, and an Invalidation.Begin() block honours that — so invalidating the outer method invalidates the inner computed, and the comparison runs as intended:
cs
using (Invalidation.Begin())
_ = service.GetConsolidatedLastCheckIn(userId, default); // -> the source, so the comparison happensSo invalidate the consolidating method itself. Invalidating a method that merely calls it — the RPC-exposed wrapper above, say — invalidates only that caller's computed, which then re-reads the still-consistent consolidating one and keeps serving the previous value.
ConsolidationComparer
Type: Type? (an IEqualityComparer<T> implementation) Default: null (use FusionDefaultDelegates.ComputedOutputEqualityComparer)
By default consolidation compares two consecutive outputs with Equals(x.Value, y.Value), so a result type with referential equality can never consolidate — a freshly built instance is never "the same" as the previous one. ConsolidationComparer names an IEqualityComparer<T> (where T is the method's unwrapped return type) to use instead:
cs
// Conversation has referential Equals, so consolidation would never "swallow"
// an invalidation without a custom comparer.
[ComputeMethod(ConsolidationDelay = 0.5, ConsolidationComparer = typeof(ConversationComparer))]
Task<Conversation?> GetConversation(string chatId);cs
public class Conversation(string title)
{
public string Title { get; } = title;
}
public class ConversationComparer : IEqualityComparer<Conversation>
{
public bool Equals(Conversation? x, Conversation? y)
=> x is null || y is null
? x is null && y is null
: string.Equals(x.Title, y.Title, StringComparison.Ordinal);
public int GetHashCode(Conversation obj)
=> obj.Title.GetHashCode(StringComparison.Ordinal);
}Rules:
- The comparer type must implement
IEqualityComparer<T>for the method's unwrapped return type and have a public parameterless constructor. A single instance is created per comparer type and reused. - It's consulted only when both compared outputs are values. If either one carries an error, the default (error-aware) comparison applies, so errors keep behaving exactly as before.
- It requires
ConsolidationDelayto be set — otherwise it would never be used, so that combination is rejected. - Same as
ConsolidationDelay, it can't be used onDistributedservices or with[RemoteComputeMethod].
Combining Options
Options can be combined for sophisticated caching strategies:
cs
// Long-lived cache with automatic refresh
[ComputeMethod(
MinCacheDuration = 300, // Keep in memory 5 minutes
AutoInvalidationDelay = 60)] // But refresh every minute
Task<Stats> GetDashboardStats();
// Resilient external call with debouncing
[ComputeMethod(
TransientErrorInvalidationDelay = 10, // Retry errors after 10s
InvalidationDelay = 1)] // Debounce updates by 1s
Task<Price> GetExternalPrice(string symbol);
// Aggregation that should only invalidate when value changes
[ComputeMethod(
MinCacheDuration = 60,
ConsolidationDelay = 0)] // Invalidate only on actual value change
Task<int> GetTotalUnreadCount();Default Values
Fusion provides different defaults for server-side and client-side (remote) compute services:
| Option | Server Default | Client Default |
|---|---|---|
| MinCacheDuration | 0 | 60 seconds |
| TransientErrorInvalidationDelay | 1 second | 1 second |
| NonTransientErrorInvalidationDelay | 30 seconds | 30 seconds |
| AutoInvalidationDelay | ∞ (none) | ∞ (none) |
| InvalidationDelay | 0 | 0 |
| ConsolidationDelay | -1 (none) | -1 (none) |
| ConsolidationComparer | null (default comparer) | null (default comparer) |
MutableState uses its own defaults (ComputedOptions.MutableStateDefault): both TransientErrorInvalidationDelay and NonTransientErrorInvalidationDelay are ∞, so its value or error is never auto-invalidated and stays until you change it explicitly.
You can change global defaults by modifying ComputedOptions.Default and ComputedOptions.ClientDefault at startup:
cs
ComputedOptions.Default = ComputedOptions.Default with {
MinCacheDuration = TimeSpan.FromSeconds(30),
};Remote Compute Methods
For distributed scenarios, use [RemoteComputeMethod] which extends [ComputeMethod] with caching options:
cs
[RemoteComputeMethod(CacheMode = RemoteComputedCacheMode.Cache)]
Task<Product> Get(string id);RemoteComputedCacheMode values:
Default— inherit fromComputedOptions.ClientDefaultCache— enable client-side caching of remote resultsNoCache— disable caching, always fetch from serverReturnDefault— behave likeCache, but with a cache that always "contains"default(T): the first call returnsdefault(T)immediately and the real value displaces it once the server answers; while the peer is disconnected, an invalidated value is re-served asdefault(T)rather than parked until reconnect. Nothing is ever read from or written toIRemoteComputedCache, so the mode works without one registered. Use it for methods whose stale value would mislead (a live session that ended while offline, who is typing).
Tips
- Assume defaults are fine — make changes once you know what's going on; you can also change
ComputedOptions.Defaultglobally - Consider memory —
MinCacheDurationtrades memory for CPU; balance accordingly - Mind the invalidation chain — delayed invalidation affects all dependent computed values
- Test consolidation carefully — too long a delay can show stale data; too short defeats the purpose
