From 39aa81fb234e0fb872342381dbc4a06dc6e6442d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:56:36 -0400 Subject: [PATCH] perf(dm): coalesce concurrent decrypts of the same DM event Multiple subscribers (history list + notifications) decrypt the same DM event at once; before the in-memory cache fills, each hit the serialized nip-07 extension, multiplying round-trips per message. Cache the in-flight decrypt promise by event id so concurrent callers share one decrypt. Harness: cold-load extension decrypt calls drop from ~50-60 to exactly 20 for 20 DMs (1 per message); warm reload stays 0 via the persisted cache. Combined with the earlier ordering/sequential and persist-cache fixes, legacy DM decryption went from ~241 extension calls (20 DMs) to 20 cold / 0 warm. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 69 ++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/js/nostr/client.js b/js/nostr/client.js index 39584cc5..452e18e5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -577,6 +577,8 @@ export class NostrClient { }, }); this.dmDecryptCache = new LRUCache({ maxSize: DM_DECRYPT_CACHE_LIMIT }); + // event id -> in-flight decrypt promise (coalesces concurrent decrypts). + this.dmDecryptInFlight = new Map(); this.dmDecryptor = null; this.dmDecryptorPromise = null; this.isInitialized = false; @@ -1373,32 +1375,53 @@ export class NostrClient { // fall through to a fresh decrypt } } - } - - const decryptDM = await this.ensureDmDecryptor(); - const context = await this.buildDmDecryptContext(actorPubkey); - - let result; - try { - result = await decryptDM(event, context); - } catch (error) { - devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { - error: sanitizeDecryptError(error), - event: summarizeDmEventForLog(event), - }); - throw error; - } - - if (result?.ok && eventId) { - this.dmDecryptCache.set(eventId, result); - try { - dmPlaintextCache.set(eventId, JSON.stringify(result)); - } catch (_) { - // result not serializable — skip persisting + // Coalesce concurrent decrypts of the SAME event. Multiple subscribers + // (history list, notifications) often process an event at once; without + // this they each hit the (serialized, slow) extension before the cache + // fills, multiplying extension round-trips per message. + const inFlight = this.dmDecryptInFlight.get(eventId); + if (inFlight) { + return inFlight; } } - return result; + const decryptPromise = (async () => { + const decryptDM = await this.ensureDmDecryptor(); + const context = await this.buildDmDecryptContext(actorPubkey); + + let result; + try { + result = await decryptDM(event, context); + } catch (error) { + devLogger.warn("[nostr] DM decryptor threw unexpectedly.", { + error: sanitizeDecryptError(error), + event: summarizeDmEventForLog(event), + }); + throw error; + } + + if (result?.ok && eventId) { + this.dmDecryptCache.set(eventId, result); + try { + dmPlaintextCache.set(eventId, JSON.stringify(result)); + } catch (_) { + // result not serializable — skip persisting + } + } + + return result; + })(); + + if (eventId) { + this.dmDecryptInFlight.set(eventId, decryptPromise); + } + try { + return await decryptPromise; + } finally { + if (eventId) { + this.dmDecryptInFlight.delete(eventId); + } + } } /**