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.
Multi-File Upload System
Four decisions in
the file pipeline.
Messages needed to carry multiple attachments (images, documents, video) uploaded straight from the browser, without one failed file blocking the rest of the batch.
Working as intended
The app server’s request-handling capacity is never spent on file
transfer, only short-lived signed URLs and small JSON confirmations.
Per-file failure is isolated by design: a caught error marks one file
failed and the loop moves on rather than aborting the batch.
Known gap · reconciliation
Uploaded files are matched back to pending entries by
name === attachment.name && size === attachment.size,
not by the unique id each pending file already carries. Two files
with the same name and size in one batch can collide.
POST /attachments/complete confirms the
upload and creates the DB record.
socket.emit("subscribe_attachment_updates",
{ attachmentIds, requestCurrentStatus: true }), then listens for
attachment_status_update and
attachment_processing_complete. The tracked ID list is
sorted and diffed so the subscription only re-fires when the actual
set of attachments changes, not on every render.
useFileUpload hook uploads files in a batch
sequentially, not in parallel. Each file moves through
pending → uploading → completed | failed
independently: a failed file is caught, marked
failed with its error, and the loop continues to the
next file instead of aborting the batch. hasOnlyFailedFiles
distinguishes “every file failed” (blocks send) from
“some failed” (allows sending the successful subset).
retryFailedUploads and retrySpecificFile
avoid re-uploading a whole successful batch over one failure.
validateFiles() also checks total file count against
maxFilesPerMessage and combined size against
maxTotalSize up front, before any file starts
uploading.
Problems Hit
Nine 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.
tenant-filter-plugin-missing-findbyid-from-scoped-method-list
tenantId scoping into most query methods, but findById wasn’t on the list. Any Model.findById(...) call anywhere in the codebase bypassed tenant isolation entirely, a real cross-tenant data leak live for about a week between the rollout and the fix.
findById to the plugin’s auto-scoped method list. Same rollout also fixed an AsyncLocalStorage ordering bug where the tenant-context middleware called next() after its .run() callback had already returned, so downstream handlers executed with no tenant context active at all.
presence-manager-tenantid-bound-to-singleton-constructor
PresenceManager took tenantId as a constructor parameter for one day, then it was reversed.
PresenceManager is instantiated once as a process-wide singleton in server.ts, not one instance per tenant. Binding tenantId at construction time was structurally incompatible with that pattern from the moment it was added.
processHeartbeat(userId, tenantId, ...)), with Redis keys built per-call as presence:${tenantId}:${userId}. The reversal was incomplete, though: the class still declares private tenantId, never assigned, and two call sites in processHeartbeat’s “already online, status changed” branch still reference this.tenantId instead of the local parameter, a narrow, still-live bug limited to that one branch.
postmessage-init-chat-race-condition-on-iframe-mount
INIT_CHAT message via postMessage, with the iframe exchanging it via a REST call. The iframe’s postMessage listener wasn’t guaranteed to be mounted before the parent sent the init message, a cross-frame mount-timing race, not a security restriction.
postMessage is still used today for CHAT_REAUTH and presence events, just not the initial handoff.
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.
Two smaller gaps in the upload system are worth naming honestly rather
than glossing over. First, reconciling completed uploads back to
pending files matches on file.name === attachment.name && file.size === attachment.size
since two different files in the same batch with an identical name and
size (two screenshots both called image.png
at the same resolution) could collide during that step. Each pending
file already carries a unique id; using that as the correlation key
instead would remove the edge case entirely. Second, attachment
deletion removes the main S3 file, then attempts thumbnail deletion in
a separate try/catch that only logs on failure rather than throwing
a deliberate choice so a thumbnail-delete failure doesn’t
block the overall delete, but it means orphaned thumbnails can
accumulate in S3 with no automatic sweep behind it.