← Projects

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.

Client Hill Country Coders
Role Full Stack Developer
Date February 2025
Chat interface showing direct messages, channel list, and file attachments
Rich text editor with message thread and user presence indicators
Channel view showing real-time message delivery and unread counts

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:

  1. 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

  2. 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 tenantId scoping.

    → Drove: AsyncLocalStorage plugin + compound index design

  3. 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: verifySSOToken middleware + 3-step tenant registration

  4. Where do tokens live in an iframe context?

    localStorage fallback, 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

  5. 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: ChatAppMessenger postMessage bridge architecture

  6. 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[] + deletedAt map + visibleAfter filter design

Architecture Decisions

Five calls that
shaped the system.

arch-01 single-nodejs-server-instead-of-microservices
Decision One Express + Socket.IO process handles messaging, presence, auth, and file uploads. No service mesh, no message queue.
Why At 100 users, the operational cost of service discovery, message queues, and separate presence servers exceeds the benefit by an order of magnitude.
Rejected RabbitMQ (adds ~$30–50/month, zero benefit at this scale), Zookeeper (overkill for single-node discovery), Cassandra (MongoDB sufficient for expected write volume).
arch-02 fargate-standard-not-spot-for-production
Decision Standard ECS Fargate for production. Fargate Spot retained only for dev/staging environments.
Why A Spot interruption in staging dropped all active socket connections mid-session. For a user-facing real-time backend, connection drops are unacceptable, not a graceful degradation.
Rejected Fargate Spot for production: initially chosen for ~70% cost savings, abandoned after the staging interruption incident.
arch-03 sso-via-hmac-signed-tokens-in-iframe-url-query-params
Decision HMAC-signed token passed as a URL query param to the iframe. Auth starts the moment the socket connects, no timing dependency on postMessage sequencing.
Why The original postMessage-based auth flow had race conditions: the iframe fired EMBED_READY before the parent had attached its message listener, or hasSentAuth.current persisted across remounts.
Rejected postMessage-first auth (implemented, debugged, and replaced). Cookie-based SSO (blocked by SameSite/Partitioned restrictions in cross-site iframe context).
arch-04 mongodb-tenant-isolation-via-asynclocalstorage-plugin-and-compound-indexes
Decision A Mongoose plugin reads from AsyncLocalStorage and injects tenantId automatically at the query level. Compound indexes replace global unique indexes to prevent cross-tenant key conflicts.
Why All 9 models needed tenantId scoping without requiring every query to manually include it. Manual filtering is error-prone and easily missed.
Rejected Separate database per tenant (too expensive at this stage). Query-level filtering without a plugin (error-prone, any missed query leaks cross-tenant data).
arch-05 sdk-architecture-planned-iframe-integration-shipped
Decision Iframe integration shipped for the immediate need. SDK (@yourorg/chat-sdk-core + @yourorg/chat-sdk-react in a chat-sdk/ npm workspace) designed for the long-term replacement.
Why The iframe solved the immediate integration need. The SDK eliminates cookie/localStorage restrictions, postMessage race conditions, and CORS complexity, but that’s future scope.
Rejected Mixing the SDK workspace into existing frontend/backend repos now: would have caused lockfile interference (both use npm, not pnpm).

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.

Browser useFileUpload batch file1.png · completed file2.png · failed file3.pdf · uploading one failure doesn’t block the batch API attachments controller extension + size check, sync issues presigned URL no Lambda in this path presigned URL exchange validated in-process first POST /complete status: ready, immediately S3 bucket presigned PUT target PUT file bytes directly bypasses the API entirely Socket.IO push: syncs status to other open sessions
The browser never sends file bytes to the API: it exchanges JSON for a presigned URL, then PUTs directly to S3. There is no Lambda in this path today. The only Lambda this system ever had was a virus-scan step (extension blocklist, size check) that ran for about a month before being replaced with the same check running in-process on the API, synchronously, before a presigned URL is ever issued. Uploads run sequentially, not in parallel, so a failed file (drawn dashed above) is caught and skipped while the rest of the batch continues.

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.

upl-01 direct-to-s3-presigned-uploads-instead-of-server-proxy
Decision Browser validates size/MIME/extension client-side, generates a thumbnail and compresses the image, then requests a presigned S3 PUT URL and uploads directly: a second presigned URL covers the thumbnail. POST /attachments/complete confirms the upload and creates the DB record.
Why The app server never touches file bytes, only short-lived signed URLs and small JSON completion payloads. Request-handling capacity doesn’t scale with upload volume.
Rejected Proxying file bytes through the Express server: couples server capacity to upload throughput for no benefit.
upl-02 virus-scan-lambda-replaced-with-in-process-sync-validation
Decision A Lambda function did exist in this pipeline, but only briefly and only for virus scanning: an extension blocklist plus a size check, added May 2025 and deleted a month later once that same check was rewritten as synchronous, in-process validation on the API. Thumbnails were never part of what it did.
Why Extension and size checks are cheap enough to run inline before issuing a presigned URL. There was no real reason to pay for an async round-trip through Lambda for a check that fast.
Rejected Keeping the validation on Lambda once it stopped buying any latency or isolation benefit over doing the same check in-process.
upl-03 socket-io-status-push-instead-of-polling
Decision The frontend subscribes per-attachment: 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.
Why Socket.IO was already the transport for messaging and presence since reusing it for attachment status avoided adding a polling loop on top of an already-open connection.
Rejected Client-side polling of an attachment status endpoint: extra requests, extra latency on status changes, no reuse of the existing socket connection.
upl-04 sequential-batch-upload-with-per-file-failure-isolation
Decision The 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.
Why A tradeoff for connection/server stability over raw upload speed. For a handful of attachments per chat message, sequential upload has a low UX cost and the reliability gain is real. validateFiles() also checks total file count against maxFilesPerMessage and combined size against maxTotalSize up front, before any file starts uploading.
Rejected Parallel batch upload: higher risk of overwhelming the connection with N simultaneous large uploads for marginal speed gain at chat-message scale.

Problems Hit

Nine bugs worth
writing down.

fargate-spot-interruption-breaking-all-socket-connections

Symptom Task stopped with “Your Spot Task was interrupted.” All connected users lost their socket sessions simultaneously.
Root cause Fargate Spot capacity reclaimed by AWS. No graceful connection migration was in place for long-lived WebSocket connections.
Fix Switched production to standard Fargate. Spot retained only for dev/staging where connection drops are acceptable.

cross-tenant-duplicate-key-errors-on-fresh-local-database

Symptom Creating a second account locally threw duplicate key errors on ChannelMember.
Root cause The 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.
Fix Added 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

Symptom After an auth timeout and page remount, EMBED_READY fired but WNP skipped sending credentials because hasSentAuth.current was still true.
Root cause The flag was set on first auth attempt. The cleanup function didn’t reset it on unmount, so it persisted across React remounts of the component.
Fix Reset hasSentAuth.current = false both in handleLoad (on iframe reload) and in the useEffect cleanup function.

deletedAt-$unset-prematurely-removing-message-filter-boundary

Symptom When User B sent a new message to User A (who had deleted the conversation), all previous messages reappeared, not just messages after the deletion.
Root cause 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.
Fix $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

Symptom POST /tenants/sso/init from the iframe returned a CORS error.
Root cause The backend’s CORS_ORIGIN was a static environment variable. The iframe’s request origin didn’t match the hardcoded value.
Fix Corrected allowed origin in env (immediate fix). Identified architectural gap: dynamic per-tenant CORS validation via database-driven allowedOrigins lookup is the permanent fix, still pending.

tenant-filter-plugin-missing-findbyid-from-scoped-method-list

Symptom No error, no report: caught during review of the multi-tenant rollout diffs, not by a failing test or a user.
Root cause The Mongoose tenant-filter plugin auto-injected 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.
Fix Added 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

Symptom PresenceManager took tenantId as a constructor parameter for one day, then it was reversed.
Root cause 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.
Fix Tenant now passed as an explicit parameter on every method call (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

Symptom Users would intermittently see “Authentication timeout” on the embedded chat: the auth handshake never completed.
Root cause Not CORS or cookies, despite that being the first assumption. The original flow had the parent send an 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.
Fix Replaced the postMessage handoff entirely with the token-in-URL-query-param approach (arch-03): SSO token and signature authenticated over the Socket.IO handshake itself, so auth rides on an already-established connection instead of assuming listener readiness. 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. verifySSOToken middleware. 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 tenantId via AsyncLocalStorage. Compound indexes prevent cross-tenant key conflicts. Migration scripts 001004.

Embedding & Integration

  • Two deployment routes

    Next.js frontend with /embed route for iframe deployment and /chat for standalone. Same codebase, different entry points.

  • ChatAppMessenger postMessage bridge

    CHAT_AUTH_ERROR / CHAT_REAUTH protocol 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[] + deletedAt map on DirectMessage. visibleAfter filter 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.

Need infrastructure
that scales with you?