Skip to content
Goatfied

refactoring

Monorepo-aware LSP routing at scale

Learn how monorepo-aware LSP routing directs editor requests to the correct language servers without scanning entire repositories or exhausting memory.

2026-09-018 min readBy Goatfied
Monorepo-aware LSP routing at scale

Large monorepos demand a different approach to Language Server Protocol routing than single-project repositories. When a developer opens a file deep inside a 50,000-file monorepo, their editor must send that file's content to the correct language server—one configured with the right TypeScript version, Python virtual environment, or build targets for that specific project—without scanning the entire repository or launching dozens of redundant server processes. Monorepo-aware LSP routing solves this by mapping file paths to the minimal set of language servers needed, keeping autocomplete fast and memory usage predictable even as the repository grows.

Without intelligent routing, most editors either launch one language server per workspace root (missing project-specific tooling) or spawn a server for every subdirectory (exhausting memory). The routing layer sits between the editor client and language servers, inspecting file paths and workspace structure to direct requests to the appropriate server instance, start servers on demand, and shut down idle processes.

How LSP routing works in monorepos

A routing layer intercepts LSP messages—textDocument/didOpen, textDocument/completion, textDocument/definition—and forwards them to language servers based on file path patterns and project boundaries. The router maintains a registry of active servers, each bound to a specific project root within the monorepo, and decides which server should handle each request.

The core mechanism:

1. Project detection: Scan for marker files (package.json, pyproject.toml, go.mod, Cargo.toml) to identify project boundaries.

2. Path-to-server mapping: Build a trie or prefix map where each node points to the language server responsible for files under that path.

3. Server lifecycle: Start servers lazily when a file is opened, reuse running servers for subsequent requests, and terminate servers after an idle timeout.

4. Request forwarding: Translate file URIs in LSP requests to project-relative paths if the server expects them, then send the request to the correct server instance.

For example, a monorepo with apps/web (Next.js), apps/api (Node.js), and packages/shared (TypeScript library) might run three TypeScript language servers, each with its own tsconfig.json. When you open apps/web/pages/index.tsx, the router checks the path prefix, finds the server for apps/web, and forwards completion requests there.

Strategies for mapping files to servers

Project-root detection

The simplest strategy: walk up from the opened file until you find a project marker. This works well when projects are self-contained and don't share source files.


monorepo/

├── services/

│   ├── auth/

│   │   ├── pyproject.toml    <- project root

│   │   └── src/

│   └── billing/

│       ├── pyproject.toml    <- project root

│       └── src/

└── libs/

    └── shared/

        ├── pyproject.toml    <- project root

        └── shared/

Opening services/auth/src/main.py triggers a walk from main.py → src → auth → finds pyproject.toml, starts a Python language server with services/auth as the workspace root.

Explicit workspace configuration

For complex monorepos, maintain a manifest (e.g., lsp-routing.json) that explicitly lists project paths, language server commands, and initialization options:


{

  "projects": [

    {

      "path": "apps/web",

      "language": "typescript",

      "command": ["typescript-language-server", "--stdio"],

      "initOptions": { "tsconfig": "apps/web/tsconfig.json" }

    },

    {

      "path": "packages/*",

      "language": "typescript",

      "command": ["typescript-language-server", "--stdio"]

    }

  ]

}

The router expands globs, matches file paths, and uses the specified command and options for each project.

Multi-root workspaces

Some language servers (Rust Analyzer, gopls) support multi-root workspaces natively. Instead of routing to separate server instances, send all roots to one server during initialization:


{

  "workspaceFolders": [

    { "uri": "file:///monorepo/services/auth", "name": "auth" },

    { "uri": "file:///monorepo/services/billing", "name": "billing" }

  ]

}

The server indexes all roots and internally routes requests. This reduces memory overhead but requires language server support.

Handling cross-project dependencies

Monorepos often have projects that depend on each other—apps/web imports from packages/ui, or services/billing imports libs/shared. The routing layer must ensure language servers can resolve these references.

Option 1: Single server for related projects

Group interdependent projects under one language server instance. For a TypeScript monorepo using npm workspaces, start one server at the monorepo root with a composite tsconfig.json that references all projects.

Option 2: Path mapping in server configuration

Configure each language server with paths to dependencies. TypeScript's paths in tsconfig.json, Python's PYTHONPATH, or Go's replace directives in go.mod let the server resolve imports across projects.

Option 3: Build outputs

Some teams build shared libraries and point dependent projects at the compiled output. Language servers see the dependency as an external package, avoiding cross-project source indexing.

Optimizing server lifecycle and memory

Starting a language server for every project directory in a 100-project monorepo quickly exhausts memory. Strategies to keep resource usage in check:

| Strategy | When to use |

|----------|-------------|

| Lazy start | Launch servers only when files are opened, not at editor startup. |

| Idle shutdown | Terminate servers after 10–15 minutes of inactivity; restart if needed. |

| Shared server pools | Reuse one server instance for multiple small projects of the same language. |

| Explicit allow-lists | Let developers choose which projects to index via settings, skipping rarely-touched services. |

| Incremental indexing | Use language servers that index files on demand (e.g., Jedi for Python) instead of parsing the entire codebase upfront. |

A typical setup might run 3–5 language servers simultaneously (the projects currently being edited), with others started and stopped as focus shifts.

Tooling and editor integration

Most LSP clients (VS Code, Neovim, Emacs) don't route requests by default; they expect one server per language. To add routing:

  • Editor plugins: VS Code extensions like vscode-multi-root-workspace or custom plugins that implement routing logic in TypeScript.
  • Proxy servers: A standalone process (written in Node.js, Rust, or Go) that listens on stdin/stdout, routes messages, and manages child language server processes.
  • Build-tool integrations: Bazel's vscode-bazel or Buck2's LSP integration detects targets and configures servers automatically.

For teams using Goatfied's BUILD platform, the AI code editor and cloud coding agents handle monorepo awareness natively—agents understand project boundaries when they test changes and open pull requests, and the editor routes LSP requests based on workspace structure without manual configuration.

Common pitfalls

  • Overlapping project roots: If both packages/ui and packages have marker files, the router must choose the most specific match.
  • Language server assumptions: Some servers assume they own the entire workspace and break when multiple instances run in subdirectories.
  • Symlinks and virtual filesystems: Symlinked projects or Bazel's output trees can confuse path-based routing; resolve symlinks before mapping.
  • Performance of project detection: Scanning thousands of directories on every file open is slow; cache the project map and rebuild only when marker files change.

Frequently asked questions

Can I use one language server for the entire monorepo?

Yes, if the language server supports multi-root workspaces or the monorepo is small enough. Large monorepos (>10,000 files per language) often hit memory and indexing limits, making per-project servers necessary.

How does routing handle files outside any project?

Configure a fallback server at the repository root or skip LSP features for unmatched files. Some setups treat the root as a catch-all project.

Do cloud-based code editors need different routing?

Yes—cloud editors must route LSP requests over the network, adding latency. Some run language servers in the same cluster as the file storage to minimize round-trips, and batch requests aggressively.

What if two projects need different versions of the same language server?

Run separate server binaries (e.g., typescript-language-server-4.9 and typescript-language-server-5.0) and map projects to the correct command. Version managers like asdf or mise can automate this.

Does routing work with remote development (SSH, containers)?

Yes, but the routing logic must run where the files live. For SSH remotes, the routing proxy runs on the remote machine; for containers, inside the container or with volume mounts that preserve paths.

Related posts

Monorepo-aware LSP routing at scale | Goatfied Blog