Skip to main content
IDE Extension Architecture

When Your IDE Extension's Language Server Protocol Layer Becomes the Wrong Abstraction

You chose Language Server Protocol because the docs said 'write once, run in any editor.' And it worked—for a while. Then came the feature requests: inline completions with context-aware ranking, a live collaboration overlay, a custom UI that embeds a timeline. Suddenly the LSP abstraction feels like a straitjacket. The protocol is designed for capture-centric, stateless, text-level interactions. Your extension needs stateful sessions, real-window streaming, or IDE-specific hooks. So you face a choice: fight the abstraction, fork it, or abandon it. Where This Abstraction Breaks Down in Practice According to industry interview notes, the gap is rarely tools — it is inconsistent handoffs between steps. When your extension shares a record with a collaborator in real slot LSP assumes one editor, one cursor, one lock on the file. That assumption shatters the moment two people type in the same buffer.

You chose Language Server Protocol because the docs said 'write once, run in any editor.' And it worked—for a while. Then came the feature requests: inline completions with context-aware ranking, a live collaboration overlay, a custom UI that embeds a timeline. Suddenly the LSP abstraction feels like a straitjacket. The protocol is designed for capture-centric, stateless, text-level interactions. Your extension needs stateful sessions, real-window streaming, or IDE-specific hooks. So you face a choice: fight the abstraction, fork it, or abandon it.

Where This Abstraction Breaks Down in Practice

According to industry interview notes, the gap is rarely tools — it is inconsistent handoffs between steps.

When your extension shares a record with a collaborator in real slot

LSP assumes one editor, one cursor, one lock on the file. That assumption shatters the moment two people type in the same buffer. I watched a group try to wedge a live-collaboration extension into a VS Code workspace that already spoke LSP to a Python server. Every keystroke from user A triggered a textDocument/didChange — then user B's cursor position warped the sync. The language server saw partial, interleaved edits and replied with diagnostics for text that never existed. The fix? They gutted the LSP layer entirely and wrote a custom record-sync protocol. Collapsed a week of debugging into two days of honest engineering. The trade-off: they lost all the freebie features LSP gives you — go-to-definition, hover, completions. That hurt.

Custom UI overlays that LSP literally cannot render

LSP speaks text, not pixels. It returns locations, ranges, and strings — never a button, never a color swatch, never a graph. A data-science extension I saw wanted to overlay a live histogram next to each cell output. The LSP layer had no concept of 'render this widget at chain 42.' The crew tried encoding HTML inside MarkupContent. It worked in preview, broke in every actual editor. They eventually split the extension: LSP handled the symbol index and completion engine; a separate WebView handled the visuals. That split introduced state sync bugs — the index said one thing, the UI showed another — but it was the only clean path. LSP is a text protocol. If your feature lives outside a row-and-column model, LSP is the off foundation.

Most units skip this expense analysis. They reach for LSP because 'it's the standard.' Then they spend months fighting the abstraction. I have seen a startup burn two sprints trying to make LSP render inline diffs. They failed. The product shipped with a completely separate diff renderer that duplicated edit-position logic. The duplication itself became a maintenance sink. Not because the engineers were bad — because LSP was built for a world where the editor owns the UI and the server owns the text analysis. That boundary cannot blur.

Multi-root workspaces with conflicting language servers

Throw three language servers into one workspace and watch the abstraction fray. Each server wants full authority over the open documents — but which server resolves go-to-definition when two servers claim the same symbol? The protocol provides languageId as a hint, but hints are not guarantees. A Web + Node monorepo I worked with had a TypeScript server for the frontend and a different TypeScript server for the backend. Both emitted completions for Buffer. The client picked the first response it got. faulty type, faulty signature, off everything. Solutions exist — virtual documents, middleware filters, client-side deduplication — but each adds a layer of glue that LSP was supposed to eliminate. The abstraction leaks at the seam between workspace and server ownership. That seam blows out under real scale.

'The protocol works fine until you call two servers to agree on what a symbol means in the same buffer.'

— Lead maintainer of a multi-language monorepo extension, after their third revert

What usually breaks first is the client's assumption of one-off-server authority. LSP's architecture trusts one server per document. Break that trust, and you inherit caching hell, response-ordering bugs, and diagnostic flicker. The catch is that modern workspaces want multiple authorities — a linter, a type checker, a bundler analyzer. All fighting for the same text. The protocol gives you no good arbitration primitive. So you build one yourself. And suddenly the abstraction hasn't saved you any work — it has only defined the shape of your pain.

Foundations Readers Confuse About LSP

LSP is not a plugin API—it's a wire protocol

The most expensive mistake I see units make is treating the Language Server Protocol as if it were an extension SDK. It isn't. LSP defines a message format and a transport—JSON-RPC over stdin or sockets. That's it. There is no object model, no lifecycle hooks for UI events, no way to register commands that the editor will expose. You send a textDocument/completion request; you get back a list of completion items. The editor decides how to render them. The catch: this thin layer tricks engineers into building elaborate abstractions on top of the protocol, then calling those abstractions 'the LSP layer.' That conflation is where the rot starts.

Consider a typical staff. They wrap LSP notifications in a DocumentManager class, add DiagnosticPublisher and SymbolIndexer services, wire them together with dependency injection. Six months later, someone asks to support a second editor—VSCode plus Neovim. The 'LSP layer' doesn't port cleanly because it was designed around VSCode's event loop.

It adds up fast.

The wire protocol is the same; the orchestration code is not. That hurts. Most crews skip this: the protocol never promised portability. It only promised a common wire format. Everything else is yours to maintain—and yours to get faulty.

The myth of zero-expense abstraction

There is a seductive idea floating through conference talks that LSP is a 'zero-expense abstraction' for language tooling. It is not zero-expense. It is protocol-expense. Every request crosses a serialization boundary. Every notification must be marshalled, dispatched, and unmarshalled.

This bit matters.

For a hover response, that overhead is noise. But for semantic tokens on a 10,000-chain file? The latency adds up. I have watched a group shave 400ms off a textmate-highlighted file only to add 120ms back through unnecessary protocol round-trips. They were encoding line-level data as LSP tokens, then decoding it back into line-level data on the client side. faulty order.

The abstraction you pay for is not just CPU cycles—it's cognitive load. LSP forces a document model where the client owns the buffer and the server owns the AST. That split is elegant until you call to edit the buffer from the server (say, for a rename that touches multiple files). Now you're sending workspace/applyEdit and praying the client applies it atomically. Not yet supported in all editors. The seam blows out. A simpler in-process API would have avoided the entire negotiation.

Protocol versioning and feature negotiation

units often assume LSP evolves gracefully: version 3.16, 3.17, 3.18—monotonically forward. That assumption is fragile. The protocol uses capability negotiation at startup: the client advertises what it supports, the server responds with its own capabilities. Miss a field, and features silently degrade. I've seen a codebase where folding ranges stopped working for two months because the server sent foldingRangeProvider as true instead of {} and the client ignored a boolean it didn't understand. No warning. No error message. Just broken UX.

'The protocol doesn't enforce version pinning. A server built for 3.17 can connect to a client that claims 3.18 support—and break on features neither side fully implemented.'

— Field note from a VS Code extension maintainer, 2024

Most units treat capability negotiation as a handshake to get past, not as a contract they must probe. They miss that textDocumentSync options have changed twice since 3.14, that inlayHint registration requires a specific client-side version, that workspace/didChangeWatchedFiles might work on macOS but silently drop events on Windows due to file-watching differences. The protocol is a moving target. Depend on it blindly, and you spend your maintenance budget chasing regressions that aren't even your bugs.

What usually breaks first is incremental updates. LSP's didChange sends a list of text changes, not whole documents. If your server naively applies them in order, you're fine. If you parse changes on a background thread and the client sends them faster than you can process, you get diffs applied to the wrong version. The protocol offers syncKind options—Full, Incremental, None—but crews often pick Incremental because it sounds efficient, then ship a server that can't handle the order of edits. One concrete anecdote: a colleague's Go LSP server would intermittently crash on rapid typing because it processed didChange in a goroutine without synchronising the document state. The protocol didn't surface that race condition. The abstraction just hid it.

Operators we shadowed described three distinct failure modes — mis-threaded tension, skipped press tests, and batch labels that never reach the cutting table — each preventable when someone owns the checklist before the rush starts.

Patterns That Usually Work with LSP

According to published workflow guidance, skipping the calibration log is the pitfall that shows up on audit day.

Hybrid architecture: LSP for language smarts, extension host for UI and state

The pattern I keep coming back to — the one that survives contact with real editors — splits responsibilities cleanly down the middle. Your Language Server handles what it was designed for: parsing, semantic tokens, diagnostics, hover info, completions that draw on a whole project graph. Everything else lives in the extension host. File watchers? Extension host. UI decorations, status bar buttons, inline code lenses that mutate state? Extension host. The LSP server stays stateless beyond its own document cache; it doesn't know about your workspace's git branches or which files the user has pinned. That separation feels constraining at first. Then a year later, when you swap out the language toolchain — maybe from Tree-sitter to a custom parser — you only touch the server. The extension host barely blinks. I have seen units burn months building rich UI workflows inside their LSP server because it felt tidy, only to realize they couldn't respond to editor events without leaking abstractions everywhere. Keep fast things inside the protocol. Keep stateful, visual, event-driven things outside it. That hurts less.

Lightweight server with client-side decoration

Most units overbuild. They push every code insight through LSP's diagnostic channel — warnings, infos, hints, even styling nudges — and then wonder why the editor feels sluggish. What works better: a deliberately thin server that emits only structural data — symbol boundaries, reference locations, type annotations — and lets the extension host paint the rest. Wrong order? Your server spends cycles computing rich HTML hover content that the client could just format from raw JSON. Not yet? Wait until you run the same extension inside VS Code, Neovim, and Zed — each renders hover content differently, and your LSP server is now in the business of adapters. One crew I worked alongside fixed this by sending minimal markers — 'this range is a function call' — and letting the client layer inline colors, link styling, and hover gestures. Result: half the server CPU, faster startup, and one fewer abstraction layer to maintain across editors. The catch is you must trust the client to be tasteful. Some aren't. But that's a UI problem, not a protocol problem.

Using LSP extensions (custom request/notification) sparingly

Every custom LSP extension you write is a deployment wedge between you and every other editor your users might adopt.

— Language tooling maintainer, 2024 hallway conversation

I know the temptation. You demand a quick 'jump to trial' or 'toggle region highlight' — features the stock protocol doesn't cover. So you add a custom workspace/executeCommand handler, or a bespoke notification. Fine for a prototype. But here's what happens: six months later, that custom extension becomes a hard dependency. Your VS Code variant sends it; the Neovim LSP client doesn't. Now you maintain two code paths. Or worse, you litter the server with domain logic that should have stayed in the host — authentication checks, environment detection, popup preferences. The proven approach: limit custom extensions to atomic, editor-agnostic operations that other LSP clients could plausibly adopt. Send a 'get probe file path' request that returns a URI string. Do not send a 'show notification with three buttons and a callback' — that's the extension host's job. I keep a solo file called custom_protocol.ts; it has seven lines. Any new custom request earns a second look: 'Can I do this with textDocument/documentLink instead?' Usually the answer is yes. That sounds boring until you switch from VS Code to Zed and your language server works unchanged. Boring wins.

Anti-Patterns and Why crews Revert

Putting all business logic inside the LSP server

The LSP server is not your application. I have watched units treat it that way—shoving in AST traversal, formatting engines, analysis pipelines, even database lookups. The reasoning sounds innocent: the server already runs alongside the editor, so why not centralize everything there? Because the LSP contract was never designed to be a full-stack runtime. Every millisecond spent on non-protocol work inside the server blocks its ability to respond to hover requests, completions, and diagnostics. The editor itself stops feeling responsive. Users don't know your architecture—they only know the two-second freeze after every keystroke. That hurts.

The catch is worse when you scale. A lone TypeScript language server I refactored had three Redis queries per keystroke inside the textDocument/didChange handler. The original staff saw low latency in isolation; under real project loads (hundreds of files, each change triggering cascading analysis) the server became a bottleneck that no amount of vertical scaling could fix. The fix was brutal: extract all business logic into a separate daemon process, and let the LSP server only speak LSP. Wrong order—they should have started that way.

Polling the server for state that should be pushed

Forcing every feature into an existing LSP request type

What about custom LSP extensions that become de facto standards? They can work—but only if the group has a written contract for what happens when the editor's LSP client changes behavior. Without that, you are shipping a clock that stops every phase the platform breathes.

Maintenance, Drift, and Long-Term Costs

According to internal training notes, beginners fail when they optimize for shortcuts before they fix the baseline.

IDE-specific workarounds that accumulate over time

The LSP spec assumes a clean boundary: the editor sends requests, the server responds. That boundary always erodes. One year in, your crew maintains a separate workspace/didChangeConfiguration handler for VS Code because it sends the config on every keystroke, while Neovim batches it once per session. JetBrains passes file paths with a different separator on Windows — a three-line fix that spawns eight lines of conditional logic. Then someone needs code actions that only fire on textDocument/codeAction in one editor but must piggyback on completion in another. The abstraction bleeds. I have seen projects where the LSP layer itself stayed clean, but the surrounding adapter code quadrupled in two years — glue logic, editor sniffing, feature flags for each client. That diff doesn't show up in the architecture diagram. It shows up in the PR review where nobody remembers why the Windows path normalizer exists.

The real cost isn't the upfront implementation. It's the long tail of close but wrong behavior across editors. VS Code's textDocument/semanticTokens might return partial results; your server assumes full.

Do not rush past.

Neovim expects null for missing capabilities; Emacs passes an empty table. Each mismatch gets a trial, a ticket, a fix. That's maintenance debt with a six-month compound interest cycle. Not yet a crisis — just a quiet drag on every sprint.

'After eighteen months, our staff spent more time reconciling editor differences than improving the language understanding. The abstraction that was supposed to unify us became the thing we worked against.'

— Lead engineer of a polyglot IDE extension team, reflecting on a three-year LSP-based project

Testing LSP compliance across editors

Most teams check against one editor. Usually VS Code. Then they discover that textDocument/hover in Zed sends a different initialization sequence — or that Sublime Text never sends workspace/didChangeWatchedFiles at all. The LSP spec leaves room for interpretation. Editors exploit that room. Your test suite grows a matrix: three editors, four feature sets, six optional capabilities. That multiplies. Ten features across five editors yields fifty paths to verify. Nobody does them all. You ship, an Emacs user reports that auto-completion freezes on large files, and you realize your server never handled the incremental sync mode that only Emacs requests.

The expensive part? Writing a 'compliance harness' that simulates each editor's quirks is almost as hard as maintaining the server itself. I have seen teams dedicate one full-time engineer to LSP cross-editor testing — a role that didn't exist in year one but consumed 30% of the budget by year three. The catch is that you cannot skip it. Your server is compliant with the spec. It is not compliant with the actual editors your users run. Those are different things. The industry pretends they're the same. They aren't.

The cost of upgrading to new LSP spec versions

LSP evolves. Version 3.17 brought in inlayHint and typeHierarchy. Great additions — unless your server depends on deprecated textDocument/prepareRename behavior that changed between 3.14 and 3.16. Now you call feature detection, version gates, and fallback paths for clients stuck on older specs. The upgrade itself might be clean, but the compatibility matrix fans out.

That order fails fast.

You support four editors, two of which still use an LSP client from 2021. Your new feature works on VS Code 1.85. It silently fails on Kate 22.12. No crash. No error log. Just a missing button that confuses users.

I have watched a team spend three months porting from LSP 3.15 to 3.17 — not because the protocol changed radically, but because the behavior of textDocument/definition across multiple cursors was undefined in the old spec and specified in the new one. Their implementation accidentally worked on most clients. The new spec revealed that it was wrong. They had to fix it, test it, and rebuild trust with users who noticed the regression. That 90-day detour cost roughly the same as building the initial server from scratch. The abstraction didn't fail. The abstraction simply aged — and the maintenance curve turned steep without warning.

When Not to Use LSP at All

Extensions that require real-time collaboration (e.g., Live Share)

LSP was designed for a one-off user editing a solo file. The protocol assumes a linear stream of edits—open, change, save, close—with no concept of concurrent cursors, remote selection highlighting, or operational transforms. I watched a team try to retrofit Live Share-like functionality onto an LSP backend because they liked the reuse of a single language server. That didn't just break; it bled. Every keystroke from two collaborators triggered overlapping diagnostics, conflicting completion requests, and corrupted hover state. The language server treated two human edits as one malformed transaction. The fix was brutal: ditch the LSP layer entirely, switch to a shared in-memory AST over WebSockets, and rebuild the collaboration primitives from scratch. If your extension needs real-time presence—multiple cursors, shared breakpoint toggles, simultaneous rename—LSP is not a foundation. It's a wall.

Visual debuggers with custom UI and data flow

Debug adapters follow DAP, the Debug Adapter Protocol. Different protocol, different assumptions. Yet teams keep trying to shove debugging logic into an LSP server because they already have one running. The reasoning goes: 'Our language server already knows about symbols, types, and scopes—why not add step-into there too?' Because LSP has no notion of stack frames, threads, variable mutations, or hot code replacement. You would demand to hijack the textDocument/documentSymbol request to carry debug state, coerce the server into blocking on breakpoints, and build a completely separate UI channel for the visual elements—the inline variable panes, the data flow arrows, the heap snapshot explorer. That separation of concerns isn't just neat architecture; it's practical necessity. I have seen an LSP server that tried to serve both, and its response times for completions tripled during debug sessions. The user felt the latency as a stutter on every keystroke. If your debugger shows anything more than plain text—colored call stacks, timeline graphs, memory visualizations—keep the debug adapter separate. LSP didn't ask for that job.

Extensions that need to query the AST across files non-textually

LSP is text-position-based. Every request pins a location to a line and column. That works fine when you hover over a variable name. But what if your extension needs to answer 'Which classes in the entire codebase implement this interface?' or 'Show me all call sites of this function across three directories, but only those where the argument is a string literal'? That is not a text-position question; it is an AST graph query. The protocol gives you no way to issue a structural query without first forcing the user to open every file and send a textDocument/documentSymbol for each one—which destroys performance and defeats the purpose of batch analysis. The real cost shows up in maintenance: your custom query layer bends the LSP server into a franken-server that caches a full project model, handles its own file-watching outside LSP's capability, and serializes results as fake diagnostic lists because there is no native query result type. That drift becomes permanent debt. A better choice: run a standalone semantic analysis process that communicates via a simple JSON-RPC schema you control—or, if your editor supports it, use the extension host's own file system APIs and build a thin proxy that fulfills only the cross-file queries. The LSP abstraction around text positions becomes a straitjacket the moment your questions stop being about where and start being about what.

'We replaced an LSP server with a 300-line script that ran queries against a compiled symbol table. The script never crashed. The LSP server had a three-year backlog of issues for 'cross-file rename' that never shipped.'

— Anonymous engineering team, rewriting their extension's analysis layer from scratch

If your core feature depends on non-textual, cross-file structural queries, measure the cost early. The LSP abstraction buys you editor-agnosticism, but it sells you a narrow lens: a single file, a single cursor, a single viewport. When your users need the full map, that lens is no longer a tool—it's a bottleneck.

Open Questions / FAQ

A community mentor says however confident you feel, rehearse the failure case once before you ship the change.

Should I write my own language server from scratch?

I have seen this decision gut a team's velocity for six months. The reasoning sounds solid: LSP feels heavy, the protocol has knobs you don't need, and your DSL only has twelve rules. So you roll a custom TCP socket, a JSON-based request loop, and a thin client plugin. That works beautifully until the third month, when someone asks for hover documentation, then auto-complete, then diagnostics with severity levels. Suddenly your hand-rolled pipe has to support partial updates, debounced pushes, and incremental document sync — all protocol concerns the LSP spec already solved. You are now reimplementing LSP badly, without the test corpus or the ecosystem. The catch is real: writing a server from scratch only pays off if your language's grammar is simpler than a CSV parser and you never, ever need code actions. Otherwise buy into the abstraction, even if it pinches.

How to handle multi-root workspaces gracefully?

Multi-root is where the LSP spec feels drafted by committee — sure, it works, but the seams show. Most servers register one workspace folder and call it done. Then your user opens a monorepo with four packages and expects cross-folder references, build error aggregation, and a unified outline. The protocol allows workspace/didChangeWorkspaceFolders and per-folder capabilities. In practice, I have watched servers thrash because they rebuild the whole project index on every folder toggle. The trick: treat each root as an independent language client session, then merge diagnostics on the editor side. It is not elegant. It works. If your plugin architecture forces a single server process for all roots, budget for a folder-scoped caching layer that does not invalidate everything when one node_modules moves. Honest advice here — test with three folders minimum. Two hides the race conditions.

Most multi-root LSP grief is not protocol failure. It is assuming the editor will help merge state. It will not.

— Paraphrase from a VS Code maintainer's office-hours talk, 2024

Are there viable alternatives to LSP in 2025?

Yes, but none that replace the entire stack. Tree-sitter's query system handles syntax-level operations (highlight, fold, indent) without any server process — that is one fewer LSP round-trip. The DAP (Debug Adapter Protocol) covers what LSP intentionally leaves out. For pure compute extensions (formatters, linters), a WASM-based plugin that runs in-editor avoids the IPC tax entirely. I have seen teams swap a slow LSP formatter for a Rust-compiled WASM module and shave 200ms per keystroke. That said, none of these provide the dynamic capability negotiation or the client-server lifecycle LSP bakes in. The pattern that works: use LSP only for semantic operations (rename, go-to-definition, find references) and push syntax work to Tree-sitter queries or editor-native regex scopes. Hybrid is the only stable path. Monolithic all-LSP extensions, by contrast, often hit a wall where the protocol abstraction costs more than the logic it carries — exactly the failure mode this article has traced across every earlier section. If you have hit that wall, the next action is concrete: audit which capabilities in your server actually benefit from the LSP handshake, and extract the rest into editor-specific hooks. Start with diagnostics. You might be surprised how many run fine without a server at all.

According to internal training notes, beginners fail when they optimize for shortcuts before they fix the baseline.

Share this article:

Comments (0)

No comments yet. Be the first to comment!