NDK Operation Timeout Wrapper

SkillMedia

Fix NDK (Nostr Dev Kit) operations hanging indefinitely when relay connections stall. Use when: (1) App freezes during fetchEvents or publish calls, (2) No timeout errors despite network issues, (3) Relay connection appears stuck, (4) Using NDK with unstable or slow relays. NDK operations have no built-in timeout - wrap with Promise.race.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the NDK Operation Timeout Wrapper skill

What this skill tells your AI

The instructions your AI receives, as published by divinevideo/divine-mobile in .agents/skills/ndk-operation-timeout-wrapper/SKILL.md and read by ahel’s review.

Problem

NDK (nostr-dev-kit) operations like fetchEvents() and ndkEvent.publish() have no built-in timeout. When relay connections stall or become unresponsive, these operations hang indefinitely, causing the application to freeze without any error message.

Context / Trigger Conditions

  • Application freezes during Nostr operations
  • No timeout error thrown despite minutes of waiting
  • Works sometimes, hangs randomly (relay-dependent)
  • Log shows operation started but never completes
  • Using NDK with multiple relays where some may be unreliable

Solution

Create a timeout wrapper function:

const NDK_TIMEOUT_MS = 30000; // 30 seconds

async function withTimeout<T>(
  promise: Promise<T>,
  ms: number,
  operation: string
): Promise<T> {
  let timeoutId: ReturnType<typeof setTimeout>;
  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(
      () => reject(new Error(`${operation} timed out after ${ms}ms`)),
      ms
    );
  });

  try {
    const result = await Promise.race([promise, timeoutPromise]);
    clearTimeout(timeoutId!);
    return result;
  } catch (error) {
    clearTimeout(timeoutId!);
    throw error;
  }
}

Wrap all NDK operations:

// Connect with timeout
await withTimeout(ndk.connect(), NDK_TIMEOUT_MS, "NDK connect");

// Fetch events with timeout
const events = await withTimeout(
  ndk.fetchEvents({ kinds: [0], authors: [pubkey] }),
  NDK_TIMEOUT_MS,
  "fetch profile"
);

// Publish with timeout
const relaySet = NDKRelaySet.fromRelayUrls(relayUrls, ndk);
await withTimeout(
  ndkEvent.publish(relaySet),
  NDK_TIMEOUT_MS,
  "relay publish"
);

Also ensure timeout errors are retryable:

function isRetryableError(error: unknown): boolean {
  if (error instanceof Error) {
    const message = error.message.toLowerCase();
    const errorName = error.name.toLowerCase();
    if (
      message.includes("timeout") ||
      message.includes("aborted") ||
      errorName.includes("timeout") ||
      errorName.includes("abort")
    ) {
      return true;
    }
  }
  return false;
}

Verification

After implementing, operations that previously hung should now:

  1. Throw a timeout error after the specified duration
  2. Allow retry logic to attempt the operation again
  3. Log the specific operation that timed out

Example

Before (hangs forever):

const events = await ndk.fetchEvents({ kinds: [34236], "#d": [vineId] });

After (times out and can retry):

const events = await withTimeout(
  ndk.fetchEvents({ kinds: [34236], "#d": [vineId] }),
  30000,
  "check video exists"
);

Notes

  • 30 seconds is a reasonable default; adjust based on expected operation duration
  • Consider shorter timeouts for existence checks, longer for batch operations
  • Wrap ALL NDK operations, not just problematic ones (any can hang)
  • This pattern applies to any async library without built-in timeouts
  • For AbortController support (if library supports it), prefer that over Promise.race

References

Signals

GitHub stars
264
Forks
55
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
ndk-operation-timeout-wrapper
Source
github.com/divinevideo/divine-mobile