Community State Sync in Angular 20+: Matching, Real‑Time Presence, Streaks, and Peer Links in SageStepper (Signals + SignalStore + Firebase)

Community State Sync in Angular 20+: Matching, Real‑Time Presence, Streaks, and Peer Links in SageStepper (Signals + SignalStore + Firebase)

How I keep communities aligned in SageStepper using Angular 20 Signals, SignalStore, and Firebase—deterministic matching, live presence, streak safety, and peer graphs.

Deterministic state beats clever state. If a partner changes after render, users blame your product—not the network.
Back to all posts

I’ve watched a streak die at 8:58 PM—two teammates ready to practice, presence bubbles flicker, the pairing card redraws, and trust drops. SageStepper serves 320+ communities and 12,000+ mock interviews, so community state can’t be vibe-coded. In Angular 20+, I stabilize it with Signals + SignalStore and Firebase, and I wire in telemetry to prove it works under load.

As teams plan 2025 Angular roadmaps, here’s the concrete blueprint I use in SageStepper for community synchronization: deterministic matching, real-time presence, streak safety, and peer links—production patterns you can lift into your app. If you’re looking to hire an Angular developer or an Angular consultant to get this right, this is my playbook.

Architecture at a glance

  • Angular 20 Signals + SignalStore slices: PresenceStore, MatchStore, StreakStore, PeerLinkStore
  • Firebase: Realtime Database for presence; Firestore for matches, streaks, and peer links; Cloud Functions for enforcement
  • Nx monorepo with CI: unit tests for matching invariants; e2e for streak edge cases; telemetry checks
  • PrimeNG UI for presence chips, match lists, and streak badges
  • Typed event schemas piped to GA4/Firebase Analytics

This separation is essential. Presence is lossy and transient; matching is authoritative and transactional; streaks need daily idempotency; peer links form a graph and must be auditable.

The state model (multi-tenant, role-aware)

  • communityId is present on every doc and every store’s state.
  • User profiles carry tags (topic, level, timezone) and guardrails (availability window, past partner cooldown).
  • Matches live under communities with a matchRoundId to enforce idempotency.
  • Peer links are undirected edges with a canonical key.

Tip: I treat role-based visibility with selectors in SignalStore—mentors see queue detail; learners see only anonymized slots. This aligns with my multi-tenant patterns and prevents leakage.

Real-time presence with Firebase RTDB

Presence wants millisecond churn and onDisconnect semantics. RTDB wins here; Firestore is for durable state. I normalize presence into a Signals store and expose derived selectors for UI and matching.

// presence.store.ts (Angular 20+, @ngrx/signals)
import { computed, inject, signal } from '@angular/core';
import { signalStore, withState, withMethods, withHooks } from '@ngrx/signals';
import { Auth } from '@angular/fire/auth';
import { Database, ref, onValue, onDisconnect, serverTimestamp, set } from '@angular/fire/database';

interface PresenceState {
  communityId: string;
  me?: { uid: string; displayName: string };
  online: Record<string, { uid: string; ts: number; tags: string[] }>;
  loading: boolean;
}

export const PresenceStore = signalStore(
  { providedIn: 'root' },
  withState<PresenceState>({ communityId: '', online: {}, loading: true }),
  withMethods((store, db = inject(Database), auth = inject(Auth)) => ({
    connect(communityId: string) {
      store.patch({ communityId, loading: true });
      const user = auth.currentUser!;
      store.patch({ me: { uid: user.uid, displayName: user.displayName ?? 'You' } });

      const statusRef = ref(db, `/status/${communityId}/${user.uid}`);
      onDisconnect(statusRef).set({ state: 'offline', last_changed: serverTimestamp() });
      set(statusRef, { state: 'online', last_changed: serverTimestamp() });

      const onlineRef = ref(db, `/status/${communityId}`);
      onValue(onlineRef, (snap) => {
        const val = snap.val() ?? {};
        const mapped = Object.entries(val)
          .filter(([_, v]: any) => v.state === 'online')
          .reduce((acc: any, [uid, v]: any) => ({
            ...acc, [uid]: { uid, ts: v.last_changed, tags: v.tags ?? [] }
          }), {});
        store.patch({ online: mapped, loading: false });
      });
    }
  })),
  withHooks({
    onInit(store) {
      // defer connect until caller provides communityId
    }
  })
);

export const onlineCount = (s: PresenceState) => Object.keys(s.online).length;
export const onlineUids = computed(() => Object.values(PresenceStore.state().online).map(o => o.uid));

In production I also mirror a slim presence index into Firestore every few minutes via Cloud Functions for analytics queries. Angular DevTools and Firebase Logs confirm the presence heartbeat under simulated packet loss (Docker network shaping).

<!-- Presence UI with PrimeNG -->
<p-chip *ngFor="let u of presenceUsers()" [label]="u.displayName" styleClass="mr-2"
        [icon]="'pi pi-circle-fill'" [style]="{ color: '#16a34a' }"></p-chip>
<span class="text-sm text-color-secondary">{{ presenceCount() }} online</span>

Deterministic matching (stable, fair, and idempotent)

SageStepper pairs learners for mock interviews in timed rounds. The UX requirement: once you see your partner, it shouldn’t change. Matching must be stable across refreshes and resilient to late arrivals.

Core ideas I ship:

  • Only match from a snapshot of online+eligible members taken inside a Firestore transaction.
  • Use a deterministic score function and a fixed seed for tie‑breaks.
  • Write matches with a matchRoundId; subsequent retries become no‑ops.
  • Enforce partner cooldown and time zone overlap in the score.
// matching.ts (invoked from Cloud Function or privileged admin)
import { firestore as fs } from 'firebase-admin';

type Candidate = { uid: string; tags: string[]; tz: number; cooldownUntil?: number };

function score(a: Candidate, b: Candidate) {
  const overlap = a.tags.filter(t => b.tags.includes(t)).length;
  const tzDelta = Math.abs(a.tz - b.tz);
  const cooldown = (a.cooldownUntil && a.cooldownUntil > Date.now()) || (b.cooldownUntil && b.cooldownUntil > Date.now());
  return cooldown ? -Infinity : overlap - tzDelta * 0.1; // tuneable
}

export async function runMatchRound(db: fs.Firestore, communityId: string, roundId: string) {
  await db.runTransaction(async (tx) => {
    const metaRef = db.doc(`communities/${communityId}/meta/matching`);
    const meta = (await tx.get(metaRef)).data() || {};
    if (meta.lastRoundId === roundId) return; // idempotent

    const snap = await tx.get(db.collection(`communities/${communityId}/queue`).where('eligible', '==', true));
    const users: Candidate[] = snap.docs.map(d => ({ uid: d.id, ...d.data() } as any));

    // Greedy stable pairing with deterministic sort
    users.sort((a, b) => (a.uid < b.uid ? -1 : 1));
    const pairs: [Candidate, Candidate][] = [];
    const used = new Set<string>();

    for (let i = 0; i < users.length; i++) {
      if (used.has(users[i].uid)) continue;
      let best: Candidate | undefined; let bestScore = -Infinity; let bestIdx = -1;
      for (let j = i + 1; j < users.length; j++) {
        if (used.has(users[j].uid)) continue;
        const s = score(users[i], users[j]);
        if (s > bestScore) { best = users[j]; bestScore = s; bestIdx = j; }
      }
      if (best && bestScore > -Infinity) {
        used.add(users[i].uid); used.add(best.uid);
        pairs.push([users[i], best]);
      }
    }

    const batch = db.batch();
    pairs.forEach(([a, b]) => {
      const ref = db.collection(`communities/${communityId}/matches`).doc(`${roundId}_${a.uid}_${b.uid}`);
      batch.set(ref, { a: a.uid, b: b.uid, roundId, createdAt: fs.FieldValue.serverTimestamp() });
    });
    batch.set(metaRef, { lastRoundId: roundId }, { merge: true });
    tx.set(metaRef, { lastRoundId: roundId }, { merge: true });
    await batch.commit();
  });
}

I’ve load-tested this with synthetic presence spikes (1,000+ simulated users via Docker workers). The key is not algorithmic purity; it’s determinism, fairness, and idempotency so the UI doesn’t jitter. Angular DevTools confirms zero extra recomputes on match write thanks to Signals’ fine-grained updates.

Streak tracking that won’t double-count

Daily streaks are fragile: time zones, daylight savings, device clocks, and retries. I use a dayKey per user and increment once inside a transaction. A Cloud Function backfills and repairs if a user completed a session but lost the client write.

// streak.store.ts (client-side guard + server enforcement)
import { signalStore, withState, withMethods } from '@ngrx/signals';
import { doc, serverTimestamp, increment, runTransaction, getFirestore } from 'firebase/firestore';

interface StreakState { dayKey?: string; count: number; lastUpdated?: number; }

export const StreakStore = signalStore(
  { providedIn: 'root' },
  withState<StreakState>({ count: 0 }),
  withMethods((store) => ({
    async markToday(uid: string) {
      const db = getFirestore();
      const key = new Date().toLocaleDateString('en-CA', { timeZone: 'UTC' });
      const ref = doc(db, `users/${uid}/streaks/${key}`);
      await runTransaction(db, async (tx) => {
        const snap = await tx.get(ref);
        if (!snap.exists()) {
          tx.set(ref, { createdAt: serverTimestamp() });
          tx.set(doc(db, `users/${uid}`), { streak: increment(1), streakUpdatedAt: serverTimestamp() }, { merge: true });
        }
      });
      store.patch({ dayKey: key });
    }
  }))
);

On the server, I compute the canonical dayKey with the user’s preferred time zone and mark-only-once. Telemetry emits StreakMarked and StreakAlreadyCounted with typed payloads. We caught a daylight savings bug this way before it hit production.

Peer links and community graph

Peer links are undirected. I store a normalized key peers/{minUid}_{maxUid} with both participant views and a communityId index. This enables fast “friends online” selectors and lightweight moderation tooling. Connections are cached in a SignalStore for O(1) presence lookups.

Telemetry, guardrails, and UX polish

  • Telemetry: GA4 event schemas for PresenceHeartbeat, MatchAssigned, StreakMarked with versioned payloads. Logs sampled to BigQuery.
  • Guardrails: Nx + GitHub Actions run invariants—no duplicate pairs in a round, cooldown not violated, one streak per day.
  • UX: Animations are discrete and stable; presence chips don’t remount on every heartbeat; PrimeNG skeletons during presence warm-up; AA contrast for streak badges.

Firebase rules (sketch)

// Firestore security (excerpt)
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function inCommunity(comm) { return request.resource.data.communityId == comm; }
    match /communities/{comm}/matches/{id} {
      allow read: if request.auth != null && resource.data.communityId == comm;
      allow write: if false; // server-only via Cloud Functions
    }
    match /users/{uid} {
      allow read, write: if request.auth.uid == uid;
    }
  }
}

These rules are paired with RTDB presence rules that allow users to write only to their own node. Combined with server-side matching, this prevents client-side tampering.

When to hire an Angular developer for community sync

  • Your matching algorithm causes UI jitter or duplicate pairs under load.
  • Presence shows ghosts or flaps during flaky networks.
  • Streaks double-count or miss days across time zones.
  • You need multi-tenant isolation and role-based selectors without leaks.
    I’ve solved these issues in kiosks (offline-tolerant flows), enterprise dashboards (real-time WebSockets), and now in SageStepper’s learning communities. If you need an Angular expert to stabilize this, let’s talk.

A streak dies at 8:58 PM: why community state must be exact

Scene from production

Two learners ready to go, presence flickers, a pairing card redraws, and one user’s streak doesn’t tick. In a community product, these moments break trust. I’ve fixed this in SageStepper by making state deterministic with Angular 20 Signals + SignalStore and Firebase.

Why I wrote this

If you’re evaluating whether to hire an Angular developer or bring in an Angular consultant, this article shows the exact patterns I use in production across 320 communities.

  • Give teams a concrete blueprint

  • Show real code that survives load

  • Offer hiring criteria for when to bring in help

Why Angular 20+ teams need deterministic community sync

2025 planning reality

As companies plan 2025 Angular roadmaps, live community features (pairing, presence, streaks) are on most backlogs. Without deterministic state and guardrails, you’ll ship jitter and support tickets.

  • Q1 is hiring season

  • Communities grow faster than moderation teams

  • Live features amplify bugs

What good looks like

Determinism + telemetry beats clever algorithms. Make it provable with tests, analytics, and CI guards.

  • Stable matching across refreshes

  • Presence that recovers from packet loss

  • Streaks that respect time zones

  • Peer graphs that are auditable

Signals + SignalStore blueprint for community sync

// presence.store.ts ... see article body for full snippet

// matching.ts ... see article body for full snippet

State slices

Each slice is a SignalStore so updates are precise and traceable in Angular DevTools.

  • PresenceStore: RTDB heartbeat

  • MatchStore: transactional pairing

  • StreakStore: dayKey idempotency

  • PeerLinkStore: undirected edges

Real-time presence

Presence is transient and needs low-latency semantics. Firebase RTDB nails the basics; Signals keeps UI quiet—no jitter redraws.

  • RTDB onDisconnect

  • Derived selectors for UI

  • Mirror to Firestore for analytics

Deterministic matching

Run inside a Firestore transaction. If the round replays, writes are no-ops. Users see stable partners across refreshes.

  • Transactional snapshot

  • Deterministic score function

  • Idempotent roundId

  • Cooldown + timezone guards

Streaks

Guard streaks in a transaction and emit typed analytics for observability.

  • One write per day per user

  • Timezone-aware dayKey

  • Backfill on server

Build friend lists and mentorship edges with auditability.

  • Normalized key peers/{min}_{max}

  • Community index

  • Fast presence joins

Telemetry + CI

I gate deployments with tests that assert no duplicate pairs and streak idempotency.

  • Typed GA4 events

  • Nx + GitHub Actions invariants

  • Synthetic load tests

UI wiring with PrimeNG

<p-chip *ngFor="let u of presenceUsers()" [label]="u.displayName" [icon]="'pi pi-circle-fill'" class="mr-2" />

Presence chips

PrimeNG chips display online peers without jitter. Use Signals’ derived selectors so the chip list doesn’t churn.

  • Minimal remounts

  • AA contrast

  • Skeletons during warm-up

Match cards

Treat match assignment as a confirmed state; optimistic UI is fine, but never change partner quietly.

  • Stable identities

  • No reflows during write

  • Retry banner if server delayed

When to hire an Angular developer for community sync

Bring in help when

I specialize in real-time, multi-tenant Angular apps. If your team needs a fast stabilization, I can help as a remote Angular contractor.

  • Presence flaps or shows ghosts

  • Duplicate pairs appear in a round

  • Streaks double-count or miss days

  • Multi-tenant isolation is leaky

Takeaways and next steps

What to instrument next

Add dashboards for these metrics and wire alerts. This is how I keep SageStepper stable while it grows to new communities.

  • Presence heartbeat rate

  • Match idempotency rate

  • Streak write conflicts

  • Peer link churn

Kickoff questions I ask

Discovery intake

These answers shape the matching algorithm, presence wiring, and CI guardrails we set.

  • How do you identify eligibility and cooldowns?

  • What’s your authoritative time zone source?

  • Do you need GDPR data deletion on peer links?

  • What’s your peak concurrency and region split?

  • Who owns server-side transactions?

Related Resources

Key takeaways

  • Use Signals + SignalStore to isolate presence, matching, streaks, and peer links as testable slices.
  • Firebase Realtime Database is ideal for low-latency presence; Firestore for durable matches/streaks.
  • Deterministic, fairness-aware matching avoids jitter and double-assignments during peak times.
  • Typed event schemas and audit logs make community state debuggable in production.
  • CI guardrails (Nx + GitHub Actions) protect matching invariants and presence fallbacks.

Implementation checklist

  • Define a tenancy-aware data model: communityId is first-class in every store.
  • Use RTDB presence with onDisconnect and server timestamps; expose derived signals for UI.
  • Run matching inside Firestore transactions; enforce idempotency with matchRoundId locks.
  • Track streaks with a single daily id; guard timezones and backfill via Cloud Functions.
  • Emit typed analytics events for presence/matching/streaks; validate in CI.
  • Add dashboards in Angular DevTools + Firebase Logs to verify invariants in production.

Questions we hear from teams

How much does it cost to hire an Angular developer for this work?
Most community sync engagements run 2–6 weeks. Fixed-scope audits start around $6k, and full implementations typically range $15k–$45k depending on matching complexity, multi-tenant needs, and CI/telemetry depth.
How long does an Angular upgrade or Signals migration take alongside this?
For stable codebases, migrating to Angular 20 Signals + SignalStore takes 2–4 weeks. If we’re also adding presence, matching, and streaks, plan 4–8 weeks with CI guardrails and progressive rollouts.
What does an Angular consultant do on a project like this?
I design the state slices, implement SignalStores, wire Firebase presence and transactions, add telemetry, and set up Nx CI with invariants. I also train the team and leave playbooks and tests for sustainable ownership.
Can Firebase handle real-time presence at scale?
Yes. Use Realtime Database for presence (onDisconnect, low latency) and Firestore for durable writes like matches and streaks. Mirror presence summaries to Firestore for analytics and run matching server-side in transactions.
What’s involved in a typical engagement?
Discovery and audit (week 1), presence + streaks MVP (week 2), matching + peer links with telemetry (weeks 3–4), and polish/hand-off (final week). Discovery call within 48 hours; assessment delivered within one week.

Ready to level up your Angular experience?

Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.

Hire Matthew – Remote Angular Expert, Available Now See live Angular apps (gitPlumbers, IntegrityLens, SageStepper)

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
NG Wave Component Library

Related resources