Skip to content

typescript-async-patterns

Async/await, concurrency control, and error propagation patterns for MCP documentation server performance

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

Async/await, concurrency control, and error propagation patterns for MCP documentation server performance

Agent definition

typescript-async-patterns.md
description: Async/await, concurrency control, and error propagation patterns for MCP documentation server performance

TypeScript Async Patterns for MCP Servers

> **Scope**: MCP server file I/O concurrency, indexing performance, error propagation. Node.js 18+, TypeScript 5.0+.

Pattern Table

| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | `Promise.all(batch)` | Node 18+ | Bounded concurrent I/O | Unbounded arrays (OOM risk) | | `Promise.allSettled()` | Node 12.9+ | Must collect all results despite failures | Need fast-fail on first error | | `Promise.race()` | Node 18+ | Startup timeout racing | Error handling (loses other results) | | `async/await` in handlers | Always | All `setRequestHandler` callbacks | Sync callbacks (blocks event loop) | | `AbortController` | Node 18+ | Cancellable indexing operations | Simple timeouts (use setTimeout) |

---

Correct Patterns

Bounded Concurrency for File Indexing

Fixed-size batches to avoid exhausting file descriptors.

async function indexFilesInBatches(
  files: string[],
  batchSize: number,
  processFile: (path: string) => Promise<void>
): Promise<{ processed: number; failed: number }> {
  let processed = 0;
  let failed = 0;

  for (let i = 0; i < files.length; i += batchSize) {
    const batch = files.slice(i, i + batchSize);
    const results = await Promise.allSettled(batch.map(processFile));

    for (const result of results) {
      if (result.status === 'fulfilled') {
        processed++;
      } else {
        failed++;
        console.warn(`[warn] indexing failed: ${result.reason}`);
      }
    }
  }

  return { processed, failed };
}

// Usage: process 50 files at a time
await indexFilesInBatches(allFiles, 50, (f) => this.parseAndCache(f));

**Why**: `Promise.all(files.map(...))` on 10,000 files opens 10,000 file handles simultaneously. Most OS defaults limit to 1024 open file descriptors. `EMFILE: too many open files` crashes the indexer.

---

Startup Timeout with Partial Results

Serve before indexing completes.

class DocsServer {
  private indexingComplete = false;

  async start(): Promise<void> {
    const STARTUP_TIMEOUT_MS = 15_000;

    const indexingDone = this.runIndexing();

    // Serve partial results after timeout — don't block connection
    const startupFence = Promise.race([
      indexingDone.then(() => { this.indexingComplete = true; }),
      new Promise<void>((resolve) => setTimeout(resolve, STARTUP_TIMEOUT_MS)),
    ]);

    await startupFence;

    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    // Indexing may still be running in background
    await indexingDone.then(() => { this.indexingComplete = true; }).catch(() => {});
  }

  // Add indexing status to list response metadata
  handleListResources() {
    return {
      resources: this.getIndexedResources(),
      _meta: this.indexingComplete ? undefined : { indexing: true },
    };
  }
}

**Why**: MCP clients (Claude Desktop) have a 30-second connection timeout. A 10,000-file corpus can take 60+ seconds to index sequentially. Without a startup fence, Claude never connects.

---

Error Propagation with Context

Preserve context through async chains.

async function parseDocWithContext(filePath: string): Promise<ParsedDoc> {
  try {
    const content = await fs.promises.readFile(filePath, 'utf-8');
    return parseMarkdownDoc(filePath, content);
  } catch (err) {
    // Wrap with file context before re-throwing
    throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`, {
      cause: err, // Node 16.9+ / ES2022
    });
  }
}

// Caller can inspect cause:
try {
  await parseDocWithContext('/docs/broken.md');
} catch (err) {
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
    // File disappeared between glob and read — normal race condition
  }
  console.error(err.message); // "Failed to parse /docs/broken.md: ENOENT..."
  console.error('Caused by:', (err as Error).cause);
}

**Why**: `cause` option (ES2022, Node 16.9+) enables error chains without losing the original. Without it, wrapping with template strings loses the original stack trace.

---

Pattern Catalog

Batch File Operations with Bounded Concurrency

**Detection**:

# Find Promise.all with .map() on potentially large arrays
grep -rn 'Promise\.all.*\.map' --include="*.ts" src/
rg 'Promise\.all\(' --type ts src/ -A2 | grep '\.map'

**Signal**:

// Reads ALL files simultaneously — EMFILE on large repos
const docs = await Promise.all(
  allFiles.map((f) => fs.promises.readFile(f, 'utf-8'))
);

**Why this matters**: `EMFILE: too many open files` at runtime. Default ulimit is 1024 file descriptors on Linux. A repo with 2000 markdown files triggers this. The error appears non-deterministically depending on OS load, making it hard to reproduce locally.

**Preferred action**:

// Process in batches of 50
for (let i = 0; i < allFiles.length; i += 50) {
  await Promise.all(allFiles.slice(i, i + 50).map((f) => this.parseAndCache(f)));
}

**Version note**: Node.js 18+ raises default file descriptor limit on Linux but OS limits still apply. Don't rely on this.

---

Use async/await in All Request Handlers

**Detection**:

grep -rn 'setRequestHandler' --include="*.ts" src/ -A10 | grep -v 'async\|await\|return'
# More specifically: promises not awaited inside handlers
rg 'setRequestHandler.*\{' --type ts src/ -A5 | grep '^\s*[a-zA-Z].*\(\)' | grep -v 'await'

**Signal**:

server.setRequestHandler(CallToolRequestSchema, (request) => {
  // Not async, not returning promise properly
  this.searchIndex(request.params.arguments?.query).then((results) => {
    return { content: [{ type: 'text', text: JSON.stringify(results) }] };
  });
  // Returns undefined! Handler completes before search finishes.
});

**Why this matters**: The ha

Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked