Skip to content

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 valueMeaning
double.NaN"Use default" — inherits from ComputedOptions.Default
double.PositiveInfinity"Infinite" / disabled — translates to TimeSpan.MaxValue
>= 0Actual duration in seconds
< 0Invalid — 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/Unknown errors is min(AutoInvalidationDelay, NonTransientErrorInvalidationDelay) — so a service that lowers AutoInvalidationDelay to 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-induced NullReferenceException) from staying cached indefinitely.
  • Terminal errors use AutoInvalidationDelay alone (ignoring this delay), since re-executing them can never help — retrying a Terminal error is pointless.
  • Set it to double.PositiveInfinity to keep non-transient errors cached until an explicit invalidation (or until AutoInvalidationDelay, if set).
  • MutableState is an exception: ComputedOptions.MutableStateDefault overrides both this delay and TransientErrorInvalidationDelay to TimeSpan.MaxValue, so a value (or error) you set on a MutableState stays 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:

  1. The target waits for the consolidation delay (can be zero)
  2. Then recomputes the consolidation source
  3. If the new value differs from the target's current value, the target invalidates itself
  4. 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 modeConsolidationDelay
LocalAllowed — the method is computed locally, so it consolidates
ServerAllowed — the method is always computed locally, and remote clients get fewer invalidations as a result
ClientAllowed, but inert — the client only consumes the value, and the producer already consolidated it
ServerAndClientAllowed — see the two rows above
DistributedRejected 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 happens

So 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 ConsolidationDelay to be set — otherwise it would never be used, so that combination is rejected.
  • Same as ConsolidationDelay, it can't be used on Distributed services 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:

OptionServer DefaultClient Default
MinCacheDuration060 seconds
TransientErrorInvalidationDelay1 second1 second
NonTransientErrorInvalidationDelay30 seconds30 seconds
AutoInvalidationDelay∞ (none)∞ (none)
InvalidationDelay00
ConsolidationDelay-1 (none)-1 (none)
ConsolidationComparernull (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 from ComputedOptions.ClientDefault
  • Cache — enable client-side caching of remote results
  • NoCache — disable caching, always fetch from server
  • ReturnDefault — behave like Cache, but with a cache that always "contains" default(T): the first call returns default(T) immediately and the real value displaces it once the server answers; while the peer is disconnected, an invalidated value is re-served as default(T) rather than parked until reconnect. Nothing is ever read from or written to IRemoteComputedCache, 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

  1. Assume defaults are fine — make changes once you know what's going on; you can also change ComputedOptions.Default globally
  2. Consider memoryMinCacheDuration trades memory for CPU; balance accordingly
  3. Mind the invalidation chain — delayed invalidation affects all dependent computed values
  4. Test consolidation carefully — too long a delay can show stale data; too short defeats the purpose