architecture
Lsp Routing Strategies For Monorepos Failure Patterns And: practical systems guide
Technical field guide on lsp routing strategies for monorepos failure patterns and fixes for teams building dependable AI coding workflows.
Large monorepos eventually hit a wall with language server performance. A workspace with 50+ packages, each with its own `tsconfig.json`, can bring `typescript-language-server` to its knees. Autocomplete becomes sluggish, go-to-definition times out, and developers wait 30 seconds for diagnostics after a single-character edit. The naive solution—one language server instance per workspace—doesn't scale once your repository crosses a few hundred thousand lines of managed code.
This guide walks through three LSP routing strategies we've deployed in production monorepos, the specific failure modes each addresses, and how to test whether your current setup is actually the bottleneck.
The single-instance baseline and where it breaks
Most editors default to spawning one language server per workspace root. For TypeScript, that means `tsserver` loads every `tsconfig.json` it discovers, builds a unified project graph, and attempts to track changes across all packages simultaneously.
This works until:
- **Memory exhaustion**: A monorepo with 40 microservices and 15 shared libraries can push `tsserver` past 4GB RAM. The process starts swapping, then OOMs.
- **Quadratic watch overhead**: File watchers scale poorly. Changing a widely-imported type in `packages/core/types.ts` triggers re-analysis of 30+ projects, each with its own incremental compilation state.
- **Stale diagnostics**: After the language server falls behind, you'll see red squiggles for code that compiled successfully five commits ago, or no errors for code that will fail CI.
The symptom pattern: IntelliSense latency spikes when you edit files in heavily-depended-upon packages, and diagnostics lag 10-20 seconds behind your typing.
Strategy 1: Per-package language servers
Partition the monorepo so each package gets its own LSP instance, routed by file path. If you edit `packages/api/src/handler.ts`, the editor sends LSP requests only to the `api` language server. Dependencies are resolved through TypeScript project references or published build artifacts.
**Implementation sketch** for VS Code's multi-root workspaces:
{
"folders": [
{ "path": "packages/api" },
{ "path": "packages/core" },
{ "path": "packages/ui" }
],
"settings": {
"typescript.tsserver.maxTsServerMemory": 2048
}
}
Each folder spawns its own `tsserver`. The key constraint: `tsconfig.json` must use `references` to other packages, not direct `paths` mapping into their source. Otherwise, cross-package go-to-definition fails because the `api` server doesn't parse `core` source files.
**Failure mode**: Developers open a file in `packages/ui` that imports from `packages/core`. The `ui` server only sees the compiled `.d.ts` from `core`, not the original source. Go-to-definition jumps to type declarations instead of implementation. Refactoring across package boundaries requires manually opening both workspaces.
**When it works**: Clear package boundaries with infrequent cross-cutting changes. Each package is effectively an independent repository with versioned dependencies. Build-time errors are caught by CI, not the language server.
Strategy 2: Dynamic server spawning with TTL
Spawn language server instances on-demand for the currently active file's package, and kill idle servers after a timeout. This reduces steady-state memory while still providing full IntelliSense when you're actively editing.
A custom LSP proxy sits between the editor and language servers:
// Simplified routing pseudocode
const servers = new Map<string, LSPProcess>();
async function route(uri: string, method: string, params: any) {
const pkg = detectPackage(uri); // Parse nearest package.json
if (!servers.has(pkg)) {
servers.set(pkg, spawnServer(pkg));
}
servers.get(pkg).lastUsed = Date.now();
return servers.get(pkg).send(method, params);
}
setInterval(() => {
for (const [pkg, server] of servers) {
if (Date.now() - server.lastUsed > 5 * 60 * 1000) {
server.kill();
servers.delete(pkg);
}
}
}, 60_000);
**Failure mode**: Switching between files in different packages causes 2-5 second cold-start pauses while the new server initializes and parses its project. The first autocomplete request after switching from `packages/api` to `packages/core` blocks until `tsserver` finishes its initial project load.
**Observable symptom**: The editor feels "chunky"—fast when you stay in one package, frozen when you jump between them. Developers learn to open all relevant files up front to pre-warm servers, defeating the memory savings.
**When it works**: Workflows that focus on one package at a time for extended periods. Backend engineers working on `packages/api` for a full day, then switching to `packages/queue` the next. The 5-minute TTL reclaims memory during lunch breaks and meetings.
Strategy 3: Hybrid with shared hot-path caching
Run per-package servers for most code, but keep a single persistent instance for a designated "hot" package—usually shared types or utilities. Requests for files in `packages/core` always go to the long-lived server. Other packages get dynamic instances.
The routing logic prioritizes the hot path:
function route(uri: string, method: string, params: any) {
const pkg = detectPackage(uri);
if (pkg === 'packages/core') {
return sharedServer.send(method, params);
}
return getOrSpawnPackageServer(pkg).send(method, params);
}
**Why this helps**: The shared `core` package is imported by 80% of other packages. Keeping its server always-on means cross-package go-to-definition into core types is instant, even when the target package's server is cold.
**Failure mode**: Developers over-centralize into the "core" package to avoid cold-start penalties. What started as 5 widely-used utilities becomes a 20k-line dumping ground. The single core server hits the same memory/latency issues as the original monolithic approach.
Testing your current bottleneck
Before optimizing LSP routing, confirm that the language server is actually your problem:
1. **Measure end-to-end autocomplete latency**: Open a file, type a dot after an identifier, and time until suggestions appear. Repeat in different packages. If p99 is under 200ms, LSP routing isn't your bottleneck.
2. **Check server memory**: On macOS, `ps aux | grep tsserver`. If resident memory stays under 2GB across all instances, you have headroom.
3. **Watch file edit-to-diagnostic lag**: Change a type in a widely-imported file, save, and time until red squiggles update everywhere. If that's under 5 seconds, incremental compilation is working.
The real bottleneck is often **workspace indexing on startup**—the initial project load when you open the repository. That's fixed by incremental build artifacts (`.tsbuildinfo`), not LSP routing.
Goatfied's agent loop sidesteps some of this by constraining edits to small, validated diffs. The plan → constrain → edit → validate cycle keeps language server state fresh because changes are localized and compile/lint gates run before the LSP sees the update. Self-hosted deployments can pre-warm language servers for common packages, and the retry mechanism handles stale diagnostics by re-running the full validation chain.
Related reading
- [Goatfied vs Cursor vs GitHub Copilot: a benchmark across 50 real PR tasks](/blog/goatfied-vs-cursor-vs-github-copilot-benchmark-50-pr-tasks)
- [The Goatfied agent loop: how we ship code that actually compiles first try](/blog/goatfied-agent-loop-compiles-first-try)
