Skip to content

Event count service ​

createCountService loads per-event totals without downloading the matching events. It shares observations, caches answers, and balances NIP-45 COUNT requests across trusted relays with adaptive concurrency and rate-limit recovery.

Each count comes from one relay. Relays can hold different datasets, so an answer is that relay's count, not a network-wide total. The service never sums answers. Use Reactions Loader or Zaps Loader when you need the matching events instead.

How to use it ​

Create one service at application scope with your existing RelayPool:

ts
import { createCountService } from "applesauce-loaders/loaders";

const counts = createCountService(pool, {
  relays: ["wss://relay.ditto.pub", "wss://relay.primal.net"],
  concurrency: 40,
});
const subscription = counts.event(event).reactions$.subscribe(renderCount);

subscription.unsubscribe(); // When the view closes.
counts.stop(); // When the application no longer needs the service.

The pool must expose relay(url).count(...), as applesauce-relay's RelayPool does. Generic event-request adapters used by other loaders do not automatically support this interface.

Handles are lazy: constructing one sends nothing. Subscribing starts work; observers of the same target and metric ID share it. Unsubscribing the last observer cancels that metric's queued request, active attempt, and refresh timer. stop() cancels the whole service without closing the pool, and future subscriptions complete without loading.

Values and loading state ​

reactions$ and comments$ emit number | undefined. Zero is a valid answer; undefined means no answer is available. The chainable reactions and comments properties expose full state:

ts
const note = counts.event(event);
const subscription = note.reactions.subscribe((state) => {
  renderCount(state.value);
  renderSpinner(state.refreshing);
});
State fieldMeaning
valueCount, or undefined until an answer is available
freshWhether the retained answer is within its freshness period
refreshingWhether the metric is waiting for capacity or a new answer
origin{ relay } identifying the answering relay, or undefined
approximateThe relay's optional approximation flag
observedAtTime the answer was received, in milliseconds
failureLocal metric or scheduler error; retried relay errors appear in diagnostics

Keep stale values visible while refreshing is true. Relay failures keep the request pending until a relay answers or the observer unsubscribes. Empty relay lists also remain pending, allowing a reactive configuration to supply relays later.

Built-in and custom metrics ​

DescriptorHandle propertyMeaning
reactionCountMetricreactionsKind-7 reactions tagged with the target's e reference
rootCommentCountMetriccommentsEntire NIP-22 comment tree rooted at the target, using E or A
legacyReferenceCountMetricmetric(legacyReferenceCountMetric)Kind-1 references, including indirect thread references

comments means NIP-22 comments, and legacy references are broader than direct replies. Define other event counts with a stable versioned ID and filters:

ts
const quotes = counts.event(event).metric({
  id: "quotes:v1",
  filters: (target) => [{ kinds: [1], "#q": [target.id] }],
});
const subscription = quotes.subscribe(renderState);

The metric ID is part of the sharing and cache key. Use the same ID only for the same filter semantics; increment its version when those semantics change. Filters must describe one target's total, rather than several targets whose separate counts you need.

Return undefined for unsupported targets. Empty or throwing filters settle with failure and allocate no retry timer. Correct the metric and invalidate the target, or release all observers and subscribe again, to retry.

Targets accept ordinary events with id, kind, and created_at. Supply an explicit address to count a stable address across revisions. Built-in reactions and legacy references only support event IDs; root comments support either identity.

Relay configuration and retries ​

relays accepts a static array or an observable array. URLs are normalized and deduplicated; reordering the same set preserves work and cached results. Changing the set cancels obsolete requests and discards answers accepted under the previous set. Invalid WebSocket URLs are ignored; a configuration error clears the effective set until the service is recreated.

The scheduler chooses one available relay per metric, favoring lower estimated outstanding work. Each relay starts with at most two active attempts and adapts toward concurrency as responses arrive. Structured rate limits and transport failures reduce admission and apply cooldowns. Refusals, authentication failures, and malformed responses allow other relays to answer. Arbitrary NOTICE text is not treated as a rate-limit instruction.

attemptTimeout bounds each dispatched attempt, including connection and authentication waits. There is no overall request deadline or queue-capacity rejection: observed metrics remain requested until answered or cancelled. Limit subscriptions to visible views to bound application demand.

For a caller-specific deadline, skip the initial missing value:

ts
import { filter, firstValueFrom, timeout } from "rxjs";

const value = await firstValueFrom(
  counts.event(event).reactions$.pipe(
    filter((value) => value !== undefined),
    timeout({ first: 10_000 }),
  ),
);

Caching, freshness, and invalidation ​

In-memory caching is always available. cacheCapacity bounds inactive entries; active metrics stay shared and pinned. Set it to 0 to discard inactive results. Optional persistent caching is configured separately below.

Target ageDefault freshness
Under 6 hours1 minute
6 hours to under 1 day5 minutes
1 day to under 7 days30 minutes
7 days to under 30 days2 hours
30 days and older6 hours

Only observed metrics refresh. Override freshness with ttl: ({ age, query }) => milliseconds; age is seconds. Finite intervals respect minimumRefresh; Infinity disables automatic successful refreshes. Counts are snapshots: use event subscriptions alongside them when you need live event details.

await counts.invalidate(event) clears one target and restarts observed metrics; await counts.invalidate() does the same for every target. Both clear memory immediately and resolve after persistent invalidation completes, not after replacement relay answers arrive. Call invalidation after a relevant publish when you want to refresh a displayed count; the remote relay still needs to have received the event.

Options ​

OptionDefaultPurpose
relaysRequiredStatic or observable trusted relay set
concurrency20Maximum participating attempts per relay
attemptTimeout5000 msDeadline per dispatched attempt
countSchedulerService-ownedScheduler shared by several services
ttlEvent-age curve aboveFreshness milliseconds for { age, query }
minimumRefresh1000 msMinimum finite refresh interval
cacheCapacity1000Maximum inactive in-memory entries
cacheNoneKeyv-compatible persistent store
cacheRetention24 hoursPersistent retention, independent of freshness
cacheTimeout1000 msDeadline per persistent cache operation
signalNoneAbort signal that stops the service
diagnosticNoneCallback for current activity and attempt outcomes
loggerCore loggerParent namespace for service and relay logs
scheduler, randomRxJS async scheduler, Math.randomInjectable clock and cooldown jitter source

Attempt and cache-operation timeouts must be positive and at most 2,147,483,647 ms. Concurrency limits cover participating COUNT work, not unrelated traffic on the pool.

Integration ​

React ​

Share the service outside components and let hooks own subscriptions. Defer subscription past use$'s synchronous render probe so abandoned renders do not start relay work:

tsx
import { asapScheduler, subscribeOn } from "rxjs";

const state = use$(() => counts.event(event).reactions.pipe(subscribeOn(asapScheduler)), [event]);

Virtualized rows naturally release offscreen demand. The event counts example demonstrates this with a persistence toggle and cache statistics.

Persistent counts with Keyv ​

Use a dedicated store or Keyv namespace with asynchronous get and set methods. For keyv-browser, use a dedicated database: its adapter creates the object store only when the database is first created.

ts
import Keyv from "keyv";
import { KeyvIndexedDB } from "keyv-browser";

const cache = new Keyv({
  store: new KeyvIndexedDB({ dbName: "my-client-counts", storeName: "counts" }),
});
const counts = createCountService(pool, {
  relays: trustedRelays$,
  cache,
  ttl: () => 5 * 60_000,
  cacheRetention: 24 * 60 * 60_000,
});

Only successful counts persist. Matching target, metric ID, and relay set are required for reuse. Fresh restored answers skip relay requests; stale answers remain visible during refresh. Hydration preserves original freshness and retention deadlines. Infinity disables the corresponding expiry.

Cache errors fall back to network loading. Invalidation rejects if its storage update fails. Invalidation markers have no expiry; plan storage cleanup for long-lived applications, particularly with unlimited retention. Stopping the service leaves saved data intact, and writes already started may finish asynchronously.

Shared scheduler and diagnostics ​

One service already shares capacity across its observers. Supply a shared scheduler only when several services should share relay limits and feedback:

ts
import { createCountScheduler } from "applesauce-loaders/loaders";

const scheduler = createCountScheduler({ concurrency: 40 });
const feed = createCountService(pool, { relays, countScheduler: scheduler });
const details = createCountService(pool, { relays, countScheduler: scheduler });

feed.stop();
details.stop();
scheduler.stop();

Configure concurrency on the shared scheduler, and let its owner stop it after its services. Capacity is shared by normalized relay URL, including across different pool objects. Each service keeps its own result cache.

scheduler.status$ reports active attempts, outstanding requests, and per-relay limits, latency, cooldowns, and failures. A service's diagnostic callback includes these snapshots on scheduler notifications. With a shared scheduler, activity covers all its users; cacheEntries and activeMetrics remain service-local.

Enable cache, refresh, and attempt logs through the core logger, or pass your own parent logger:

ts
import { enableLoggerNamespaces } from "applesauce-core";

enableLoggerNamespaces("applesauce:count-service*");

Best Practices ​

  • Use trusted relays and retain relay provenance when presenting totals.
  • Share a service at application scope and release subscriptions when views close.
  • Keep zero distinct from missing values; retain stale answers during refresh.
  • Version metric IDs when filter semantics change.
  • Use COUNT for event totals, not zap amounts or other numeric tag aggregates.
  • Tune maximum concurrency against your relay workload; a higher limit does not guarantee faster answers.