--- title: Performance Audit — 2026-05-24 tags: [audit, performance, database, findings] created: 2026-05-24 status: open --- # Performance Audit — 2026-05-24 Performance review of MongoDB queries, indexes, in-memory data structures, Socket.IO emission patterns, and pagination. Triggered by completion of Request Network integration, Telegram auth, and the internal funds ledger. Every finding is derived from actual source code. > [!warning] Scale risk > H3 (unbounded seller fan-out) and H4 (full chat document in memory) are time-bombs that will cause outages once user counts and message volumes grow past modest scale. Both have zero performance headroom at their current implementation. --- ## HIGH ### H1 — N+1 Query: `getPurchaseRequestsByBuyer` Issues One Payment Lookup Per Request Row **File:** `src/services/marketplace/PurchaseRequestService.ts:516–534` **Status:** Open ```ts await Promise.all(requests.map(async (request) => Payment.findOne({ purchaseRequestId: request._id }) )) ``` A page of 10 requests produces 11 DB round-trips (1 list + 10 payment lookups). With a 50-item page, that is 51 round-trips on every buyer dashboard load. **Remediation:** ```ts const requestIds = requests.map(r => r._id); const payments = await Payment.find({ purchaseRequestId: { $in: requestIds } }).lean(); const paymentMap = new Map(payments.map(p => [p.purchaseRequestId.toString(), p])); // then: requestWithPayment = { ...r, payment: paymentMap.get(r._id.toString()) } ``` --- ### H2 — Missing Index on `Payment.purchaseRequestId` **File:** `src/models/Payment.ts:190–196` **Status:** Open The Payment schema indexes `buyerId`, `sellerId`, `status`, `transactionHash`, and `providerPaymentId` — but not `purchaseRequestId`. Every call to `Payment.findOne({ purchaseRequestId })` (used in `getPurchaseRequestsByBuyer`, `getPurchaseRequestById`, payment coordinator, notification handlers) causes a full collection scan. **Remediation:** Add to `Payment.ts`: ```ts paymentSchema.index({ purchaseRequestId: 1, status: 1 }); ``` --- ### H3 — Unbounded Seller Fan-Out on New Public Purchase Request **File:** `src/services/marketplace/PurchaseRequestService.ts:190–191` **Status:** Open ```ts sellers = await User.find({ role: "seller", status: "active" }).select(...) ``` When a public purchase request is created, every active seller is fetched with no limit. For 10 000 sellers: - 10 000 `Notification` documents are inserted (not bulk-inserted — individual creates in a loop). - Each insert calls `emitRealTimeNotification` → `getUnreadCount` (an additional `countDocuments` query per seller = 10 000 extra queries). - A 50 ms staggered `setTimeout` per seller means the event loop is managing 500 seconds of scheduled callbacks. **Remediation:** 1. Cap the fan-out with a configurable batch limit (e.g., 500 most-relevant sellers by category). 2. Use `Notification.insertMany(docs)` for bulk creation. 3. Push a single room-broadcast (`io.to('sellers').emit(...)`) rather than per-user socket emits. 4. Move heavy fan-out to a background job queue (Bull/BullMQ). --- ### H4 — Full Chat Document with All Embedded Messages Loaded for Pagination **File:** `src/services/chat/ChatService.ts:370–408` **Status:** Open ```ts const chat = await Chat.findById(chatId).populate("messages.senderId", ...) const messages = chat.messages.sort(...).slice(skip, skip + limit) ``` A chat with 50 000 messages (~5 KB each = 250 MB) is fully loaded into Node.js memory on every paginated message request. Pagination happens in JavaScript after the full document is fetched. **Remediation (minimum):** Use MongoDB's `$slice` projection: ```ts const chat = await Chat.findById(chatId, { messages: { $slice: [skip, limit] }, participants: 1, unreadCounts: 1 }).lean(); ``` **Recommended long-term:** Extract messages into a separate `Message` collection with server-side pagination (`Message.find({ chatId }).sort({ timestamp: -1 }).skip(skip).limit(limit).lean()`). --- ### H5 — Extra `countDocuments` Query on Every Single Notification Creation **File:** `src/services/notification/NotificationService.ts:131–152` **Status:** Open `emitRealTimeNotification` calls `emitUnreadCountUpdate`, which calls `getUnreadCount` (a full `countDocuments` query) synchronously within the notification creation hot path. When H3's 10 000-seller fan-out fires, this produces 10 000 additional `countDocuments` queries in rapid succession. **Remediation:** Maintain an unread count field directly on the `User` or in a Redis hash. Increment on notification insert rather than re-counting. Or skip the re-query and have the client increment its local counter on socket event receipt. --- ### H6 — `getUserPayments` Queries Non-Indexed Field and Has No Default Limit **File:** `src/services/payment/paymentService.ts:331–370` **Status:** Open The query builds `query.userId = userId` (line 343), but the Payment schema stores buyers as `buyerId` (ObjectId) — not `userId`. The query hits `metadata.legacyUserId`, which has no index. Additionally, if `options.limit` is not supplied the query returns all matching documents with no limit. **Remediation:** 1. Fix field: `query.$or = [{ buyerId: userId }, { sellerId: userId }]` 2. Add default limit: `.limit(options.limit ?? 100)` --- ## MEDIUM ### M1 — Missing Compound Index `(buyerId, createdAt)` on PurchaseRequest **File:** `src/models/PurchaseRequest.ts:360–369` **Status:** Open `getPurchaseRequestsByBuyer` queries `{ buyerId }` with `.sort({ createdAt: -1 })`. The existing single-field index on `buyerId` satisfies the filter but forces MongoDB to sort results post-scan. A compound index eliminates the sort entirely. **Remediation:** Add to `PurchaseRequest.ts`: ```ts PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 }); PurchaseRequestSchema.index({ status: 1, createdAt: -1 }); PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 }); ``` --- ### M2 — Unanchored Case-Insensitive Regex Search — Full Collection Scan **File:** `src/services/marketplace/PurchaseRequestService.ts:703–720` **Status:** Open ```ts { title: { $regex: searchTerm, $options: "i" } } ``` An unanchored, case-insensitive regex cannot use a B-tree index. MongoDB performs a full collection scan with in-memory string matching on every search request. **Remediation:** Create a text index: ```ts PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' }); ``` Query with `{ $text: { $search: searchTerm } }` and sort by `{ score: { $meta: 'textScore' } }`. --- ### M3 — Double-Fetch Pattern in Update Methods (No `.lean()` on Pre-Check) **File:** `src/services/marketplace/PurchaseRequestService.ts:406–425,557–572` **Status:** Open Both `updatePurchaseRequest` and `updatePurchaseRequestStatus` call `PurchaseRequest.findById(requestId)` to validate current status, then call `findByIdAndUpdate`. The pre-check `findById` instantiates a full Mongoose document that is immediately discarded. **Remediation:** Use `findByIdAndUpdate` with `{ new: false }` to get the pre-update document in a single round-trip, or use `.lean().select('status')` on the pre-check to minimize overhead. --- ### M4 — Regex on Embedded `messages.content` Array — Full Array Deserialization **File:** `src/services/chat/ChatService.ts:318–320` **Status:** Open ```ts { "messages.content": { $regex: filters.search, $options: "i" } } ``` MongoDB must deserialize the entire embedded `messages` array for every chat document to evaluate this filter. There is no index support for nested array field regex searches. **Remediation:** Move to a dedicated `Message` collection with a text index on `content`, then query `Message.find({ chatId: { $in: userChatIds }, $text: { $search: searchTerm } })`. --- ### M5 — In-Memory Coordination State Not Shared Across Replicas **File:** `src/services/payment/paymentCoordinator.ts:25` **Status:** Open The `coordinationState` Map is process-local. In a multi-replica deployment, each process starts with an empty map; the 10-second debounce provides no cross-process deduplication. The DB guard in CRIT-1 (Logic Audit) is the correct production fix; this Map is supplementary at best. *(Code comment at line 24 already acknowledges this — a Redis migration is planned.)* --- ### M6 — `getSellers()` Is Unbounded (No Limit) **File:** `src/services/marketplace/PurchaseRequestService.ts:728–741` **Status:** Open ```ts await User.find({ role: "seller", isEmailVerified: true }).select(...).lean() ``` No `.limit()`. Returns every seller in the collection. This method is exposed via API. **Remediation:** Add `.limit(500)` at minimum, or paginate with a cursor. --- ### M7 — `user-online` Event Broadcast to All Connected Sockets Globally **File:** `src/app.ts:300` **Status:** Open ```ts socket.broadcast.emit('user-status-change', { userId, status: 'online', ... }) ``` This pushes the online-presence event to every connected socket regardless of whether they have a relationship with the user. With 10 000 concurrent connections, this is 10 000 individual socket writes per login event. **Remediation:** Restrict to relevant rooms. For a marketplace, "relevant" means chat participants and active purchase request counterparties: ```ts userChatIds.forEach(chatId => { socket.to(`chat-${chatId}`).emit('user-status-change', { userId, status: 'online' }); }); ``` --- ### M8 — Post-Filter After Pagination Causes Wrong `totalItems` Count **File:** `src/services/marketplace/PurchaseRequestService.ts:258–337` **Status:** Open The code paginates the base query, then applies visibility filtering (offer ownership, anonymization) after pagination. This means `totalItems` in the response is the pre-filter count, not the count of items the caller can actually see. Users may see fewer items than indicated by the pagination metadata (e.g., "showing 8 of 10" when only 6 are visible). **Remediation:** Move visibility filtering into the MongoDB query (aggregation pipeline with `$match` conditions) so `countDocuments` reflects the actual visible set. --- ## LOW ### L1 — `getUserChats` Runs Count Query Sequentially After Find **File:** `src/services/chat/ChatService.ts:324–335` **Status:** Open `Chat.find(query)` and `Chat.countDocuments(query)` are independent queries run sequentially. They should run in parallel. **Remediation:** ```ts const [chats, total] = await Promise.all([ Chat.find(query).lean(), Chat.countDocuments(query) ]); ``` *(The notification service at `NotificationService.ts:53–61` already does this correctly — apply the same pattern.)* --- ### L2 — `findByIdAndUpdate` Results Not Using `.lean()` **File:** `src/services/marketplace/PurchaseRequestService.ts:418–425,566–572` **Status:** Open `findByIdAndUpdate` with `.populate(...)` returns full Mongoose document objects for data that is only read and returned to the client. `.lean()` saves ~15–30% memory and processing per returned document. **Remediation:** Add `.lean()` to read-only `findByIdAndUpdate` calls. --- ### L3 — Telegram Replay Maps Are Process-Local (Multi-Replica Gap, Same as M5) **File:** `src/services/telegram/telegramService.ts:395–396` **Status:** Open ```ts const initDataReplayWindow = new Map(); const webhookReplayWindow = new Map(); ``` `cleanupReplayMap` is called lazily before each check — entries are evicted correctly once the window expires, so the Maps do not grow unboundedly under normal load. However, like `coordinationState`, they are process-local and will not catch replays across multiple Node replicas. **Remediation:** Redis `SET NX EX` (same as Security Audit H-1 recommendation). --- ### L4 — PasskeyService Challenge Map: 5-Minute Cleanup Is Correct, Process-Local Only **File:** `src/services/auth/passkeyService.ts:56–63` **Status:** Open Cleanup is implemented and the code comment (lines 50–55) already notes the Redis migration path. Not a performance risk at current scale. Same multi-replica caveat as L3. --- ## Confirmed PASS | Check | Result | Source | |-------|--------|--------| | `TempVerification` TTL index | PASS | `TempVerification.ts:59` — MongoDB-native TTL, no app polling | | `TelegramSession` TTL index | PASS | `TelegramSession.ts:66` | | `Notification` 90-day TTL index | PASS | `Notification.ts:77` — `expireAfterSeconds: 7776000` | | `FundsLedgerEntry` indexes (purchaseRequestId, paymentId, idempotencyKey) | PASS | `FundsLedgerEntry.ts:86–107` | | SHKeeper polling rate bounded | PASS | `shkeeperSimpleAuto.ts:64` — 2-minute interval, self-cleaning | | Socket.IO room cleanup on disconnect | PASS | Socket.IO removes socket from all rooms automatically on disconnect | | `getUserNotifications` count + find in parallel | PASS | `NotificationService.ts:53–61` — already uses `Promise.all` | | Telegram replay map bounded (lazy eviction) | PASS | `telegramService.ts:100` — `cleanupReplayMap` evicts expired entries | --- ## Index Additions Summary The following indexes should be added to eliminate collection scans identified in this audit: ```ts // Payment.ts paymentSchema.index({ purchaseRequestId: 1, status: 1 }); // PurchaseRequest.ts PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 }); PurchaseRequestSchema.index({ status: 1, createdAt: -1 }); PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 }); // PurchaseRequest.ts (text search) PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' }); ``` Caution: adding indexes increases write latency and storage. Run `db.collection.getIndexes()` before adding to avoid duplicating existing indexes (some `unique: true` fields already create indexes automatically). --- ## Summary | Severity | Count | |----------|-------| | HIGH | 6 | | MEDIUM | 8 | | LOW | 4 | **Highest urgency:** H3 (seller fan-out) and H4 (full chat in memory) will cause visible degradation with modest growth. H1 (N+1 payments) and H2 (missing index) are easy wins with high query-count impact. --- ## Related - [[Security Audit - 2026-05-24]] - [[Logic Audit - 2026-05-24]] - [[Backend Architecture]] - [[Data Model Overview]] - [[Funds Ledger and Escrow State Machine Specification]]