9.5 KiB
Relay Disconnect & Excessive Re-Read Fix Plan
Problem Summary
Two related issues with the local relay (ws://127.0.0.1:7777/):
- Relay disconnects — The WebSocket connection drops with code
1005(no status code present) after ~81 seconds of silence. - Massive re-reads after reconnect — After reconnecting, hundreds of kind:1 events are re-fetched from the relay, duplicating data already received.
Root Cause Analysis
Issue 1: Relay Disconnect
Timeline from logs:
13:49:31— Last batch of RECV events13:50:52— Disconnect (81 seconds of silence)- Disconnect code:
1005(no status code),wasClean: true
Root cause: The local relay at ws://127.0.0.1:7777/ is closing idle WebSocket connections. The NDK keepalive/silence detection in ndk-core.bundle.js uses a 120-second (12e4 ms) NDKRelayKeepalive timer, but the local relay times out before that threshold is reached.
The NDK probe mechanism (which sends {"kinds":[99999],"limit":0}) only fires AFTER silence is detected — it doesn't prevent the disconnect, it only detects it after the fact.
Key code path:
setupMonitoring() → NDKRelayKeepalive(120000ms) → probeRelayConnection()
But the relay disconnects at ~80s, before the 120s keepalive fires.
Issue 2: Massive Re-Reads
Two contributing factors:
Factor A: NDK Subscription Re-Fire on Reconnect
In ndk-core.bundle.js, when a WebSocket reconnects, ALL open subscriptions are re-fired:
const isReconnection = this.reconnectAttempts > 0;
this.reconnectAttempts = 0;
for (const sub of this.openSubs.values()) {
sub.eosed = false;
if (isReconnection) {
for (let f = 0; f < sub.filters.length; f++) {
if (sub.lastEmitted) {
sub.filters[f].since = sub.lastEmitted + 1;
}
}
}
sub.fire();
}
The since filter is only set if sub.lastEmitted exists. If a subscription never received an event (or lastEmitted wasn't tracked), the subscription re-fires with its original filter — fetching ALL matching events again.
Multiple pages create closeOnEose: false subscriptions for kind:1 events:
post.html—{ kinds: [1], authors: [...], limit: 50 }withcloseOnEose: falsepost.html—{ kinds: [1], authors: [targetPubkey], limit: 50 }withcloseOnEose: falsepost.html—{ kinds: [1], authors: [...], limit: 50 }withcloseOnEose: falselinks.html,template.html,strudel.html— similar patterns
Factor B: Probe Filter and Local Relay limit:0 Handling
The NDK probe sends {"kinds":[99999],"limit":0} via probeRelayConnection. However, the log shows:
[13:52:52.797] CLIENT ➜ RELAY SEND
["REQ","probe-9yft3",{"kinds":[1],"limit":0}]
This shows kinds:[1] not kinds:[99999]. This could be:
- The relay event log is capturing a different subscription being re-fired (not the probe itself)
- The probe filter is being modified somewhere
Regardless, the local relay appears to interpret limit:0 as no limit rather than zero results, causing it to dump all matching events.
Fix Plan
flowchart TD
A[Issue 1: Relay Disconnect] --> B[Add WebSocket keepalive pings in worker]
B --> B1[30s interval sends REQ/CLOSE probe to keep connection alive]
D[Issue 2: Excessive Re-Reads] --> E[Detect reconnect via relayLastDisconnectMeta]
E --> F[Use disconnect timestamp as since filter]
F --> F1[Re-create active subscriptions with since = disconnect time]
D --> G[Add event deduplication guard per subscription]
G --> G1[Track seen event IDs - skip duplicates on broadcast]
D --> H[Track lastEventCreatedAt per subscription as fallback]
H --> H1[Use max created_at if disconnect meta unavailable]
D --> I[Investigate local relay limit:0 behavior]
I --> I1[NIP-01 says limit:0 = zero results - relay may be non-compliant]
Fix 1: Prevent Relay Disconnect with Keepalive Pings
File: www/ndk-worker.js
Add a periodic WebSocket ping mechanism in the worker for local/private relays. Since the NDK keepalive is 120s but the relay times out at ~80s, we need to send activity more frequently.
Approach: After relay connect, start a 30-second interval that sends a lightweight REQ/CLOSE probe to keep the connection alive. This is cheaper than modifying the NDK bundle.
// In attachRelayEventListeners() or after relay connect:
function startKeepalive(relay) {
const KEEPALIVE_INTERVAL = 30000; // 30 seconds
const intervalId = setInterval(() => {
if (relay.status !== 5) { // Not connected
clearInterval(intervalId);
return;
}
// Send a no-op REQ/CLOSE to keep connection alive
const keepaliveId = `ka-${Date.now()}`;
try {
relay.send(JSON.stringify(["REQ", keepaliveId, {"kinds":[99999],"limit":0}]));
setTimeout(() => {
try { relay.send(JSON.stringify(["CLOSE", keepaliveId])); } catch(e) {}
}, 100);
} catch(e) {
clearInterval(intervalId);
}
}, KEEPALIVE_INTERVAL);
}
Fix 2: Use Disconnect Timestamp as since on Reconnect
File: www/ndk-worker.js
The worker already tracks disconnect timestamps per relay in relayLastDisconnectMeta — a Map of relay.url → { timestamp, code, reason, wasClean }. We can use this to distinguish reconnects from first connects and inject a since filter.
How to tell the difference between first connect and reconnect:
- First connect: No entry in
relayLastDisconnectMetafor that relay URL - Reconnect: Entry exists with a
timestamp(milliseconds)
Approach — Reconnect-aware subscription re-creation:
In the relay connect event handler at attachRelayEventListeners():
- Check if
relayLastDisconnectMetahas an entry for this relay → this is a reconnect - Get the disconnect timestamp (convert from ms to Nostr seconds:
Math.floor(timestamp / 1000)) - For each active subscription in
activeSubscriptions, stop the old NDK sub and create a new one withsince= disconnect timestamp injected into the filters - Clear the disconnect meta entry after processing
This way, after a reconnect, the relay only sends events created after the disconnect — not the entire history.
// In the relay 'connect' handler:
relay.on('connect', () => {
const normalized = normalizeRelayUrl(relay.url);
const disconnectMeta = relayLastDisconnectMeta.get(normalized);
if (disconnectMeta && disconnectMeta.timestamp) {
// This is a RECONNECT — patch active subscriptions with since filter
const sinceSec = Math.floor(disconnectMeta.timestamp / 1000);
console.log(`[Worker] Reconnect detected for ${relay.url}, will use since=${sinceSec}`);
for (const [subId, sub] of activeSubscriptions.entries()) {
// Stop old subscription
sub.stop();
// Re-create with since filter
const patchedFilters = sub.filters.map(f => ({ ...f, since: sinceSec }));
const newSub = ndk.subscribe(patchedFilters, sub.opts);
// Re-attach event handlers...
activeSubscriptions.set(subId, newSub);
}
relayLastDisconnectMeta.delete(normalized);
}
});
Additionally, track lastEventCreatedAt per subscription as a fallback:
- In
handleSubscribe(), on eachevent, recordMath.max(lastEventCreatedAt, event.created_at) - Use this as an alternative
sincevalue if disconnect meta is unavailable
Fix 3: Event Deduplication Guard
File: www/ndk-worker.js
Add a per-subscription seen-event-ID set in the worker's event handler to prevent broadcasting duplicate events to pages.
// In handleSubscribe(), add:
const seenEventIds = new Set();
sub.on('event', async (event) => {
// Dedup guard
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
// Cap the set size to prevent memory leaks
if (seenEventIds.size > 5000) {
const entries = Array.from(seenEventIds);
for (let i = 0; i < 1000; i++) seenEventIds.delete(entries[i]);
}
// ... rest of event handling
});
Fix 4: Investigate Local Relay limit:0 Behavior
The NIP-01 spec says limit:0 should return zero events. If the local relay interprets it as "no limit," this is a relay-side bug. Options:
- Fix the local relay to respect
limit:0per NIP-01 - Change the probe to use
limit:1instead oflimit:0in the NDK bundle
Fix 5: Add Reconnect Logging
File: www/ndk-worker.js
Add logging around subscription re-fire to make future debugging easier:
- Log when subscriptions are re-sent after reconnect
- Log whether
sincefilter was applied - Log count of events received per subscription after reconnect
Priority Order
- Fix 1 (Keepalive) — Prevents the disconnect entirely, which eliminates the cascade
- Fix 3 (Dedup guard) — Quick safety net against duplicate events regardless of cause
- Fix 2 (Since filter) — Ensures reconnect re-fetches are incremental
- Fix 4 (Relay limit:0) — Investigate and fix relay-side behavior
- Fix 5 (Logging) — Observability for future issues