User-global presence
Is this person reachable? One status per human, composed from a heartbeat and a manual override, driving notification suppression. This page.
Granit.Presence answers two orthogonal questions about who is around. The
first — the original dimension — is user-global: is this user reachable
right now, and through which channels? It blends a volatile client heartbeat
with a persisted manual override (Available, Busy, DoNotDisturb,
AppearOffline) into an effective PresenceStatus, and exposes that snapshot
through HTTP endpoints, an integration event, and an optional notification
delivery gate that suppresses push channels when the user does not want to be
paged.
The second dimension — resource-scoped rooms — answers who else is looking at this resource right now? It is the Google Docs / Notion face-pile primitive: informational, non-blocking awareness for collaborative surfaces. The rest of this page documents the user-global dimension; the room model lives on its own Resource rooms page.
The module is split across four NuGet packages so apps only pay for the moving
parts they need: the core (Granit.Presence) is in-memory and single-pod by
default, persistence is opt-in (Granit.Presence.EntityFrameworkCore), HTTP
endpoints are opt-in (Granit.Presence.Endpoints), and the bridge to the
notification fan-out engine is opt-in (Granit.Presence.Notifications).
These dimensions are independent — a surface can use either or both. They share the same FusionCache backplane, options, and diagnostics meter, so there is nothing extra to install for rooms.
User-global presence
Is this person reachable? One status per human, composed from a heartbeat and a manual override, driving notification suppression. This page.
Resource-scoped rooms
Who is in this room? A live participant list per (Kind, Id) resource —
the co-editing face-pile. Action-driven and non-blocking.
Presence shows up wherever the framework needs to make a routing decision before the user is informed of something:
DoNotDisturb or Offline, but keep store-and-forward
channels (InApp, Email, Sms, WhatsApp, Zulip) delivering.Granit.Caching.StackExchangeRedis,
the FusionCache backplane broadcasts heartbeat updates between hosts so a
user who pings pod A is visible from pod B without a database round-trip.| Package | Role |
|---------|------|
| Granit.Presence | Abstractions, domain types, in-memory store, FusionCache tracker, query/recorder/override services, meter + ActivitySource. |
| Granit.Presence.EntityFrameworkCore | Durable IPresenceStore backed by PresenceDbContext (host schema, table presence_user_presence). |
| Granit.Presence.Endpoints | Minimal API (/presence/*), DTOs, FluentValidation validators, Presence.Self.Manage / Presence.Users.Read permissions, 18-culture localization. |
| Granit.Presence.Notifications | INotificationDeliveryGate that suppresses push channels for DoNotDisturb / Offline recipients. |
presence_user_presence table[DependsOn(typeof(GranitPresenceModule))]public sealed class AppModule : GranitModule { }In-memory IPresenceStore, FusionCache L1 only. Single-pod, no persistence —
suitable for development and tests.
[DependsOn( typeof(GranitPresenceModule), typeof(GranitPresenceEntityFrameworkCoreModule))]public sealed class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Builder.AddGranitPresenceEntityFrameworkCore( opts => opts.UseNpgsql(context.Configuration .GetConnectionString("Presence"))); }
public override void OnApplicationInitialization(ApplicationInitializationContext context) { context.App.MapGranitPresence(); }}UserPresence rows are persisted to the host schema (table
presence_user_presence). The heartbeat stays cache-only — see
Why the heartbeat is cache-only.
[DependsOn( typeof(GranitPresenceModule), typeof(GranitPresenceEntityFrameworkCoreModule), typeof(GranitPresenceNotificationsModule), typeof(GranitNotificationsModule))]public sealed class AppModule : GranitModule { /* … */ }Once Granit.Presence.Notifications is loaded, the fan-out engine consults
PresenceNotificationDeliveryGate per delivery attempt. Push channels are
suppressed for recipients in DoNotDisturb or Offline; store-and-forward
channels keep delivering.
EffectiveStatus is composed server-side from two sources:
UserPresence aggregate (DB via
EF Core, or in-memory by default) — Available, Busy, DoNotDisturb,
AppearOffline, with an optional OverrideUntilUtc.LastPollUtc + LastActivityUtc.PresenceQueryService.Compose(userId, presence, heartbeat) deterministically
folds them into a PresenceSnapshot:
flowchart TD
Start([Compose userId, presence, heartbeat])
Active{Override active<br/>and not expired?}
AO[AppearOffline]
DnD[DoNotDisturb]
Bz[Busy]
HB{Heartbeat<br/>present?}
Old{Poll older than<br/>OfflineThreshold?}
Idle{Activity older than<br/>AwayThreshold?}
Start --> Active
Active -- AppearOffline --> AO --> Offline[(Offline)]
Active -- DoNotDisturb --> DnD --> DoNotDisturb_[(DoNotDisturb)]
Active -- Busy --> Bz --> Busy_[(Busy)]
Active -- "no / expired" --> HB
HB -- no --> Offline
HB -- yes --> Old
Old -- yes --> Offline
Old -- no --> Idle
Idle -- yes --> Away[(Away)]
Idle -- no --> Online[(Online)]
A user with three tabs open polls three times per cycle, each with its own
client-reported idle duration. The tracker reconstructs LastActivityUtc as
now − idleDuration and merges with the existing entry using a MAX rule:
LastActivityUtc := MAX(existing.LastActivityUtc, now − idleDuration)This avoids the classic flap where a backgrounded tab reports a long idle
period right after an active tab reported zero. No front-end coordination is
required — the merge is server-side in FusionCachePresenceTracker.RecordPollAsync.
TenantId)UserPresence is not IMultiTenant. A human carries one presence across
every tenant they belong to: the consultant logged into three customer
tenants is online once, not three times. The aggregate’s primary key is
UserId, the EF Core configuration places the table in the host schema, and
the visibility policy (IPresenceVisibilityPolicy) is the seam multi-tenant
apps use to restrict cross-tenant reads.
The heartbeat is volatile by design — every 30-60 s a fresh poll arrives,
the previous one is moot. Persisting it would generate sustained write
amplification (≈ 1 write per active user per polling cycle) for data that
must be discarded on pod restart anyway. The cache TTL (HeartbeatCacheTtl,
default 120 s) is the entire lifecycle: an absent entry naturally means
offline.
Bound from the Presence section of appsettings.json and validated at
startup by PresenceOptionsValidator:
| Option | Default | Bounds | Purpose |
|--------|---------|--------|---------|
| OfflineThreshold | 00:01:30 (90 s) | > 0, ≤ 30 min | Heartbeat older than this → Offline. |
| AwayThreshold | 00:03:00 (3 min) | > 0, ≤ 2 h | Activity older than this → Away. |
| HeartbeatCacheTtl | 00:02:00 (120 s) | ≥ OfflineThreshold | TTL of the cached heartbeat. |
| MaxBatchSize | 200 | 1–1000 | Cap on POST /presence/users/batch. |
| MaxOverrideDuration | 7.00:00:00 (7 d) | > 0, ≤ 30 d | Cap on OverrideUntilUtc - now. |
{ "Presence": { "OfflineThreshold": "00:01:30", "AwayThreshold": "00:03:00", "HeartbeatCacheTtl": "00:02:00", "MaxBatchSize": 200, "MaxOverrideDuration": "7.00:00:00" }}MapGranitPresence(endpoints, configure?) mounts six routes under the
PresenceEndpointsOptions.RoutePrefix (default presence) and tags them
Presence in the OpenAPI document. All routes require authentication; the
self routes additionally require Presence.Self.Manage and the cross-user
routes require Presence.Users.Read.
| Method | Route | Permission | Rate-limit policy |
|--------|-------|------------|-------------------|
| GET | /presence/my | (auth only) | — |
| PUT | /presence/my | Presence.Self.Manage | presence-mutate |
| DELETE | /presence/my/override | Presence.Self.Manage | presence-mutate |
| POST | /presence/my/poll | Presence.Self.Manage | presence-poll |
| GET | /presence/users/{userId:guid} | Presence.Users.Read | presence-query |
| POST | /presence/users/batch | Presence.Users.Read | presence-query |
POST /presence/my/pollRecords a heartbeat and returns the post-merge snapshot. Clients call this
every 30–60 s with the user’s local idle duration in seconds; the server
reconstructs LastActivityUtc using its own clock to avoid client clock-skew.
POST /presence/my/pollContent-Type: application/json
{ "idleSeconds": 12 }{ "userId": "f4a1…", "effectiveStatus": "Online", "manualOverride": null, "overrideUntilUtc": null, "lastSeenUtc": "2026-05-23T08:42:11.123Z"}PUT /presence/mySets or clears the caller’s manual override. Passing
manualStatus: "Available" clears it; untilUtc is optional and bounded by
Presence:MaxOverrideDuration.
PUT /presence/myContent-Type: application/json
{ "manualStatus": "DoNotDisturb", "untilUtc": "2026-05-23T18:00:00Z"}POST /presence/users/batchReturns snapshots for up to MaxBatchSize users (default 200) in a single
call. POST is used because UUID lists exceed practical URL length around
50 entries. Targets the caller is not allowed to read (per
IPresenceVisibilityPolicy) are silently omitted from the response — not
403’d — so the response shape cannot be used as an enumeration oracle.
POST /presence/users/batchContent-Type: application/json
{ "userIds": ["f4a1…", "9c00…", "21b8…"] }{ "presences": { "f4a1…": { "userId": "f4a1…", "effectiveStatus": "Online", "manualOverride": null, "overrideUntilUtc": null, "lastSeenUtc": "2026-05-23T08:42:11.123Z" }, "9c00…": { "userId": "9c00…", "effectiveStatus": "DoNotDisturb", "manualOverride": "DoNotDisturb", "overrideUntilUtc": "2026-05-23T18:00:00Z", "lastSeenUtc": "2026-05-23T08:40:55.000Z" } }}Validators are auto-discovered (FluentValidation + Granit.Validation) and return localized messages keyed by the following error codes:
| Code | Trigger |
|------|---------|
| Granit:Validation:PresenceBatchDuplicates | Same user id appears twice in userIds. |
| Granit:Validation:PresenceBatchTooLarge | userIds.Count > MaxBatchSize. |
| Granit:Validation:PresenceIdleSecondsNegative | idleSeconds < 0. |
| Granit:Validation:PresenceIdleSecondsTooLarge | idleSeconds > 2 × OfflineThreshold. |
| Granit:Validation:PresenceOverrideUntilInPast | untilUtc ≤ now + 1s. |
| Granit:Validation:PresenceOverrideUntilTooFar | untilUtc - now > MaxOverrideDuration. |
| Granit:Validation:PresenceUntilWithoutOverride | manualStatus = Available with a non-null untilUtc. |
Messages ship in the 18 cultures Granit standardizes on (15 base
languages + 3 regional variants) via PresenceEndpointsLocalizationResource.
Declared by PresencePermissionDefinitionProvider (auto-discovered) under
the Presence permission group, both with MultiTenancySides.Both:
| Permission | Allows |
|------------|--------|
| Presence.Self.Manage | Set or clear one’s own manual override; record one’s own heartbeat. |
| Presence.Users.Read | Read another user’s presence snapshot (single or batch). |
PresenceNotificationDeliveryGate (registered as INotificationDeliveryGate
when Granit.Presence.Notifications is loaded) is consulted by the fan-out
engine per delivery attempt:
sequenceDiagram
participant Pub as INotificationPublisher
participant Fan as Fan-out engine
participant Gate as PresenceNotificationDeliveryGate
participant QS as IPresenceQueryService
participant Ch as Push channel
Pub->>Fan: PublishAsync(type, data, recipients)
Fan->>Gate: ShouldDeliverAsync(userId, type, channel, tenant)
alt channel is store-and-forward
Gate-->>Fan: true (pass-through)
else channel is push
Gate->>QS: GetAsync(userId)
QS-->>Gate: snapshot
alt EffectiveStatus ∈ {DoNotDisturb, Offline}
Gate-->>Fan: false (suppress)
Fan-->>Pub: granit.presence.notification.gated++
else
Gate-->>Fan: true
Fan->>Ch: SendAsync(...)
end
end
Push channels gated (suppressed when EffectiveStatus is
DoNotDisturb or Offline): SignalR, Sse, Push (web push),
MobilePush.
Channels always delivered (store-and-forward): InApp, Email, Sms,
WhatsApp, Zulip. The user can read them when they return.
Granit.Presence| Counter | Tags |
|---------|------|
| granit.presence.heartbeat.received | tenant_id |
| granit.presence.status.changed | tenant_id, from, to |
| granit.presence.override.set | tenant_id, status, has_until |
| granit.presence.notification.gated | tenant_id, channel |
tenant_id defaults to the literal "global" when ICurrentTenant is
unavailable (single-tenant host, background work).
Granit.Presence| Operation | Emitted by |
|-----------|------------|
| presence.record_poll | PresenceHeartbeatRecorder.RecordAsync |
| presence.query_effective | PresenceQueryService.GetAsync / GetManyAsync |
| presence.set_override | PresenceOverrideService.SetAsync / ClearAsync |
| presence.gate_notification | PresenceNotificationDeliveryGate.ShouldDeliverAsync |
Granit.Observability registers the source automatically via
GranitActivitySourceRegistry when both packages are loaded.
The default FusionCachePresenceTracker uses the IFusionCache provided by
Granit.Caching with the key prefix granit:presence:. Single-pod
deployments need nothing more. Multi-pod deployments must also load
Granit.Caching.StackExchangeRedis: FusionCache’s Redis L2 backplane then
propagates heartbeat updates between hosts, so a user who pings pod A is
visible from pod B without each pod re-querying.
Without the Redis backplane, a heartbeat recorded on pod A is invisible to
pod B for the L1 TTL — clients hitting different pods would see different
effective statuses for the same user. The startup checks in
PresenceStartupChecks log a warning when this combination is detected.
UserPresence stores only the current override.
There is no audit trail of past statuses — Granit.Auditing covers the
override mutation if you need a paper trail.LastSeenUtc reflects the heartbeat, not the activity. It is the
instant of the most recent successful poll. A user with the tab open but
truly idle is “last seen” 30 s ago, not “last active” — the activity timing
surfaces only inside Away derivation, not in the response.PresenceStatus values
(Online, Away, Offline, Busy, DoNotDisturb). Custom status strings
(“In a meeting”, “Driving”) are explicitly out of scope — they belong in a
domain-specific module, not in the presence primitive.A React / TanStack Query integration ships separately in granit-front and
demonstrates the polling cadence, multi-tab merge handling on the client
side, and the realtime invalidation triggered by UserPresenceChangedEto.
See the showcase apps in granit-showcase-react and
granit-showcase-dotnet for a complete wiring example.
<module>.<entity> convention for roomsICurrentTenantpresence-* policies