What happens when you open the page
This is the normal entry path for a creator with existing conversations, after the route chunk has loaded and the user profile is in the store.
The constructor waits for the profile in the NgRx store, then calls fetchInitialData().
GET api/get-chatGroups-and-users fetches the whole inbox with no limit, behind the full-screen global loader. ~300 ms
searchResults() sorts each tab again after every single push, then picks the first conversation.
POST api/getpreviousMessagesFromHttp fetches that conversation's history. JSON.parse runs on each message's message string, and moment.js formats every date. ~300 ms
getUserEmail() sets msgrightLoader = true and waits in a setTimeout for no reason. 1000 ms
POST api/chat-user-validation fetches send permissions (paid DM, tip required, guaranteed reply). ~300 ms
Only now does msgrightLoader go false. The template hides both the history and the composer until then.
scrollToBottom() jumps to the latest message after another timer. 500 ms
That comes to roughly 1.9 s until the history shows and 2.4 s until it settles, measured from the moment the component starts. Chunk download, guards and the profile request all come before that. The 1.5 s of timers is there on every network, even a fast one.
Direct links (?user=, history.state.user_key, tip-back id) add a GET user/:name lookup and sometimes a group-creation call on top of this.
The biggest costs, ranked
1. Fixed timers on the critical path frontend, small
The 1000 ms setTimeout in getUserEmail() exists only to read messageList[0] and work out who the other participant is. We already know the answer: the inbox row we clicked carries users[0].gsi4pk. The 500 ms scroll timer covers the case where the list hasn't rendered yet. afterNextRender does that job exactly.
2. Three serial round trips where one or two would do frontend + backend
History and permissions both depend only on the selected conversation, so they can run in parallel. The code can't do that today because getUserChatMessage() and checkNewUserValidation() share one cancel subject, userValidationCancel$. Starting either one cancels the other one in flight. With a combined backend endpoint, the inbox response could also carry the first thread, which leaves a single round trip.
3. One loader flag gates everything frontend, small
@if (messageList?.length && !msgrightLoader) hides history and !msgrightLoader hides the composer. Ten places in the component set that flag, and getUserChatLogs's complete handler clears it while history can still be loading. Reading messages doesn't need permissions. Only sending, tipping and unlocking do.
4. About 630 KB gzipped of route JavaScript, most of it unused at first paint frontend, medium
The messages chunk (chunk-ASVT5GJH.js, 71 KB gz) statically pulls in 56 more chunks that the app shell hasn't loaded yet. That's 2.7 MB raw and 630 KB gzipped. The big pieces:
| What | Gzipped | Needed when |
|---|---|---|
Emoji picker (@ctrl/ngx-emoji-mart) | 92 KB | User clicks the emoji button |
Angular Material, pulled in by ngx-audio-player | 71 KB | An audio message scrolls into view |
| Dynamic form builder, circle progress, CDK drag | 61 KB | Automated messages tab, upload progress, reordering attachments |
| ngx-bootstrap datepicker and timepicker | 57 KB | Automated message and new message modals |
| PrimeNG components | 47 KB | Modals and multiselects |
Merchandise, portfolio and event share modals, app-table | ~125 KB | User opens a share dialog or the automated tab |
All of this sits in the component's imports array or gets opened through a static class reference (modalService.show(NewMessageModelComponent)). Up to ~450 KB looks deferrable. Shared chunks can keep code eager, so confirm against --stats-json before claiming the saving.
A separate problem affects every page: main statically imports 146 chunks, about 1.74 MB gzipped. Fingerprints suggest the video player (IVS/video.js) and upload code are in there. A hard refresh on /messages pays for all of it. That belongs in its own app-shell task, but it caps how fast a cold load of this page can get.
5. Every incoming message reloads the whole inbox frontend + backend
receiveMessage() calls getUserChatLogs(false, '') for every websocket privateMessage. That refetches every conversation. The response handler also fires chat-user-validation again, which cancels any history request in flight through the shared subject. Rows get rebuilt as new objects, and track uses object identity (trackItem returns the item), so Angular destroys and recreates every row's DOM. A busy creator's inbox keeps churning after load.
6. Change detection runs twice per cycle frontend, small to medium
ngAfterViewChecked() calls detectChanges(), so every cycle re-checks the 3,100-line template a second time. That includes getCurrentMessages() allocating a new filtered array per tab, and getTimeDiff() running moment.js for every row. Timers, websocket pings and typing all trigger cycles. After the initial render this costs main-thread time rather than network time, and it shows up as slow typing and slow switching on low-end phones.
Frontend changes
Ordered so each phase ships on its own and gets measured before the next.
Phase 1. Stop waiting (1 to 2 days, no backend)
- Delete the timer in
getUserEmail(). When a conversation is selected, take the participant from the inbox row and start history and validation in two independent streams. Render each result when it arrives. - Give history and validation their own
switchMapstreams keyed bygroupId. Switching from A to B cancels A's requests only, and a response for A can never overwrite B. - Split
msgrightLoaderintohistoryLoadingandpermissionsLoading. Show history as soon as it arrives. Render the composer with send, tip and unlock disabled until permissions arrive. If validation fails, keep authorized history readable, leave actions disabled, and offer retry. The server still enforces payment rules. - Stop calling
checkNewUserValidation()from the inbox response handler. Permissions belong to the selected thread, not the list. - Replace the full-screen loader on the first inbox call with a list skeleton built from
app-skeleton, following.guides/skeleton-guide.md. Replace the 500 ms scroll timer withafterNextRender. - Remove
ngAfterViewChecked(). Anything that relied on it is a missed update that needs a targetedmarkForCheck(). - In
searchResults(), partition first and sort each tab once. Track conversations bygroupIdand messages bysk, or byuinqueIdfor optimistic sends. - Give search a stream with
debounceTime,distinctUntilChangedandswitchMap, so a slow old query can't overwrite a newer one.
Expected: history readable at about 0.6 s after component start instead of 1.9 s, with no backend change.
Phase 2. Load less code (2 to 4 days)
- Wrap the emoji picker in
@defer (on interaction)behind a small placeholder button. - Render audio messages with a native
<audio>placeholder and@defer (on viewport)forngx-audio-player. Better still, replace it, since it drags in Angular Material for one slider. - Put the automated messages tab (
app-table,AutomatedMessageModalComponent, dynamic forms) behind@defer (when active === SET_UP_AUTOMATED_MESSAGE). - Open
NewMessageModelComponent,EditParticipantsModalComponent,MessagePriceModelComponent, the share modals and the image overlay withawait import(...)at click time. Remove them fromimports, and change theviewChild.requiredqueries that point at them to optional ones. - Stop calling
lazyCssLoader.loadForVideoPlayer()in the constructor. Load it when the first video renders. Use posters for offscreen videos and instantiate playback on demand; current video elements preload metadata. Many photos already use lazy loading, so check actual requests before changing them. - Prefetch the messages chunk when the sidebar Messages link is hovered or focused, or during idle time after login.
NoPreloadingstays for every other route.
Expected: route JavaScript beyond the shell drops from 630 KB to around 200 KB gzipped. That helps first visits and phones most.
Phase 3. Start earlier and remember (3 to 5 days, pairs with backend)
- Move inbox and thread state into a
providedIn: 'root'MessagesStoreservice. Kick off the inbox request when navigation to/messagesstarts or the link is pressed, so the request runs while the chunk downloads. - Keep a bounded cache of the inbox and recent threads in
MessagesStore. Scope entries to account, tenant, permission context, tab, search, and conversation. Paint cached history, then revalidate and merge. Clear on logout, account switch, or tenant change; reconcile edits, deletions, read receipts, and permission changes. - Patch a single row on each websocket message instead of refetching the inbox. This needs the event payload described in the backend section below.
- Turn history pagination back on.
onUp()is commented out, although requests accepttimeStamp. Add loading, end-of-history, error/retry, and duplicate-page guards. Preserve the first visible message when prepending. Scroll to the bottom only on initial open or when the reader is already near it. - If inboxes regularly exceed about 100 rows, virtualize the conversation list with CDK virtual scroll. Leave the message list alone until profiling says otherwise, because variable-height media makes virtualization tricky there.
Backend changes
These give the biggest gain for large inboxes and busy creators, and they unlock the one-round-trip path. Each one can ship behind a new version while the old endpoint keeps working.
| Change | Proposal | What it removes |
|---|---|---|
| Paginated inbox | GET api/v2/inbox?tab=featured|general|bulk&limit=30&cursor=. Returns slim rows (group ID, display name, avatar thumbnail, last message preview, timestamp, unread count, featured or tip flags) plus unread totals for each tab, independent of loaded rows. Page boundaries must not change default-thread selection. Broadcast and mailing-list rows return a member count, not the member list. | Downloading every conversation and every mailing-list member on each visit. Client-side sorting and classification. |
| Thread bootstrap | POST api/v2/thread with groupId. Returns the newest 30 to 50 messages with body as structured JSON, an older-page cursor, the participant, and the same data chat-user-validation returns today. | One round trip, the per-message JSON.parse, and the participant guesswork that led to the timer. |
| First thread inline | Optional include=firstThread on the inbox call. The server picks the default conversation using the same rules as setInitalDisplay() and embeds its bootstrap. Use this only when measured round-trip savings justify the extra work; optional enrichment must not delay the inbox. | A second round trip on normal entry. Direct links skip it and call the thread endpoint directly. |
| Row deltas over websocket | Each privateMessage event carries the updated conversation summary (last message, timestamp, unread count, classification) plus a sequence number. On reconnect, ask for changes since the last acknowledged sequence. If it has expired, fetch a fresh first page. Deduplicate replayed events and history pages by message ID. | The full inbox refetch and the extra validation call on every incoming message. |
| Members on demand | GET api/v2/groups/:id/members?cursor= for the edit-participants modal and the broadcast header. | Mailing lists with thousands of fans bloating the inbox payload. |
| Cheap revalidation | An ETag on inbox and thread responses so a warm return gets 304. Confirm brotli or gzip is on at API Gateway or CloudFront for these JSON routes. Mark them Cache-Control: private, never shared. | Re-downloading unchanged data on every visit. |
| Server-side query work | Look at traces for get-chatGroups-and-users. If the handler rebuilds the inbox from message records or looks up each participant per row, keep a per-user conversation summary table updated on send, read, tip and membership changes, and batch the user lookups. | Server time that grows with inbox size. This is the part the frontend can't fix. |
Keep enforcing send, tip and unlock permissions server-side at action time. The permissions in the thread response are a UI hint that can go stale.
- Use stable, opaque cursors. Order history by timestamp plus unique message ID, and inbox summaries by activity plus conversation ID. Equal timestamps must not skip or repeat records. If DynamoDB is confirmed, use its query continuation keys.
- Remove duplicate backend work and run independent reads concurrently. Combining existing serial queries into one endpoint does not make them fast. Keep optional enrichment outside the first-paint path.
Expected effect
Estimates from the component starting to a readable conversation, with 300 ms per API call. Replace them with measured numbers after Phase 1.
| Stage | History readable | Composer usable | Why |
|---|---|---|---|
| Today | ~1.9 s | ~1.9 s | 3 serial calls, a 1 s timer, one shared loader |
| Phase 1 | ~0.6 s | ~0.6 s | Inbox, then history and validation in parallel |
| Phase 1 + thread bootstrap | ~0.6 s | ~0.6 s | Same wait, fewer requests, no client parsing |
| + first thread inline | ~0.3 s | ~0.3 s | One round trip |
| + early start and memory cache, warm return | < 100 ms | < 100 ms | Paint from memory, revalidate in background |
Phase 2 doesn't show up in this table because it happens before the component starts. It shortens chunk download and parse on the first visit to the page.
How to verify
- Add
performance.markcalls at route activation, inbox painted, history painted and composer enabled. Log them from a production build, not the dev server. Build with--max_old_space_size=10048. - Compare
ng build --stats-jsonbefore and after Phase 2, and check that no emoji, Material, datepicker or share-modal chunk loads until it's used. - Record Chrome traces on a mid-range Android profile with a throttled network, at least 10 runs per build, for an empty inbox, a 20-row inbox and a 500-row inbox.
- Test fast switching between conversations, overlapping searches, incoming messages during load, reconnects and direct links (
?user=, tip-backid,history.state.user_key). - Cover fan, creator, delegate, paid DM, tip-required, guaranteed reply, broadcasts and mailing lists, on TopFans and on one generated white-label project.
- Also test failed validation, pagination retries, equal timestamps, duplicate events, expired reconnect cursors, and account/tenant switches. Assert disabled actions, correct-thread history, stable scroll position, and cleared private state.
- Trace p50/p95 handler and query time, database reads, and compressed response bytes alongside browser timing. Keep message content and credentials out of captures.
Things that must not break
- Paid DM and tip-required prompts. While permissions are loading, the send, tip and unlock buttons stay disabled rather than defaulting to "free".
- White-label behavior.
applyDefaultWhiteLabelChatTarget()opens the owner's chat for fans when the user list is hidden, andWLSAllowDMscontrols the list. - Default conversation choice.
setInitalDisplay()prefers Priority, then General, then Bulk for creators, and Bulk whenhistory.state.groupIdis set. The paginated inbox and the inline first thread must follow the same order. - Unread counts in the header badge (
coreService.messageCount) once pages and deltas replace the full list. - Optimistic sends and the message queue, which match on
uinqueIdtoday.
Roll out without losing messages
- Feature-flag the new read APIs. Backfill conversation summaries, then compare old and new rows, counts, and default selections before moving clients.
- Release to a small cohort first. Watch p75/p95 load times, missing-message reports, send failures, duplicate events, unread-count mismatches, and backend load.
- Keep the old read path available during migration. Expand after TopFans and white-label checks pass; roll back if correctness or latency worsens.
Open questions for the backend
- How big is a typical and a worst-case
get-chatGroups-and-usersresponse, and how long does the handler take at p95? - Does
getpreviousMessagesFromHttpalready cap the page size? The frontend never asks for more, so either it returns everything or old messages are unreachable. - Is
chat-user-validationslow? It checks subscriptions, free DM quotas and guaranteed replies, and it may be the slowest of the three calls. - What's the storage model (DynamoDB keys, GSIs) behind conversations and messages? It decides whether a summary table is needed or a query change is enough.
Code references
MC is src/app/modules/messages/components/message-container/message-container.component.
| Finding | Where |
|---|---|
| Load sequence | MC.ts 1424 userProfileData, 521 fetchInitialData, 672 getUserChatLogs, 986 checkChatLogs, 3442 getUserMsgDetails, 3008 getUserChatMessage |
| 1 s timer | MC.ts 3081 to 3117 getUserEmail |
| Shared cancel subject | MC.ts 306, 3031, 3122 |
| Loader gating | MC.html 725 to 729 (history), 1836 to 1841 (composer); MC.ts 724 clears it early |
| Validation on every inbox fetch | MC.ts 686 to 694 |
| Inbox refetch per message | MC.ts 2740 receiveMessage, 2593 updateUserList |
| Double change detection | MC.ts 2647 ngAfterViewChecked |
| Sort per push, identity tracking | MC.ts 759 to 808, 2898; MC.html 265 to 267, 745 to 748 |
| Template work per row | MC.ts 3882 getCurrentMessages, 3153 getTimeDiff |
| Scroll timers, pagination off | MC.ts 3492 to 3516 |
| Eager imports | MC.ts 127 to 157 and 164 to 206 (imports), 55 to 56 and 97 (modal classes), 431 (video CSS) |
| Endpoints | src/app/core/api-url.enum.ts 149, 158, 166 |
| Bundle | dist/TopFans/browser/chunk-ASVT5GJH.js (route), main-OAYPDOTP.js (shell) |