Church App — Chat Architecture Deep Dive

Church App — Chat Architecture Deep Dive

Addendum to the Technical Architecture Blueprint The hardest decision in the entire platform.


Why Chat Is Different From Everything Else

Every other feature in this app (news, events, groups, giving) follows a standard request/response pattern. Symfony handles it beautifully. Chat breaks that model in every way:

  • Persistent connections. Users stay connected for minutes or hours. PHP’s request-lifecycle model doesn’t support this natively.
  • Bidirectional real-time. Messages must flow both ways with < 200ms perceived latency. HTTP polling is not acceptable.
  • Delivery guarantees. A message sent must arrive — even if the recipient was briefly offline. This requires queuing, retry, and offline push fallback.
  • Presence and typing indicators. Ephemeral, high-frequency data that must never touch a database.
  • Read receipts. Per-user, per-message state tracking at scale.
  • Media handling. Images, files, voice messages — upload, process, store, deliver.
  • Ordering guarantees. Messages in a conversation must appear in order, even under network jitter.
  • Encryption potential. Pastoral conversations carry extreme sensitivity. E2E encryption is a trust differentiator.

Building all of this from scratch is 3-6 months of focused work for an experienced team. For a solo founder, it’s a trap. But buying it brings cost and lock-in risks. Let’s map every option honestly.


Option Comparison Matrix

DimensionStreamMatrix (Synapse)Centrifugo + CustomFull Self-Built
What it isManaged chat-as-a-service API + SDKOpen protocol + self-hosted serverReal-time transport layer + your business logicEverything from scratch
Time to MVP chat1-2 weeks3-5 weeks6-10 weeks12-24 weeks
React Native SDKExcellent (official, maintained, UI kit included)Decent (matrix-js-sdk, no RN UI kit)JS SDK exists, no chat UI — build your ownN/A — you build everything
GDPR / Data sovereigntyEU datacenter (Frankfurt) available. Data on Stream’s infra. You’re the controller, they’re the processor. DPA available.Full sovereignty — you host it, you own it, you control it. Best possible GDPR story.Full sovereignty — self-hosted.Full sovereignty.
Offline message deliveryBuilt-in. Missed messages queued, delivered on reconnect + push.Built-in. Synapse stores messages, syncs on reconnect.NOT built-in. Centrifugo is pub/sub — if you’re offline, you miss it. You must build offline queue + push fallback yourself.You build it.
Typing indicatorsBuilt-inBuilt-inBuilt-in (presence API)You build it.
Read receiptsBuilt-inBuilt-inNOT built-in. You build it.You build it.
ThreadsBuilt-inBuilt-in (MSC3440, maturing)You build it.You build it.
File/media sharingBuilt-in (CDN, processing, thumbnails)Built-in (media repo in Synapse)You build it (S3 + processing)You build it.
Push notification integrationBuilt-in (FCM + APNs)Sygnal push gateway (self-hosted)You build it (via your Notification service)You build it.
E2E encryptionNot available for group chats. Limited.Built-in (Olm/Megolm, Signal-protocol-based). Industry leading.You build it.You build it.
Moderation toolsBuilt-in (AI moderation, blocklists, flagging)Basic (power levels, kick/ban). No AI.You build it.You build it.
Multi-tenancyBuilt-in. Multi-tenant by design.Possible but not native — one Synapse per tenant, or complex Space-based isolation.Your architecture handles this.Your architecture handles this.
Scalability ceilingTheir problem. They scale to billions.Synapse: medium. Workers help but Python is the bottleneck. Dendrite (Go): better. Conduwuit (Rust): promising but young.Centrifugo: 1M+ connections proven. Your chat logic is the bottleneck.Depends on your implementation.
Vendor lock-inHigh. Proprietary protocol. Migration = rewrite.Zero. Open protocol. Switch servers, switch clients.Low. Centrifugo is transport only. Swap to any WebSocket server.Zero.
Maintenance burdenZero (managed).Medium-high. Synapse upgrades, PostgreSQL, media cleanup, worker tuning.Medium. Centrifugo is low-maintenance. Your chat logic is the work.Very high.
Cost at 1K MAUFree (Maker plan)~€20/mo hosting~€0 (runs on existing server)~€0 (your time)
Cost at 10K MAU~€499/mo~€50-100/mo hosting~€20-40/mo (Centrifugo resources)~€20-40/mo hosting
Cost at 50K MAU~€1,500-2,500/mo (estimated)~€200-400/mo hosting (needs workers)~€100-200/mo~€100-200/mo
Cost at 200K MAUEnterprise pricing, likely €5K+/mo~€500-1,000/mo (multi-worker Synapse)~€300-500/mo~€300-500/mo

Deep Dive: Each Option

Option A: Stream (getstream.io)

How it works: Stream provides a complete chat backend as a service. Your app connects via their SDK (React Native, iOS, Android, Web). Messages, channels, users, moderation — all managed by Stream. Your Symfony backend creates users and tokens via Stream’s server API, then the mobile app communicates directly with Stream’s servers.

┌──────────┐         ┌──────────────┐         ┌─────────────┐
│  Mobile   │ ──SDK──▶│  Stream API  │◀──HTTP──│  Symfony     │
│  App      │◀──SDK── │  (managed)   │         │  Backend     │
└──────────┘         └──────────────┘         └─────────────┘
                      Handles: connections, messages,
                      storage, push, moderation, CDN

The good:

  • Fastest path to production. Their React Native SDK includes pre-built UI components (message list, input, thread view, reactions). You can have working chat in days.
  • Multi-tenant out of the box. Each church can be a “team” with isolated channels.
  • Their Maker plan gives you $100/month free credit if you have < 5 team members and < $10K revenue. Effectively free for MVP.
  • Handles all the hard parts: offline delivery, push integration, media processing, moderation.
  • 99.999% uptime SLA on Enterprise plans.

The bad:

  • Pricing jumps sharply. The minimum production plan is ~$499/month for 10K MAU. At 50K MAU you’re looking at $1,500-2,500/month. At scale, this becomes a significant line item.
  • Media transfer and storage are metered separately (~$0.12/GB transfer, ~$0.05/GB storage) — not included in the base plan.
  • No end-to-end encryption for group chats.
  • Data lives on Stream’s infrastructure. You get a DPA, EU datacenter, and SOC 2 compliance — but you don’t own the data in the way you would with self-hosted.
  • Migration away from Stream means rebuilding chat from scratch. The SDK, the data model, the real-time layer — all proprietary.
  • Concurrent connection pricing can be a surprise. Sunday morning with 5,000 users in the app = 5,000 concurrent WebSocket connections. If your plan includes 2,500, you pay overages.

The Sunday problem: Your peak concurrent connections happen predictably every Sunday. Stream charges for peak concurrent connections in a billing cycle. If 200 churches × 25 active chat users = 5,000 concurrent connections for 2 hours every Sunday, you pay for that peak all month. This pricing model punishes your exact usage pattern.

Verdict: Best for MVP speed. Worst for long-term cost structure given your Sunday-peak pattern.


Option B: Matrix (self-hosted Synapse)

How it works: You run your own Matrix homeserver (Synapse or an alternative). The app communicates with your server via the Matrix Client-Server API. Messages are stored in your PostgreSQL database. You own everything.

┌──────────┐         ┌──────────────┐         ┌─────────────┐
│  Mobile   │ ──HTTP──▶│  Synapse     │         │  Symfony     │
│  App      │◀──sync── │  (self-host) │◀──HTTP──│  Backend     │
│  (custom  │         │  PostgreSQL  │         │  (user mgmt) │
│   UI)     │         │  Media repo  │         └─────────────┘
└──────────┘         └──────────────┘
                      You host: server, DB, media,
                      push gateway (Sygnal)

The good:

  • Full data sovereignty. The GDPR story is unbeatable: “All your chat data is stored on European servers that we control. No third-party processor sees your messages.”
  • End-to-end encryption is built in and battle-tested (Olm/Megolm, same cryptographic ratchet as Signal). For pastoral conversations, this is a killer feature.
  • Open protocol — zero vendor lock-in. If Synapse doesn’t work, switch to Dendrite (Go) or Conduwuit (Rust).
  • Federation is available but can be disabled. For your use case: disable federation (churches don’t need to chat with random Matrix servers), which dramatically reduces complexity and resource usage.
  • Self-hosting in 2025 has become much easier. Docker Compose, Sliding Sync built-in, Element X client is fast.
  • Cost is purely infrastructure — at 50K MAU you’re paying ~€200-400/month for hosting vs. €1,500+ for Stream.

The bad:

  • No official React Native UI kit. You build all chat UI yourself — message bubbles, input bar, media viewer, thread view, reaction picker. This is weeks of work.
  • Synapse is written in Python (Twisted). It’s improved dramatically but still heavier on resources than a Go or Rust server. A self-hosted instance for a few hundred users uses ~222MB RAM, but this grows.
  • The database can become bloated. Synapse uses append-only state tables. Without maintenance (VACUUM, media cleanup cron jobs), PostgreSQL storage grows significantly even for small installations.
  • Multi-tenancy is awkward. Matrix doesn’t natively support “church A’s chats are isolated from church B’s chats” within one server. You’d need either: separate Synapse instances per denomination (ops overhead) or careful use of Matrix Spaces with access controls (complex).
  • Matrix Rooms have a complex state model (state events, power levels, canonical aliases) that you don’t need for simple church group chat. You’ll be fighting the protocol’s complexity for features designed for federation that you’ve disabled.
  • Push notifications require running Sygnal (Matrix’s push gateway) alongside Synapse — another service to maintain.

The multi-tenancy problem is serious. Your app serves multiple denominations. In Matrix, you’d need to either run one Synapse per denomination (operationally painful) or build an access-control layer on top of Matrix Spaces. Neither is clean. Matrix was designed for open federation, not for multi-tenant SaaS.

Verdict: Best GDPR and encryption story. But the multi-tenancy mismatch and missing React Native UI kit make it a heavy investment. Better suited if you were building a single-church app, not a multi-denomination platform.


How it works: Centrifugo handles the real-time transport (WebSocket connections, channel subscriptions, message delivery to online users). Your Symfony backend handles everything else: message storage, chat room management, permissions, offline delivery, push notifications.

┌──────────┐  WebSocket  ┌──────────────┐  HTTP API  ┌──────────────┐
│  Mobile   │ ──────────▶│  Centrifugo   │◀──publish──│  Symfony      │
│  App      │◀────────── │  (Go, self-   │            │  Chat Module  │
│           │  real-time  │   hosted)     │            │               │
│           │  messages   └──────────────┘            │  PostgreSQL   │
│           │                                          │  Redis        │
│           │ ──HTTP────────────────────────────────▶  │  S3 (media)   │
│           │  send msg, history, read receipts         └──────────────┘
└──────────┘

The architecture in detail:

  1. User opens a chat. Mobile app connects to Centrifugo via WebSocket (one connection for the entire app session, multiplexed across all subscribed channels).

  2. User sends a message. Mobile app sends HTTP POST to Symfony Chat API (not through Centrifugo). Symfony validates, stores in PostgreSQL, then publishes to Centrifugo via its server API. Centrifugo broadcasts to all online subscribers of that channel.

  3. Recipient is online. They receive the message via WebSocket in real-time (< 100ms).

  4. Recipient is offline. Symfony’s Notification service detects no active connection (via Centrifugo presence API or a heartbeat table). It queues a push notification via Firebase/APNs.

  5. Recipient comes back online. Mobile app requests message history from Symfony API (paginated, since last read message). Also subscribes to the Centrifugo channel to receive new messages in real-time. Centrifugo’s channel history feature can also recover messages missed during a brief disconnect.

  6. Typing indicators. Client publishes a typing event directly to Centrifugo (client-side publish, no server roundtrip needed). Ephemeral — never stored.

  7. Read receipts. Client sends HTTP PATCH to Symfony API with last_read_message_id. Symfony updates the read state and publishes an update to Centrifugo for real-time display.

What you build:

ComponentEffortComplexity
Chat data model (conversations, messages, participants, read state)1-2 daysLow
Chat REST API (send, history, mark read, create conversation)3-5 daysMedium
Centrifugo integration (publish, subscribe, auth proxy)2-3 daysLow-Medium
Offline detection + push notification fallback2-3 daysMedium
Media upload for chat (images, files via S3)1-2 daysLow
React Native chat UI (message list, input, thread, media viewer)5-10 daysHigh (most effort here)
Typing indicators (via Centrifugo client-side publish)0.5 dayLow
Read receipts (API + real-time update)1-2 daysMedium
Total~15-28 days

The good:

  • Full control, full sovereignty, zero vendor lock-in. Centrifugo is MIT-licensed.
  • Centrifugo is battle-tested at scale: 1M+ concurrent connections demonstrated. Written in Go, single binary, tiny footprint.
  • Your existing Symfony backend handles all business logic. Centrifugo is just the transport.
  • Cost is purely infrastructure. Centrifugo itself uses minimal resources (a few hundred MB RAM for thousands of connections).
  • Centrifugo has official JavaScript/React Native SDK, plus Swift and Java SDKs.
  • Integrates with your existing auth (JWT-based — Symfony generates the token, Centrifugo validates it).
  • The same Centrifugo instance handles Sunday live features (polls, prayer wall, slide sync) — one real-time infrastructure for everything.
  • Centrifugo supports channel history with recovery — if a client briefly loses connection, it can catch up on missed messages without hitting your API.
  • Scales horizontally with Redis. Add more Centrifugo nodes, point them at the same Redis, done.
  • Centrifugo’s PostgreSQL async consumer supports the transactional outbox pattern — you can publish events from your Symfony app via PostgreSQL and Centrifugo picks them up, giving you exactly-once delivery guarantees.

The bad:

  • You build the chat UI from scratch. No pre-built message list, no reaction picker, no thread view. This is the biggest time investment.
  • Delivery guarantees are your responsibility. If a user is offline and the push notification fails, you need retry logic.
  • Read receipts at scale can be expensive (N participants × M messages = N×M state updates). Need careful design.
  • No built-in moderation. You build flagging, blocking, content filtering yourself.
  • No E2E encryption out of the box. Adding it later (Signal protocol / MLS) is a major engineering effort.

The Centrifugo PRO angle: Centrifugal Labs offers a PRO version with additional features including push notification integration (FCM/APNs directly from Centrifugo), user online/offline status tracking, and more. Worth evaluating — it could save you from building the push fallback yourself.

Verdict: The sweet spot for your situation. You’re already building with Symfony. Centrifugo is the real-time sidecar that PHP lacks. The chat UI investment is real but one-time. And the same infrastructure serves chat, Sunday live features, and any future real-time needs.


Option D: Full Self-Built (WebSocket Server + Everything)

Not recommended. For completeness: you’d build a WebSocket server in Go/Node.js, implement pub/sub, message storage, delivery guarantees, presence, read receipts, push integration, media handling, and E2E encryption from zero.

Estimated effort: 3-6 months for a team of 2-3. For a solo founder, this is the “never ship” option.

The only scenario where this makes sense is if you’ve already launched, have revenue, have a team, and have outgrown Centrifugo’s model. That’s a Phase 3+ decision.


Phase 1 — MVP (Month 1-4): No chat, or minimal chat

Be honest: chat is not your MVP differentiator. News feeds, groups, events, and giving are. Ship without chat, or with a minimal “group announcements” channel (one-directional, uses Centrifugo pub/sub, no message history needed).

If you absolutely need two-way chat at MVP, use Stream with Maker plan ($100/mo credit, free for < $10K revenue). Get to market fast. Abstract it behind an interface:

// ChatProvider interface — swap implementations later
interface ChatProvider {
  connect(userId: string, token: string): Promise<void>;
  sendMessage(channelId: string, message: MessageInput): Promise<Message>;
  subscribeToChannel(channelId: string, onMessage: (msg: Message) => void): Unsubscribe;
  getHistory(channelId: string, before?: string, limit?: number): Promise<Message[]>;
  markRead(channelId: string, messageId: string): Promise<void>;
  disconnect(): Promise<void>;
}

// Phase 1: Stream implementation
class StreamChatProvider implements ChatProvider { ... }

// Phase 2: Centrifugo + custom implementation
class CentrifugoChatProvider implements ChatProvider { ... }

Phase 2 — Growth (Month 5-8): Migrate to Centrifugo + Custom

Once you have paying churches, revenue, and validated that chat is actually used (measure this!), build the Centrifugo-based chat:

  1. Set up Centrifugo alongside your Symfony stack.
  2. Build the Chat module in Symfony (message storage, history API, read state).
  3. Build the React Native chat UI (this is the big one — 5-10 days).
  4. Implement the CentrifugoChatProvider behind the same interface.
  5. Migrate existing Stream conversations to your own storage (export via Stream API).
  6. Flip the switch. Mobile app uses the new provider. Stream subscription cancelled.

Phase 3 — Maturity (Month 12+): Encryption & Advanced Features

  • Evaluate E2E encryption for 1:1 pastoral chats (MLS protocol or adapted Olm).
  • Add voice messages.
  • Add thread support.
  • Add AI-powered moderation.
  • Consider Centrifugo PRO for built-in push notifications.

The Chat Data Model (for Centrifugo + Custom)

-- Conversations (channels)
CREATE TABLE chat_conversations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    type VARCHAR(20) NOT NULL, -- 'group_chat', 'direct', 'event_chat'
    church_id UUID NOT NULL REFERENCES churches(id),
    group_id UUID REFERENCES groups(id),        -- linked group (optional)
    event_id UUID REFERENCES events(id),        -- linked event (optional)
    name VARCHAR(255),                           -- display name for group chats
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_message_at TIMESTAMPTZ,                 -- for sorting conversations
    metadata JSONB DEFAULT '{}'                  -- flexible extra data
);

-- Conversation participants
CREATE TABLE chat_participants (
    conversation_id UUID NOT NULL REFERENCES chat_conversations(id),
    user_id UUID NOT NULL REFERENCES users(id),
    role VARCHAR(20) NOT NULL DEFAULT 'member',  -- 'admin', 'member'
    joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_read_message_id UUID,                    -- read receipt tracking
    last_read_at TIMESTAMPTZ,
    notification_preference VARCHAR(20) DEFAULT 'all', -- 'all', 'mentions', 'none'
    PRIMARY KEY (conversation_id, user_id)
);

-- Messages
CREATE TABLE chat_messages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    conversation_id UUID NOT NULL REFERENCES chat_conversations(id),
    sender_id UUID NOT NULL REFERENCES users(id),
    content TEXT,                                  -- message text
    content_type VARCHAR(20) DEFAULT 'text',      -- 'text', 'image', 'file', 'voice'
    media_url VARCHAR(500),                        -- S3 URL for media messages
    thread_parent_id UUID REFERENCES chat_messages(id), -- thread support
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    edited_at TIMESTAMPTZ,
    deleted_at TIMESTAMPTZ,                        -- soft delete
    metadata JSONB DEFAULT '{}'                    -- reactions, link previews, etc.
);

-- Indexes for performance
CREATE INDEX idx_messages_conversation_created 
    ON chat_messages(conversation_id, created_at DESC);
CREATE INDEX idx_messages_thread 
    ON chat_messages(thread_parent_id) WHERE thread_parent_id IS NOT NULL;
CREATE INDEX idx_participants_user 
    ON chat_participants(user_id);
CREATE INDEX idx_conversations_church_last_message 
    ON chat_conversations(church_id, last_message_at DESC);

Centrifugo channel naming convention:

chat:conversation:{conversation_id}     — main channel for a conversation
chat:typing:{conversation_id}           — typing indicators (ephemeral)
chat:presence:{conversation_id}         — online status

Message flow:

1. Client → POST /api/chat/{conversationId}/messages
2. Symfony validates, stores in PostgreSQL
3. Symfony → Centrifugo HTTP API: publish to "chat:conversation:{id}"
4. Centrifugo → WebSocket → all online participants
5. For offline participants: Symfony Messenger worker → push notification

Cost Comparison at Scale (5-Year Projection)

Assuming growth: Year 1 = 5K MAU, Year 2 = 20K, Year 3 = 50K, Year 4 = 100K, Year 5 = 200K.

YearMAUStream (estimated)Matrix (self-hosted)Centrifugo + Custom
15,000Free (Maker) → ~€200/mo~€50/mo~€0 (existing server)
220,000~€800/mo~€150/mo~€50/mo
350,000~€2,000/mo~€350/mo~€120/mo
4100,000~€4,000/mo~€600/mo~€250/mo
5200,000~€7,000+/mo~€1,000/mo~€400/mo
5-Year Total~€250,000~€38,000~€15,000

The engineering investment for Centrifugo + Custom is ~15-28 days upfront (Phase 2). At a fully loaded developer cost of ~€500/day, that’s €7,500-14,000. The break-even vs. Stream happens within Year 2.


Final Recommendation

Phase 1 (MVP):     Skip chat, or use Stream Maker plan behind an abstraction.
Phase 2 (Growth):  Build Centrifugo + Custom chat. Migrate off Stream.
Phase 3 (Mature):  Add E2E encryption, voice messages, AI moderation.
                   Evaluate Centrifugo PRO for push integration.

Centrifugo is the long-term answer. It aligns with everything else in your architecture: self-hosted, PHP-backend-friendly (HTTP API for publishing), scales with Redis, single binary deployment on Hetzner, and the same infrastructure powers chat AND Sunday live features.

The key discipline: abstract the chat provider from day one, even if you start with Stream. The ChatProvider interface ensures the migration is a backend swap, not a rewrite.


Status: Architecture Decision — Pre-Implementation