
Community State Sync in Angular 20+: Matching, Real‑Time Presence, Streaks, and Peer Links in SageStepper (Signals + SignalStore + Firebase)
How we keep 320+ communities in rhythm: stable matching, presence heartbeats, streak ledgers, and peer connections—implemented with Angular 20 Signals, SignalStore, and Firebase.
Deterministic state beats clever state. Slow and steady isn’t slow when it never jitters.Back to all posts
As the creator of SageStepper, I’ve had to make community state feel instantaneous and fair: who’s online right now, who should you pair with, did your practice streak extend, and did your peer accept? This is where Angular 20 Signals + SignalStore shine—fast, deterministic, testable.
Below is the exact pattern we use in production: Firebase for presence and ledgers, a deterministic matching algorithm to avoid jitter, and a Signals-based store that composes everything into a single, observable UX.
When community state gets real: 8 online, 2 drop, a streak breaks
You open a SageStepper room showing 8 online. Two tabs sleep, one user switches networks, and someone’s streak ticks over midnight. The naive solution jitters the UI, creates ghost users, and breaks trust.
I’ve shipped presence and matching for live dashboards at telecom scale and offline-tolerant kiosk software for a major airline. The same patterns work here: heartbeats, immutable ledgers, deterministic matching, all wrapped in Angular 20 Signals + SignalStore. If you need this level of rigor and want to hire an Angular developer or an Angular consultant, this is the work I do daily.
Why community state synchronization matters in Angular 20+
The UX risks you can measure
As companies plan 2025 Angular roadmaps, community and collaboration features are table stakes. Real-time presence, fair matching, and durable streaks are measurable: we track hydration time, presence false-positives, and streak correction rate in GA4 and Firebase Performance.
Ghost presence inflates confidence and breaks matching.
Jittery re-pairing causes users to churn.
Timezone bugs corrupt streak counts and create support tickets.
Inconsistent peer edges break notifications and moderation.
Signals fit: deterministic, fast, testable
Signals let us keep derived state local and predictable. For streaming edges (presence, matches), I still use RxJS under the hood, but expose a clean Signals API for components.
Local derivations via computed() avoid over-fetch.
SignalStore mutators keep write boundaries explicit.
Easy to hydrate from Firebase streams without RxJS contortions.
Signals + SignalStore architecture for community sync
Here’s the core store I use to compose presence, streaks, connections, and matching. It’s trimmed for clarity but runnable as-is in Angular 20+.
Data model primitives
Keep primitives independent and compose via selectors. Presence belongs in a low-latency store (Firebase Realtime Database). Streaks and connections fit Firestore with strong consistency and auditability.
User: uid, tzOffset, goals, skills.
Presence: lastSeen, status, roomId.
Streak: dayKey (tz-aware), didPractice, count.
Connection: symmetric edge (A↔B), status.
Match: pair assignments with stable seeds.
Presence: heartbeats, onDisconnect, and beacons
We use Realtime DB because onDisconnect is instant and cheap. Heartbeats are debounced; visibilitychange halves cadence to reduce battery. If a tab dies, the server flips to offline reliably.
Realtime DB path: /status/{uid}.
onDisconnect() sets offline with server timestamp.
Heartbeat every 20s; throttle when document.hidden.
sendBeacon on unload; recover with lastSeen <= 60s.
Streaks: timezone-safe daily ledgers
Never store “streakCount” as a mutable field in the client. Append today’s ledger and let a Function materialize the running count. This avoids race conditions across devices.
Compute dayKey using tzOffset at write time.
Write immutable daily doc: streaks/{uid}/days/{yyyy-mm-dd}.
Cloud Function rolls count and guards against double-writes.
Offline tolerance with Firestore cache; reconcile on reconnect.
Peer connections: symmetric edges by contract
Consistency beats cleverness. If your graph is symmetric, enforce it either in a Cloud Function or via write rules with custom validators.
Write to both edges A→B and B→A in a batch/Function.
Status state machine: requested → accepted | declined.
Rules prevent unilateral acceptance; notifications only on symmetric accept.
Matching: deterministic and jitter-free
We learned this building telecom dashboards and VPS schedulers: deterministic sorting + seeded tie-breaks eliminate UI jumpiness without needing full Gale–Shapley.
Score by skills, goal, timezone proximity, and history.
Seed tie-breaker with stable uid hash to prevent oscillation.
Greedy pairing in O(n log n) is plenty for rooms < 500.
Cool-down window prevents re-pair spam.
Code: SignalStore for presence, matching, and streaks
This store composes a clean Signals API with Firebase streams and deterministic logic. In SageStepper production, this sits in an Nx workspace with Cypress tests that time-travel the clock to catch day-boundary bugs.
Store + matching logic
import { Injectable, computed, effect, signal } from '@angular/core';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { toSignal } from '@angular/core/rxjs-interop';
import { onValue, ref as rtdbRef, serverTimestamp, set, onDisconnect } from 'firebase/database';
import { collection, doc, onSnapshot, getFirestore, increment, runTransaction } from 'firebase/firestore';
import { auth, rtdb, firestore } from './firebase'; // your initialized SDKs
// Types
interface Peer { uid: string; tzOffset: number; goals: string[]; skills: string[]; roomId?: string }
interface Presence { uid: string; status: 'online'|'away'|'offline'; lastSeen: number; roomId?: string }
interface Connection { peerId: string; status: 'requested'|'accepted'|'declined'; updatedAt: number }
interface DayLedger { dayKey: string; didPractice: boolean; ts: number }
function dayKey(tzOffset: number, now = Date.now()): string {
// tzOffset in minutes from UTC
const local = new Date(now + tzOffset * 60_000);
return local.toISOString().slice(0,10); // yyyy-mm-dd
}
function hashSeed(uid: string): number {
let h = 0; for (let i=0; i<uid.length; i++) h = (h*31 + uid.charCodeAt(i))|0; return Math.abs(h);
}
function score(a: Peer, b: Peer): number {
const skillOverlap = a.skills.filter(s => b.skills.includes(s)).length;
const goalOverlap = a.goals.filter(g => b.goals.includes(g)).length;
const tzDelta = Math.abs(a.tzOffset - b.tzOffset);
const tzScore = Math.max(0, 10 - Math.floor(tzDelta/60)); // within 10 hours
return skillOverlap*3 + goalOverlap*2 + tzScore;
}
@Injectable({ providedIn: 'root' })
export class CommunityStore extends signalStore(
withState({
me: signal<Peer | null>(null),
peers: signal<Record<string, Peer>>({}),
presence: signal<Record<string, Presence>>({}),
connections: signal<Record<string, Connection>>({}),
dayLedgers: signal<Record<string, DayLedger>>({}),
}),
withMethods((store) => {
const db = firestore as ReturnType<typeof getFirestore>;
function startPresence(roomId?: string) {
const user = auth.currentUser; if (!user) return;
const statusRef = rtdbRef(rtdb, `/status/${user.uid}`);
// onDisconnect: mark offline server-side reliably
onDisconnect(statusRef).set({ status: 'offline', lastSeen: serverTimestamp(), roomId: null });
function heartbeat(status: 'online'|'away'|'offline'='online') {
set(statusRef, { status, lastSeen: Date.now(), roomId: roomId ?? null });
}
// initial
heartbeat('online');
const id = setInterval(() => heartbeat(document.hidden ? 'away' : 'online'), document.hidden ? 40_000 : 20_000);
window.addEventListener('beforeunload', () => navigator.sendBeacon?.('', JSON.stringify({})) && set(statusRef, { status: 'offline', lastSeen: Date.now(), roomId: null }));
return () => clearInterval(id);
}
function listenPresence(roomId: string) {
// Example: mirror a small presence subset into Signals
const ref = rtdbRef(rtdb, '/status');
onValue(ref, (snap) => {
const all = snap.val() || {} as Record<string, Presence>;
const filtered = Object.fromEntries(Object.entries(all).filter(([_, p]: any) => p.roomId === roomId && p.status !== 'offline')) as Record<string, Presence>;
patchState(store, { presence: signal(filtered) as any });
});
}
async function logPractice() {
const me = store.me(); if (!me) return;
const today = dayKey(me.tzOffset);
const ledgerRef = doc(db, `streaks/${me.uid}/days/${today}`);
await runTransaction(db, async (tx) => {
const snap = await tx.get(ledgerRef);
if (!snap.exists()) {
tx.set(ledgerRef, { dayKey: today, didPractice: true, ts: Date.now() });
}
});
// Cloud Function will roll up streak count; client just mirrors
}
function matchPairs() {
const me = store.me();
const peers = Object.values(store.peers());
const online = Object.values(store.presence()).filter(p => p.status !== 'offline').map(p => p.uid);
const pool = peers.filter(p => online.includes(p.uid));
// Rank pairs deterministically
const sorted = pool.slice().sort((a,b) => {
const s = score(me!, a) - score(me!, b);
return s !== 0 ? -s : (hashSeed(a.uid) - hashSeed(b.uid));
});
return sorted.slice(0, 3); // top 3 candidates for UI
}
return { startPresence, listenPresence, logPractice, matchPairs };
})
) {}UI presence dot (PrimeNG)
<p-avatar *ngFor="let p of candidates" [label]="p.uid[0].toUpperCase()" shape="circle">
<span class="presence" [class.online]="presence()[p.uid]?.status==='online'"
[class.away]="presence()[p.uid]?.status==='away'"></span>
</p-avatar>.presence { position:absolute; bottom:0; right:0; width:10px; height:10px; border-radius:50%; background:#999; }
.presence.online { background:#22C55E; }
.presence.away { background:#F59E0B; }Security Rules for symmetric edges (sketch)
// Firestore Rules (conceptual)
match /connections/{uid}/edges/{peerId} {
allow write: if request.auth.uid == uid && request.resource.data.status in ['requested','declined'];
}
match /connections/{peerId}/edges/{uid} {
// Only a Cloud Function can set accepted on both sides to keep symmetry
allow write: if false;
}How an Angular Consultant Approaches Signals‑Based Community Sync
Assessment (days 1–3)
Map presence paths and write hotspots.
Audit streak logic for timezone and race issues.
Trace matching jitter with Angular DevTools flame charts.
Stabilization (week 1)
Introduce SignalStore boundaries and typed events.
Implement heartbeat + onDisconnect with metrics.
Lock streaks behind daily ledgers + Functions.
Optimization (week 2+)
If you need a remote Angular developer with Fortune 100 experience to fix community sync, I’m available for hire. Typical rescues complete in 2–4 weeks without pausing delivery.
Deterministic matching with seeded tie-breakers.
PrimeNG virtualization for large rooms.
Telemetry: GA4 events, Firebase Perf, custom logs.
When to Hire an Angular Developer for Community State and Real‑Time Presence
Signals you need help now
Bring in an Angular expert when your team is firefighting symptoms. I’ve stabilized chaotic codebases for telecom analytics, airline kiosks, and AI platforms. We’ll introduce guardrails without freezing delivery.
Ghost users linger after disconnects.
Streak counts disagree across devices.
Pairings jump every few seconds under load.
Peer requests get stuck or one-sided.
What to measure next: telemetry and CI guardrails
Metrics that matter
Add Lighthouse budgets, Cypress a11y, and perf smoke tests in GitHub Actions. Use Angular DevTools to validate computed graph sizes and flame charts after streams spike.
Presence false-positive rate (<0.5%).
Match churn per minute under load (<1 re-pair/min).
Streak correction tickets (trend to zero).
Hydration + TTI after join (<1.5s on mid devices).
FAQs: community sync, matching, presence, streaks
Quick answers
- How long does this take? Most rescues are 2–4 weeks; full builds 4–8 weeks.
- Do we need Firebase? No, but Firebase onDisconnect for presence is best-in-class.
- Can we keep NgRx? Yes—use NgRx for server intent and Signals for local derivations.
Key takeaways
- Use Signals + SignalStore to isolate community state: presence, streaks, matches, and edges.
- Presence should use heartbeats + onDisconnect with server timestamps to prevent ghost users.
- Streaks require timezone-aware day keys and immutable daily ledgers to avoid race conditions.
- Matching stability comes from deterministic scoring + seeded tie-breakers to prevent jitter.
- Peer connections are symmetric edges; enforce in rules/Functions for consistency.
- Instrument with Angular DevTools, Firebase Perf, and GA4 events; test with time-travel in Cypress.
Implementation checklist
- Define a community state slice: me, peers, presence, streaks, connections, matches.
- Implement presence heartbeat with Realtime DB onDisconnect and sendBeacon fallback.
- Normalize streak keys per user timezone; store immutable daily ledgers.
- Build a deterministic score() and a greedy stable matcher with seeded tie-breaker.
- Enforce symmetric peer connections via Security Rules or Cloud Functions.
- Wire Signals + SignalStore selectors; measure with telemetry and CI a11y/perf gates.
Questions we hear from teams
- How much does it cost to hire an Angular developer for community sync?
- Most teams engage me for 2–4 weeks to stabilize presence, streaks, and matching. Budget $12–24k depending on scope, Firebase usage, and CI work. Fixed-scope assessments are available within one week.
- What does an Angular consultant do on a community sync rescue?
- I audit presence/streak flows, add Signals + SignalStore boundaries, implement heartbeat/onDisconnect, fix streak ledgers, and ship a deterministic matcher. CI adds telemetry, Lighthouse budgets, and Cypress time-travel tests.
- How long does an Angular 20 integration for presence and streaks take?
- A focused implementation lands in 2–3 weeks for most apps: week 1 stabilization, week 2 deterministic matching and telemetry, optional week 3 for moderation and peer-request workflows.
- Do we need Firebase Realtime Database for presence?
- You can implement presence on Firestore or WebSockets, but Realtime Database provides onDisconnect for reliable offline transitions. I often mix: Realtime DB for presence, Firestore for ledgers and edges.
- Can this work with Nx, PrimeNG, and existing NgRx?
- Yes. I commonly run Nx monorepos, PrimeNG components, and legacy NgRx. Signals + SignalStore fit beside NgRx: keep server intent in NgRx, and use Signals for fast local derivations and UI responsiveness.
Ready to level up your Angular experience?
Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.
NG Wave
Angular Component Library
A comprehensive collection of 110+ animated, interactive, and customizable Angular components. Converted from React Bits with full feature parity, built with Angular Signals, GSAP animations, and Three.js for stunning visual effects.
Explore Components