How Autocomplete and Go-to-Definition Actually Work in VS Code

June 23, 2026 · updated June 25, 2026 · The Engine Behind Every AI Code Editor (part 3)

▶ Watch on YouTube & subscribe to The Stack Underflow

You type a dot. A ranked list of method suggestions appears in under 50 ms. You press F12 and the cursor jumps to a function definition three packages away. It feels like the editor understands your code at a deep level. It doesn’t — and the thing that actually does understand it is running in a completely separate OS process that you almost certainly have never thought about.

This post traces the exact path of a single autocomplete request from the keystroke that triggers it to the dropdown that renders on screen. Along the way you will see why VS Code is architecturally a thin display client, how language servers build their in-memory models of your project, what travels over the wire between them, and why every serious AI code editor in 2025-2026 — Cursor, Windsurf, GitHub Copilot — is built on this same foundation without modifying a line of it.

The one-sentence version: Your editor is a thin JSON-RPC client that fires standardised requests to a separate language server process; the Language Server Protocol (LSP) defines the shared vocabulary so any editor can talk to any language server without either side needing to understand the other.

VS Code’s Four-Process Architecture

Before getting to LSP itself, you need to understand where language servers sit inside VS Code’s process model. VS Code is built on Electron, which wraps Chromium and Node.js into a desktop application. Because Chromium is inherently multi-process, VS Code inherits that model and extends it.

VS Code process tree (one window)
─────────────────────────────────────────────────────────────
 Main process (Node.js / Electron main)
   └── Renderer process (Chromium — draws the editor UI)
         └── Extension Host process (Node.js — runs all extensions)
               └── Language Server process (any language — spawned by extension)

The main process manages application lifecycle, native OS dialogs, and inter-process communication (IPC). The renderer process runs Chromium and draws everything you see: tabs, the file tree, the editor surface. The extension host process is a sandboxed Node.js runtime that runs all activated extensions; crashing it does not crash the renderer. The language server process is spawned by a language extension inside the extension host and is yet another isolated process.

This four-level isolation is not accidental. Language analysis is expensive — parsing a large TypeScript project can consume hundreds of megabytes of RAM and seconds of CPU. Running that work in the renderer process would freeze the UI. Running it in the extension host risks taking down all extensions. So it lives one more level out, in its own process, communicating back through LSP.

The Language Server Protocol Wire Format

LSP is a JSON-RPC 2.0 specification originally created by Microsoft for VS Code in 2016 and open-sourced so every editor and every language could converge on one shared vocabulary. The current specification is LSP 3.18, released in 2024-2025 (microsoft/language-server-protocol on GitHub). VS Code ships implementations through the vscode-languageclient and vscode-languageserver-node packages.

Messages travel over one of four transport mechanisms — IPC (the default for Node.js servers), stdio, named pipes, or TCP sockets. The wire format looks like HTTP: a header section followed by a JSON body, separated by \r\n.

Content-Length: 158\r\n
\r\n
{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "textDocument/completion",
  "params": {
    "textDocument": { "uri": "file:///project/src/app.ts" },
    "position": { "line": 14, "character": 12 }
  }
}

Every request carries a method name and a params object. Responses carry the same id so the client can match them up asynchronously. Servers can also push notifications (no id, no response expected) — diagnostics are delivered this way, as unsolicited textDocument/publishDiagnostics events.

The initialization handshake is the first thing that happens when a language server is spawned. The client sends initialize with its ClientCapabilities — a structured JSON object declaring every LSP feature the editor supports. The server responds with ServerCapabilities declaring what it can actually do. Feature support is guarded by these capability flags, so a client that does not advertise textDocument.completion support will never receive completion responses, and unknown capabilities are safely ignored by both sides. This is how LSP stays backward-compatible across versions.

LSP methodDirectionWhat it does
initializeclient → serverHandshake; exchanges capabilities
textDocument/didOpenclient → serverSends full file content to server
textDocument/didChangeclient → serverSends incremental edits on every keystroke
textDocument/completionclient → serverRequests completions at a cursor position
textDocument/definitionclient → serverRequests the definition location of a symbol
textDocument/referencesclient → serverRequests all usage sites of a symbol
textDocument/hoverclient → serverRequests inline docs / type signature
textDocument/publishDiagnosticsserver → clientPushes errors and warnings (notification)
textDocument/renameclient → serverRequests all edits needed to rename a symbol
workspace/symbolclient → serverSearches across all symbols in the project

LSP 3.17 (2022) added inlay hints, inline values, type hierarchies, and notebook document support. LSP 3.18 (2024-2025) extended this with inline completions, snippet text edits, markup content in diagnostics, position encoding negotiation (UTF-8 / UTF-16 / UTF-32), relative glob patterns in document filters, and refresh support for folding ranges. The specification is actively maintained; you should always check the current version at microsoft.github.io/language-server-protocol before citing feature availability.

Inside the Language Server: How Symbol Indexes Are Built

When you open a project, the first thing the language server does is read your files and build an in-memory model of your codebase. The exact mechanism differs by server, but the stages are the same.

Source files on disk
       |
       v
  [1] Lexer / tokenizer
       |  (splits text into tokens: keywords, identifiers, operators)
       v
  [2] Parser
       |  (builds a Concrete Syntax Tree — preserves whitespace and comments)
       v
  [3] AST / HIR
       |  (Abstract Syntax Tree or High-level Intermediate Representation)
       v
  [4] Name resolution + type inference
       |  (resolves what each identifier refers to; infers types)
       v
  [5] Symbol index
       |  (maps every name to its definition location, type, and usage sites)
       v
  Queries (completion, definition, references...) answered from index

rust-analyzer is the reference example for a well-architected language server. It tokenizes with ra_ap_rustc_lexer, then constructs a lossless Concrete Syntax Tree (CST) using the rowan library — “lossless” meaning whitespace and comments are preserved so the source can be reconstructed character-for-character. Typed wrappers (ast::FnDef, etc.) provide a structured view over the generic CST nodes. Name resolution uses a DefCollector component that performs fixed-point iteration to expand macros and resolve names simultaneously. The entire analysis pipeline is wired through Salsa, a demand-driven incremental computation framework: results are cached, and when a file changes, only the downstream computations that depend on that file are re-evaluated. This is why rust-analyzer stays fast after the initial warmup — re-indexing a single file change re-runs only what changed.

tsserver (TypeScript’s language server) is architecturally distinct: it predates LSP and speaks its own custom line-delimited JSON protocol over stdio, not the standard LSP wire format. The typescript-language-server package acts as a protocol adapter — it receives LSP requests from VS Code, translates them to tsserver’s native commands, and translates the responses back. tsserver itself manages a ProjectService that tracks open files as ScriptInfo objects and routes them to ConfiguredProject or InferredProject instances, each owning a LanguageService backed by a compiled Program. For completions, tsserver calls getSymbolsInScope() to find all in-scope identifiers, or getPropertiesOfType() for member-access completions after a dot. Diagnostics are delivered asynchronously as events (semanticDiag, syntacticDiag, suggestionDiag) rather than synchronous responses, so the editor stays responsive while the type-checker runs.

pylsp and language servers for other languages follow the same broad pattern — parse, index, answer queries from cache — though the quality of the index (and how much type information is available in a dynamically typed language) varies significantly.

The Full Request Lifecycle for Autocomplete

Here is what happens at the OS level when you type a dot and the completion list appears.

Keystroke (.) in renderer
        |
        | [Chromium event → Extension Host via IPC]
        v
vscode-languageclient (in Extension Host)
        |  builds textDocument/completion JSON-RPC request
        |  includes: file URI, line, character position
        |
        | [IPC / stdio / socket transport]
        v
Language Server process
        |  receives request
        |  looks up cursor position in CST/AST
        |  determines type of expression left of dot
        |  calls getPropertiesOfType() or equivalent
        |  builds CompletionList with CompletionItems
        |     each item: label, kind, detail, documentation,
        |                insertText, sortText, filterText
        |
        | [response over same transport]
        v
vscode-languageclient
        |  deserializes CompletionList
        |  passes to VS Code completion UI API
        v
Renderer process
        renders sorted dropdown

For go-to-definition (F12), the request is textDocument/definition with the same TextDocumentPositionParams. The server looks up the symbol under the cursor in its symbol index and returns a Location — a URI and a Range (start line/character, end line/character). VS Code opens the target file (if it is not already open) and moves the cursor to the reported range. The whole round-trip, once the index is warm, typically completes in single-digit milliseconds.

Why AI Editors Didn’t Rebuild This

Cursor is a fork of VS Code’s open-source base (code-oss). Windsurf is built on top of VS Code. GitHub Copilot is a VS Code extension. None of them rebuilt autocomplete or go-to-definition — they inherited the entire LSP layer for free, and they kept it unchanged.

The AI layer sits alongside LSP, not on top of it. When Copilot suggests a multi-line ghost-text completion, that is a separate code path from the LSP textDocument/completion dropdown. The LSP path is deterministic and type-aware: it resolves what is actually in scope. The AI path is probabilistic and context-aware: it predicts what you are probably about to write. In practice, VS Code runs both simultaneously and merges or layers the results. LSP completions populate the dropdown. AI ghost text appears inline and is accepted with Tab.

In 2025, the LSP specification itself added inline completions (textDocument/inlineCompletion) as a first-class method — which means AI-powered completions can now be expressed as a standard LSP feature rather than an editor-specific side channel. Some AI integrations are already using this path; others still use proprietary extension APIs.

What fires when you type:

  LSP path (always running)
  ─────────────────────────
  textDocument/didChange  →  server updates its model
  textDocument/completion →  dropdown of in-scope symbols

  AI path (alongside LSP)
  ────────────────────────
  Full file + surrounding context  →  LLM or local model
  textDocument/inlineCompletion    →  ghost-text suggestion

  Both paths are independent. Neither blocks the other.

Claude Code (version 2.0.74, December 2025) added native LSP integration — it runs language servers directly and uses textDocument/definition, textDocument/references, and diagnostic results to navigate and verify code changes without reading every file manually. This is LSP used by an AI agent rather than an AI model using LSP for completion; the protocol is the same either way.

How to Apply This Right Now

Diagnose slow completions correctly. If your completions feel slow when you first open a project, that is the language server building its index — not VS Code being slow. For large TypeScript projects, tsserver can take 15-30 seconds to fully warm up. After warmup, responses are typically sub-10 ms. The fix is to check the language server’s own output: in VS Code, open the Output panel and select your language server from the dropdown to see initialization progress and errors.

Check which server is running. Open a terminal while editing and look for tsserver, rust-analyzer, pylsp, clangd, or similar processes. If you do not see one, your language extension may have failed to start its server. The Output panel will usually show the failure reason.

Use the LSP capability table. If a feature like rename or find-all-references is missing or wrong for a language, the issue is almost always in the language server, not in VS Code. Check the server’s GitHub repo for known limitations. For TypeScript projects, typescript-language-server on GitHub tracks which tsserver commands it has mapped to LSP methods and which it has not.

Understand inline completions. If you are building a VS Code extension that provides AI completions, implement textDocument/inlineCompletion (LSP 3.18) rather than using the proprietary InlineCompletionItemProvider API if you want the feature to be portable across LSP-compatible editors.

Debug the wire. VS Code has a built-in LSP inspector. Set "[language].trace.server": "verbose" in your settings (e.g. "typescript.tsserver.log": "verbose") to see every message exchanged with the language server. This is the fastest way to understand why a specific feature is misbehaving.

Common Misconceptions

“The editor understands my code.” VS Code deliberately knows nothing about any programming language. All language intelligence — type checking, name resolution, symbol indexing — lives in the language server process. VS Code is a display client that renders what the server sends back.

“Autocomplete is AI.” The dropdown that appears when you press dot is LSP autocomplete — deterministic, type-resolved, computed from a symbol index with no LLM involved. AI completions (ghost text) are a parallel feature that runs alongside LSP, not a replacement for it. They use different code paths and have different latency profiles.

“AI editors rebuilt the editor from scratch.” Cursor, Windsurf, and the GitHub Copilot extension all inherit VS Code’s LSP infrastructure. The AI is additive. Go-to-definition in Cursor calls the same textDocument/definition LSP method that plain VS Code does; the language server that answers it is unchanged.

“Each editor needs its own language plugin.” Before LSP, that was true. With LSP, one language server serves every editor that speaks the protocol. rust-analyzer provides go-to-definition for VS Code, Neovim, Emacs, Helix, and Sublime Text through the same binary. You write the server once; every editor gets it for free.

“tsserver speaks LSP.” It does not. tsserver has its own custom JSON protocol over stdio. The typescript-language-server package is a protocol adapter that speaks LSP to VS Code while speaking tsserver’s native protocol to the TypeScript compiler. This matters when debugging: the message you see in VS Code’s LSP trace is not the message tsserver actually receives.

Frequently Asked Questions

What actually happens at the OS level when I press F12?

Your editor fires a textDocument/definition JSON-RPC request over the IPC or stdio channel to the language server. The server looks up the symbol at the reported position in its in-memory symbol index and returns a Location object: a file URI and a Range (line and character numbers). VS Code opens that file, scrolls to the range, and moves the cursor. The full round-trip — from keypress to cursor movement — is typically 5-20 ms for a warm server.

Why does IntelliSense sometimes show stale or wrong results?

The language server is working from an in-memory model that is updated via textDocument/didChange notifications on every edit. If the server has not finished re-indexing after a recent change, or if a file on disk changed outside the editor (e.g. a git pull), the index can be momentarily stale. Most servers handle this by watching the file system and triggering re-analysis automatically, but there is always a small lag. For TypeScript, you can force a refresh by running the “TypeScript: Restart TS Server” command from the command palette.

Can a language server serve multiple editors at once?

Not usually. Each editor instance spawns its own server process and communicates with it exclusively. However, some servers optimise for shared state: clangd uses a shared compilation database and cached index files on disk so a second editor instance does not have to re-index from scratch. rust-analyzer caches its index in the target directory similarly.

Does LSP work in Neovim, Emacs, or editors other than VS Code?

Yes. LSP is an open standard and is now implemented by virtually every serious editor. Neovim has built-in LSP support since version 0.5 (configured via nvim-lspconfig). Emacs supports it through eglot (built-in since Emacs 29) and lsp-mode. Helix has native LSP. Sublime Text has the LSP package. One server works with all of them.

What is the difference between LSP completions and AI completions?

LSP completions (textDocument/completion) are deterministic: the server resolves what symbols are actually in scope at the cursor position, using your project’s real type information. They appear in a dropdown and are always correct in the sense that they reflect real symbols. AI completions (textDocument/inlineCompletion in LSP 3.18, or proprietary ghost-text APIs) are probabilistic: a model predicts what you are likely to write next, based on context. They can complete entire functions or suggest code that does not yet exist in your codebase, but they can also be wrong. Both run simultaneously; neither replaces the other.

Why does the language server need to index the entire project before completions work well?

Accurate completions require knowing the types of all symbols. For a TypeScript project, computing the type of foo.bar requires following foo’s type declaration, which may be in a different file, which may re-export from a third file, and so on through the entire dependency graph including node_modules. The language server must have seen all those files before it can answer type-level queries correctly. That is what the startup indexing phase does.

Where This Fits in the Series

This episode is part of “The Engine Behind Every AI Code Editor” — a course in the VS Code Decoded series. The previous episode covered why VS Code survives extension crashes and how the extension host sandbox works. The LSP architecture described here is also why Cursor, Windsurf, and Copilot are built on VS Code rather than starting from scratch — they get the entire language server ecosystem for free. The next episode looks at what happens at the OS level when an AI agent runs a shell command inside the integrated terminal.

If you are interested in how AI agents use these same primitives at a higher level, How Claude Code Edits Your Repo shows how an agent uses LSP-powered navigation to understand a codebase without reading every file. Browse all tutorials to follow the full arc.

Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.

Subscribe on YouTube →