architecture
LSP routing strategies for monorepos
Learn three LSP routing strategies that prevent language servers from crawling in monorepos by scoping requests to the smallest project boundary.

Monorepos promise one-version consistency and atomic refactors across every service, but they quickly hit a developer-experience ceiling: your editor crawls the moment three TypeScript projects share the same tsconfig.json, or Rust Analyzer tries indexing a million-line workspace. The fix is LSP routing—making sure language-server requests land in the smallest possible scope—and most teams get it wrong by either under-scoping (one LSP for the entire repo, freezing every autocomplete) or over-scoping (launching dozens of server instances that exhaust memory).
This guide walks you through three proven routing strategies, when to pick each, and how to wire them into your editor or CI without breaking cross-package navigation. By the end you'll know which flags to flip in tsserver, rust-analyzer or gopls, and what wiring your build tool needs so developers still get instant go-to-definition across folder boundaries.
Why vanilla LSP configurations fail in monorepos
Language servers were designed for single-project roots. When you open a 10-project monorepo in VS Code, the default behavior spawns one LSP instance at the workspace root. That server tries to parse every source file—across frontend, backend, infrastructure and data pipelines—simultaneously. On a modest laptop, this can mean:
- 15+ second autocomplete delays while TypeScript re-checks unrelated packages.
- Out-of-memory crashes when Rust Analyzer indexes every binary and library target in a 500-crate workspace.
- Stale diagnostics because the server is too busy to re-analyze the file you just edited.
The opposite mistake—launching a separate LSP instance per package—avoids the slowdown but breaks cross-package jumps: clicking on an import from @acme/shared-utils won't resolve if the server only knows about @acme/web-app.
Strategy one: workspace-relative server instances with explicit roots
Most language servers accept a list of workspace folders or an explicit root hint. The idea is to run one server per logical project, where a project is defined by a build boundary (a package.json, Cargo.toml or Go module).
TypeScript example:
Configure tsserver.experimental.workspaceFolders in VS Code's settings to pass each packages/*/tsconfig.json as a separate root. The editor spawns one tsserver per package but keeps them in memory simultaneously, so cross-package navigation still works—TypeScript will load the sibling package's types when you hover over an import.
Rust example:
In a Cargo workspace, set "rust-analyzer.linkedProjects" to ["Cargo.toml", "tools/codegen/Cargo.toml"] if tools/codegen is excluded from the main workspace. This tells Rust Analyzer to run two parallel instances that share the target/ directory but maintain separate proc-macro servers, avoiding macro-expansion collisions.
When to use this:
Your monorepo has 3–10 distinct sub-projects, each with its own toolchain version or heavy dependency tree. You have enough RAM (16 GB+) to keep multiple servers resident, and you need cross-project jumps.
Strategy two: dynamic server lifecycle with file-watcher triggers
Instead of keeping every server running all the time, let the editor start and stop them on demand: when you open a file in packages/frontend, launch the tsserver for that package; when you haven't touched it in five minutes, kill the process.
Implementation:
Write a thin wrapper script (bash, Node or Go) that listens to file-open events from your editor's LSP client, inspects the file path, finds the nearest package.json or equivalent, and spawns the server with --rootPath pointing there. Most editors (Neovim with lspconfig, Emacs with lsp-mode, Helix) let you replace the default server command with your script.
Caching go-to-definition:
The downside is that jumping from frontend code into @acme/shared-utils may pause while the shared-utils server cold-starts. Mitigate this by keeping a background indexer (a separate daemon) that pre-parses common dependencies and exposes a JSON map of symbol locations; your wrapper consults the map for cross-package jumps instead of blocking on a fresh server.
When to use this:
You have 10+ projects and limited developer hardware (8 GB RAM), or your CI runners can't afford to keep every server alive. Developers rarely work across more than two packages in a single session.
Strategy three: unified server with ignore lists and selective type-checking
Rather than multiple server instances, run one LSP but configure it to skip directories you're not editing. Most servers expose flags like --exclude or include/exclude arrays in their config files.
TypeScript example:
In tsconfig.json at the root, set "exclude": ["packages/mobile/**", "packages/data-pipeline/**"] and enable skipLibCheck: true. Developers working in packages/web won't pay the cost of mobile or pipeline type-checks. When someone does need to edit packages/mobile, they temporarily remove that line from the exclude array (or maintain a per-user tsconfig.local.json that overrides it).
Go example:
Run gopls with "buildFlags": ["-tags=web"] so it only analyzes packages tagged web, ignoring the cli and server modules. Cross-module imports still resolve because Go's package cache already has the compiled binaries; gopls just won't re-check their source.
When to use this:
Your projects share the same language version and most dependencies, and cross-package changes are common (so you want a single source of truth). The trade-off is that excluded packages get zero autocomplete; this works if your team can mentally partition "I'm in the web context today, mobile can wait."
Wiring routing into CI and code-review tools
Local editor routing improves developer speed, but CI still needs to type-check the entire repo atomically—otherwise you'll merge a PR that passes local checks but breaks a downstream package. Reconcile this by:
- Explicit CI config per package: In
.github/workflows, runtsc --build packages/webandtsc --build packages/apias separate jobs, then a finaltsc --build tsconfig.jsonthat compiles the full workspace with no excludes. - Caching strategy: Share the LSP's build artifacts (
.tsbuildinfo,target/for Rust) between local dev and CI so the all-packages check reuses incremental state and finishes in seconds. - Pre-commit hooks: Use a tool like Husky to run the scoped LSP check matching the files you modified—if you only touched
packages/web, the hook invokes the web-scoped server, not the full-repo server.
When code-review bots like Danger or GitHub Actions add inline comments, point them at the same scoped LSP instance your local editor uses. This avoids the confusing scenario where a PR comment flags an error in packages/api even though you never touched that package—because the bot ran the global server.
Balancing scope and navigation: hybrid approaches
Many teams converge on a hybrid model:
- Primary server: One LSP instance covering the 2–3 packages you edit most often, with full autocomplete and diagnostics.
- Shadow indexer: A read-only background process (possibly a lighter tool like
ctagsor Tree-sitter queries) that indexes the entire monorepo for symbol search and go-to-definition, but doesn't perform type-checking.
Your editor's go-to-definition command first queries the primary server (fast, type-accurate) and falls back to the shadow indexer if the symbol isn't in scope (slightly slower, no semantic validation). This gives you sub-100ms autocomplete in your active packages and 500ms cross-repo jumps—a reasonable middle ground.
For larger monorepos (50+ packages), consider build-graph-aware routing: run bazel query or the equivalent for your build tool to find which packages depend on the file you opened, then launch LSP instances only for those packages. This minimizes memory while preserving the ability to see downstream breakage.
Tooling checklist: what you need to implement each strategy
| Strategy | Required tooling | Editor support |
|------------------------------|---------------------------------------------------------------------------------|---------------------------------|
| Workspace-relative instances | Multi-root workspace API in editor; language server with --rootPath or similar | VS Code, Neovim, Emacs, Helix |
| Dynamic lifecycle | File-watcher hooks; custom LSP wrapper script | Any editor with custom LSP command |
| Unified + ignore lists | Language server with exclude config; per-user config overlays | Any editor |
All three strategies benefit from editor plugins that visualize which server is active. A status-bar indicator showing "tsserver [packages/web]" vs. "tsserver [root]" helps developers understand why autocomplete might be slow or missing, and reminds them to switch scopes when needed.
If you're building on Goatfied's platform, the BUILD suite's AI code editor already handles multi-root workspaces: cloud coding agents test their changes in isolated package scopes and open pull requests that include only the touched packages, so you never pay the cost of re-checking unrelated code during review.
Frequently asked questions
Can I run different language-server versions for different packages?
Yes. Use the workspace-relative strategy and override the server executable path per root. For example, packages/legacy might point to an older typescript-language-server while packages/new-app uses the latest. Just ensure your build tool can still compile both packages in the same CI run, which may require Docker or Nix to supply multiple Node versions.
How do I avoid duplicating dependency indexing across servers?
Most language servers cache parsed modules in a shared directory—TypeScript uses node_modules/.cache/tsserver, Rust Analyzer writes to target/. Point every server instance at the same cache path, and the second server to start will reuse the first server's work. For very large repos, pre-populate the cache in CI and rsync it to developer machines overnight.
What if my monorepo mixes languages?
Launch one LSP per language, scoped to the relevant sub-trees. For a TypeScript + Rust + Go monorepo, you might run tsserver for packages/web, rust-analyzer for services/core and gopls for services/api. The editor keeps all three alive and routes requests by file extension. Cross-language navigation (e.g., TS calling a Rust WASM module) requires a build step that generates TypeScript bindings; the TS server sees those bindings as regular .d.ts files.
How does this interact with remote development or Codespaces?
The same strategies apply, but dynamic lifecycle becomes more attractive because cloud VMs often have limited RAM per user. Configure your remote editor to spawn servers on demand and kill them aggressively; the network latency to the language server is usually negligible compared to re-indexing time. Alternatively, run a persistent indexer as a system daemon on the remote host, shared across all users' sessions.
Can build tools like Bazel or Turborepo help with LSP routing?
Absolutely. Bazel's query command and Turborepo's task graph both know which packages a file affects. Write a small integration that queries the build graph when you open a file, then spawns LSP instances only for packages in the forward and backward dependency cone. This automates the scoping decision and keeps it in sync with your build config. For an example of how engineering teams wire this together, see the integration guides for Bazel + clangd or Nx + tsserver.