Real-Time
Chat
WhatNextPlease went from zero chat capability to a fully embedded, SSO-authenticated real-time messaging system their users never have to log into separately — and the same infrastructure is ready to onboard the next tenant with a credential form and a database row.
The Problem
Embed chat
without building auth twice.
The client needed a real-time chat application that could be embedded inside WhatNextPlease and eventually offered to other third-party clients. There was no existing chat infrastructure — the starting point was a greenfield design discussion.
The core constraints: keep cloud costs low for an initial ~100-user scale, avoid complex distributed infrastructure, and ensure the chat could be embedded into external apps without requiring those apps to build their own auth system. A user already logged into WhatNextPlease should drop into chat with no second login prompt.
Requirements I Gathered
Six questions
that shaped the infra.
Every infra decision traces back to an answer from this list:
-
What’s the realistic concurrent user ceiling?
Answered: ~100 users. This single number drove the entire infrastructure simplification — single Node.js process, no message queue, no separate presence server.
→ Drove: single-server architecture decision
-
Single-tenant or multi-tenant from day one?
Multi-tenant from day one. This shaped every MongoDB index and middleware decision — all 9 models needed
tenantIdscoping.→ Drove: AsyncLocalStorage plugin + compound index design
-
How will external apps authenticate their users into the chat?
HMAC-signed SSO tokens — replacing an earlier shared-login idea that would have required external apps to know internal credentials.
→ Drove:
verifySSOTokenmiddleware + 3-step tenant registration -
Where do tokens live in an iframe context?
localStoragefallback, not cookies — discovered during integration. Third-party cookies are blocked by default in most browsers in cross-site iframe contexts.→ Drove: token-in-URL-query-param SSO approach
-
Does the embedding app need notifications when the chat iframe isn’t visible?
Yes. The parent app needed unread badge counts even when the chat panel was collapsed or hidden.
→ Drove:
ChatAppMessengerpostMessage bridge architecture -
Should “delete conversation” be permanent or reversible?
Per-user soft delete with restore-on-new-message — not discussed initially, emerged from a product review. If User B sends a new message to User A who deleted the conversation, only messages after the deletion should be restored.
→ Drove:
deletedBy[]+deletedAtmap +visibleAfterfilter design
Architecture Decisions
Five calls that
shaped the system.
postMessage sequencing.
EMBED_READY before the parent had
attached its message listener, or hasSentAuth.current
persisted across remounts.
SameSite/Partitioned
restrictions in cross-site iframe context).
AsyncLocalStorage and
injects tenantId automatically at the query level.
Compound indexes replace global unique indexes to prevent
cross-tenant key conflicts.
tenantId scoping without requiring
every query to manually include it. Manual filtering is error-prone
and easily missed.
@yourorg/chat-sdk-core + @yourorg/chat-sdk-react
in a chat-sdk/ npm workspace) designed for the
long-term replacement.
Problems Hit
Five bugs worth
writing down.
fargate-spot-interruption-breaking-all-socket-connections
cross-tenant-duplicate-key-errors-on-fresh-local-database
ChannelMember.
ChannelMember model was missing tenantId in its schema field definition. The tenantIsolationPlugin tried to inject it pre-validate, but Mongoose strict mode stripped unknown fields — so docs saved without tenantId. The global unique index on channelId + userId then correctly rejected the second doc.
tenantId explicitly to the ChannelMember schema. Added migration_001b_drop_old_tenant_indexes to prevent recurrence after local database prunes.
hasSentAuth.current-flag-surviving-iframe-remounts
EMBED_READY fired but WNP skipped sending credentials because hasSentAuth.current was still true.
hasSentAuth.current = false both in handleLoad (on iframe reload) and in the useEffect cleanup function.
deletedAt-$unset-prematurely-removing-message-filter-boundary
restoreForParticipants was incorrectly calling $unset deletedAt, which is only supposed to happen in restoreForUser. Without the timestamp, the visibleAfter filter had no boundary to apply.
$unset deletedAt moved exclusively to restoreForUser. restoreForParticipants only removes the user from deletedBy array. Restore call moved to after message creation so the new message triggers the restore.
cors-failure-on-sso-init-from-iframe
POST /tenants/sso/init from the iframe returned a CORS error.
CORS_ORIGIN was a static environment variable. The iframe’s request origin didn’t match the hardcoded value.
allowedOrigins lookup is the permanent fix — still pending.
What Shipped
The full scope
of what went live.
Core Messaging
-
Real-time DMs + channels
Node.js/Express + Socket.IO backend. Full direct message and channel message delivery with instant broadcast.
-
Presence system
Redis-backed online/offline status. Socket.IO-broadcast heartbeat at 30s interval. Visible across all connected users.
-
Unread message counts
Redis-keyed per user/conversation. Real-time Socket.IO updates whenever a new message arrives in any conversation.
-
Rich text editor
Plate.js with full toolbar for message composition. Separate
RendererKit(toolbar-free) for message display.
Authentication & Multi-tenancy
-
SSO authentication
HMAC-signed token flow.
verifySSOTokenmiddleware. Auto-create/sync users on first login from any tenant. -
Tenant registration system
3-step flow: admin credential generation → client registration (
registrationToken+sharedSecret) → domain verification. -
MongoDB tenant isolation
Plugin on all 9 models injects
tenantIdviaAsyncLocalStorage. Compound indexes prevent cross-tenant key conflicts. Migration scripts001–004.
Embedding & Integration
-
Two deployment routes
Next.js frontend with
/embedroute for iframe deployment and/chatfor standalone. Same codebase, different entry points. -
ChatAppMessenger postMessage bridge
CHAT_AUTH_ERROR/CHAT_REAUTHprotocol for session recovery without page reload. Unread badge sync to parent app.
Files & Storage
-
File attachments
S3 presigned URL upload flow. Thumbnail generation. CDN delivery via CloudFront. Scoped per tenant, per conversation.
-
“Delete Chat for Me”
deletedBy[]+deletedAtmap onDirectMessage.visibleAfterfilter in message queries. Restore-on-new-message (messages after deletion only).
Infrastructure
-
AWS CDK stack
ECS Fargate (standard), Network Load Balancer, ECR, S3 + CloudFront for media, Secrets Manager. All infrastructure as code.
-
Redis layer
Presence state and unread counts. Decoupled from MongoDB so socket-heavy operations don’t compete with message persistence writes.
What I’d Do Differently
CORS policy
should have been data.
Start with dynamic CORS validation — database-driven
allowedOrigins
per tenant — rather than a static environment variable. The env var was a
reasonable shortcut early on, but it became a blocker the first time a real
tenant tried to connect from their own domain. Fixing it requires an ECS
redeploy rather than a database update. For a system explicitly designed for
multi-tenant embedding, the CORS policy should have been data-driven from
the first deploy.