Make the messages page load fast

Opening /messages today waits on three API calls one after another plus 1.5 s of fixed timers before a conversation is readable. Removing the timers and running the calls in parallel is a small frontend change and should cut the wait by more than half. Deferring about 450 KB gzipped of unused JavaScript and adding two backend endpoints gets the page to one round trip.

Code audit of c36ff481f5 plus working tree, 30 Sep 2026. Bundle numbers come from the dist/TopFans production build of 29 Sep. I had no logged-in session, so there are no live timings. The backend repo wasn't reachable, so backend items are API contract proposals based on how the frontend calls it. Time estimates assume 300 ms per API call and are illustrative.

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:

WhatGzippedNeeded when
Emoji picker (@ctrl/ngx-emoji-mart)92 KBUser clicks the emoji button
Angular Material, pulled in by ngx-audio-player71 KBAn audio message scrolls into view
Dynamic form builder, circle progress, CDK drag61 KBAutomated messages tab, upload progress, reordering attachments
ngx-bootstrap datepicker and timepicker57 KBAutomated message and new message modals
PrimeNG components47 KBModals and multiselects
Merchandise, portfolio and event share modals, app-table~125 KBUser 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)

  1. 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.
  2. Give history and validation their own switchMap streams keyed by groupId. Switching from A to B cancels A's requests only, and a response for A can never overwrite B.
  3. Split msgrightLoader into historyLoading and permissionsLoading. 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.
  4. Stop calling checkNewUserValidation() from the inbox response handler. Permissions belong to the selected thread, not the list.
  5. 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 with afterNextRender.
  6. Remove ngAfterViewChecked(). Anything that relied on it is a missed update that needs a targeted markForCheck().
  7. In searchResults(), partition first and sort each tab once. Track conversations by groupId and messages by sk, or by uinqueId for optimistic sends.
  8. Give search a stream with debounceTime, distinctUntilChanged and switchMap, 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)

  1. Wrap the emoji picker in @defer (on interaction) behind a small placeholder button.
  2. Render audio messages with a native <audio> placeholder and @defer (on viewport) for ngx-audio-player. Better still, replace it, since it drags in Angular Material for one slider.
  3. Put the automated messages tab (app-table, AutomatedMessageModalComponent, dynamic forms) behind @defer (when active === SET_UP_AUTOMATED_MESSAGE).
  4. Open NewMessageModelComponent, EditParticipantsModalComponent, MessagePriceModelComponent, the share modals and the image overlay with await import(...) at click time. Remove them from imports, and change the viewChild.required queries that point at them to optional ones.
  5. 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.
  6. Prefetch the messages chunk when the sidebar Messages link is hovered or focused, or during idle time after login. NoPreloading stays 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)

  1. Move inbox and thread state into a providedIn: 'root' MessagesStore service. Kick off the inbox request when navigation to /messages starts or the link is pressed, so the request runs while the chunk downloads.
  2. 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.
  3. Patch a single row on each websocket message instead of refetching the inbox. This needs the event payload described in the backend section below.
  4. Turn history pagination back on. onUp() is commented out, although requests accept timeStamp. 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.
  5. 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.

ChangeProposalWhat it removes
Paginated inboxGET 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 bootstrapPOST 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 inlineOptional 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 websocketEach 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 demandGET 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 revalidationAn 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 workLook 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.

StageHistory readableComposer usableWhy
Today~1.9 s~1.9 s3 serial calls, a 1 s timer, one shared loader
Phase 1~0.6 s~0.6 sInbox, then history and validation in parallel
Phase 1 + thread bootstrap~0.6 s~0.6 sSame wait, fewer requests, no client parsing
+ first thread inline~0.3 s~0.3 sOne round trip
+ early start and memory cache, warm return< 100 ms< 100 msPaint 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.mark calls 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-json before 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-back id, 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, and WLSAllowDMs controls the list.
  • Default conversation choice. setInitalDisplay() prefers Priority, then General, then Bulk for creators, and Bulk when history.state.groupId is 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 uinqueId today.

Roll out without losing messages

  1. Feature-flag the new read APIs. Backfill conversation summaries, then compare old and new rows, counts, and default selections before moving clients.
  2. Release to a small cohort first. Watch p75/p95 load times, missing-message reports, send failures, duplicate events, unread-count mismatches, and backend load.
  3. 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

  1. How big is a typical and a worst-case get-chatGroups-and-users response, and how long does the handler take at p95?
  2. Does getpreviousMessagesFromHttp already cap the page size? The frontend never asks for more, so either it returns everything or old messages are unreachable.
  3. Is chat-user-validation slow? It checks subscriptions, free DM quotas and guaranteed replies, and it may be the slowest of the three calls.
  4. 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.

FindingWhere
Load sequenceMC.ts 1424 userProfileData, 521 fetchInitialData, 672 getUserChatLogs, 986 checkChatLogs, 3442 getUserMsgDetails, 3008 getUserChatMessage
1 s timerMC.ts 3081 to 3117 getUserEmail
Shared cancel subjectMC.ts 306, 3031, 3122
Loader gatingMC.html 725 to 729 (history), 1836 to 1841 (composer); MC.ts 724 clears it early
Validation on every inbox fetchMC.ts 686 to 694
Inbox refetch per messageMC.ts 2740 receiveMessage, 2593 updateUserList
Double change detectionMC.ts 2647 ngAfterViewChecked
Sort per push, identity trackingMC.ts 759 to 808, 2898; MC.html 265 to 267, 745 to 748
Template work per rowMC.ts 3882 getCurrentMessages, 3153 getTimeDiff
Scroll timers, pagination offMC.ts 3492 to 3516
Eager importsMC.ts 127 to 157 and 164 to 206 (imports), 55 to 56 and 97 (modal classes), 431 (video CSS)
Endpointssrc/app/core/api-url.enum.ts 149, 158, 166
Bundledist/TopFans/browser/chunk-ASVT5GJH.js (route), main-OAYPDOTP.js (shell)