Have you ever wondered how VS Code knows:
- Which variables or functions are available as you type?
- When a syntax error appears, even before you save the file?
- Where a function or type is defined so you can “go to definition”?
- What suggestions to offer for autocomplete or inline documentation?
It’s tempting to think the editor itself “understands” the language. It DOESN'T.
VS Code or any other code editor is just the UI. The real magic happens in the background, in a separate process called a language server, which communicates with the editor to provide language intelligence.
In this article, I’ll take you step by step through how VS Code, a language server like gopls for go, and the Language Server Protocol (LSP) work together, from project startup to shutdown, including how diagnostics, autocompletion, and go-to-definition actually flow through JSON-RPC messages.
Key Terms
Editor (VS Code): The UI client you interact with. Knows about files, buffers, cursors, and how to render things. Knows nothing about the language itself.
Language Server (gopls for Go): A separate process that understands the language, parses code, builds ASTs, resolves symbols, checks types, and generates diagnostics.
Language Server Protocol (LSP): A protocol (a contract) defining how the editor and language server communicate. Specifies messages, request/response rules, notifications, and data structures.
JSON-RPC: The transport format used to send LSP messages. Defines a structured, language-agnostic way to send notifications and requests between editor and server.
High-Level Send/Receive Workflow
Here’s how information flows in clear send/receive terms:
At this point, you have a mental model: your keystrokes travel through the editor, are translated into structured messages, processed by the language server, and the results are sent back to render the UI.
Section 1: Process Startup and the Initialize Handshake
Before the workflow above can happen, there’s an important setup phase. When you open a project, VS Code must start the language server and agree on what each side can do.
1.1 Process Startup
VS Code spawns gopls either via --stdio (most common) or over TCP sockets.
gopls runs as a long-lived background process, continuously reading messages from stdin and writing to stdout.
The editor never blocks while the server is processing — all communication is asynchronous.
1.2 Transport: JSON-RPC Language-agnostic protocol for message passing
LSP uses JSON-RPC 2.0
Messages are framed with headers (Content-Length) and a JSON body
Example notification (didOpen) from VS Code to server:
Content-Length: 143
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///home/user/main.go",
"languageId": "go",
"version": 1,
"text": "package main\nfunc main() {}"
}
}
}
method: Name of the LSP request or notification
params: Data required for the request
id: Only used for requests, not notifications
1.3 The Initialize Request
Before the server can handle edits or queries, VS Code must initialize it by sending an initialize request over JSON-RPC.
It Provides:
- Client capabilities (what editor supports: completion, hover, etc.)
- Workspace info (root path, folders)
- Initialization options (language-specific configs)
JSON-RPC Example:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": 12345,
"rootUri": "file:///home/user/project",
"capabilities": {
"textDocument": {
"completion": {
"completionItem": {
"snippetSupport": true
}
},
"hover": {},
"definition": {}
},
"workspace": {
"configuration": true
}
},
"clientInfo": {
"name": "Visual Studio Code"
}
}
}
Server Response
The server responds with its capabilities — telling the editor what it can handle:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"capabilities": {
"textDocumentSync": 2,
"hoverProvider": true,
"completionProvider": {
"resolveProvider": true,
"triggerCharacters": ["."]
},
"definitionProvider": true
}
}
}
textDocumentSync: 2 → incremental synchronization supported
Editor now knows which features it can safely request from the server.
1.5 Key Concepts
Requests vs Notifications
LSP defines two classes of messages:
- Requests expects a response and represent explicit queries (e.g. initialize, textDocument/hover, textDocument/definition)
- Notifications do not expect a response and are used to update server state (e.g. textDocument/didOpen, textDocument/didChange)
This distinction allows the editor to push state changes without blocking while still issuing targeted queries when results are required.
Capability Negotiation
During initialization, the editor and server exchange supported capabilities.
The editor only sends requests for features the server has explicitly declared, preventing invalid calls and unnecessary work. This negotiation defines the contract for the entire session.
JSON-RPC Transport
All communication is framed using JSON-RPC.
This provides:
- A consistent request/response model
- Explicit message IDs and ordering
- A language-agnostic transport independent of editor or server implementation
JSON-RPC is a transport choice, not a language feature; all semantics live at the LSP layer above it.
1.6 Send/Receive Flow For The Initialize Request
At this stage, the server is ready to accept text document events (didOpen, didChange) and handle requests like hover, completions, and definitions.
Section 2: Document Lifecycle: How Editors Keep Language Servers in Sync
The Language Server Protocol is push-based. The server never “checks” files or polls the filesystem. Instead, the editor continuously pushes document state so the server can maintain an accurate in-memory model of the code — even when that code does not yet exist on disk.
These notifications define that lifecycle.
1. textDocument/didOpen
- Sent when a document is opened in the editor.
- Contains the entire file content.
This event establishes the editor’s buffer as the authoritative source of truth. From this point on, the language server reasons about the in-memory text, not the filesystem.
For servers like gopls, this is where:
- The file is parsed into an initial AST
- The file is associated with a package and module
- Baseline diagnostics (syntax, imports) are produced
- Disk state is explicitly secondary.
2. textDocument/didChange
- Sent on every edit.
- Can contain full text or incremental changes.
This is the most performance-critical notification in LSP.
Modern editors send incremental edits, allowing the server to:
- Update only affected text ranges
- Reuse unchanged AST subtrees
- Invalidate and recompute analysis selectively
Without this mechanism, features like real-time diagnostics, autocomplete, and go-to-definition would be prohibitively expensive.
3. textDocument/didClose
Sent when the user closes a document.
Signals the end of active editing.
Closing a document does not mean the file disappears from analysis. It means the server can:
- Release editor-specific buffers
- Drop incremental parsing state
- Deprioritize diagnostics for that file
The file may still be loaded via disk if referenced elsewhere in the workspace.
4. textDocument/didSave (optional)
- Sent when in-memory content is written to disk.
- Often used to trigger disk-oriented work.
This notification exists because some tools care about persisted state, not transient buffers. On save, servers may run:
- Formatting
- Full package type-checking
- Vet-style or static analysis passes
Because save semantics vary across editors, this notification is optional in the protocol.
The invariant
At all times:
The language server’s understanding of the program is driven by editor events, not by reading files opportunistically.
This design is what allows LSPs to reason about code that is incomplete, syntactically broken, or not yet saved — while still providing rich, real-time tooling.
2.1 Example: Opening a Go File
When a user opens a Go file, VS Code sends a textDocument/didOpen notification:
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///home/user/main.go",
"languageId": "go",
"version": 1,
"text": "package main\n\nfunc main() {}"
}
}
}
The version field establishes the starting point for all subsequent edits and increments with every change.
On receipt, the language server creates an internal document model for this file. From this point forward, all analysis is performed against this in-memory model, which is updated as edits arrive.
2.2 Incremental Updates (didChange)
Sending the entire file on every keystroke would be wasteful, so LSP supports incremental updates.
Each didChange notification includes:
- A range indicating the affected portion of the document
- The replacement text
- An updated document version
Example:
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///home/user/main.go",
"version": 2
},
"contentChanges": [
{
"range": {
"start": { "line": 2, "character": 0 },
"end": { "line": 2, "character": 0 }
},
"text": " fmt.Println(\"Hello\")\n"
}
]
}
}
The editor sends only what changed, not the entire file.
2.3 Why the Server Maintains a Full In-Memory Copy
Although updates are incremental, the server always maintains a complete copy of the document.
When a change arrives, the server:
- Applies the edit to its in-memory buffer
- Updates the document version
- Re-runs parsing and analysis as needed
Crucially, analysis is performed against the entire file, not just the modified region. Incremental updates are an optimization for how changes are applied, not a restriction on what the server understands.
This design enables:
- Incremental parsing (only affected syntax nodes are reprocessed)
- Whole-file diagnostics
- Accurate hover, completion, and navigation anywhere in the file
2.4 Diagnostics as a Server Push
After applying a change, the server may publish diagnostics back to the editor:
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": "file:///home/user/main.go",
"diagnostics": [
{
"range": {
"start": { "line": 2, "character": 4 },
"end": { "line": 2, "character": 15 }
},
"severity": 1,
"message": "undefined: fmt",
"source": "gopls"
}
]
}
}
The editor does not interpret these diagnostics. It simply maps ranges to UI elements such as red squiggles or hover messages.
All of this happens asynchronously; the editor remains responsive while analysis runs in the background.
2.5 Key Observations
- Incremental updates reduce overhead but do not limit analysis scope
- Versioning prevents out-of-order or stale edits
- The server always reasons about a complete document
- The editor never analyzes code; it forwards changes and renders results
- Communication is asynchronous and JSON-RPC based
With an up-to-date internal model of the document, the server can now answer targeted requests such as hover, completion, and go-to-definition.
Next, we will examine how those requests flow through LSP and how the server responds to them.
Section 3: Hover, Completion, and Go-to-Definition
Once the language server maintains an up-to-date internal model of the workspace, the editor can issue targeted requests for language information such as hover details, code completion, and symbol navigation.
Unlike document lifecycle events, these interactions are requests, not notifications: the editor expects a response.
3.1 Hover (textDocument/hover)
A hover request is sent when the user points at a symbol in the editor.
- The request identifies:
- The document
- The cursor position
The server resolves the symbol at that position using its internal model and returns relevant information such as type details or documentation.
Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/hover",
"params": {
"textDocument": {
"uri": "file:///home/user/main.go"
},
"position": {
"line": 3,
"character": 8
}
}
}
Response
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"contents": "func main()",
"range": {
"start": { "line": 2, "character": 0 },
"end": { "line": 2, "character": 10 }
}
}
}
The editor renders this response directly; it does not interpret or analyze the content.
3.2 Completion (textDocument/completion)
Completion requests are triggered by typing or explicit user actions (CTRL + SPACEBAR).
The editor provides the current cursor position. The server inspects scope, imports, and type information to determine valid completions.
Request:
{
"jsonrpc": "2.0",
"id": 3,
"method": "textDocument/completion",
"params": {
"textDocument": {
"uri": "file:///home/user/main.go"
},
"position": {
"line": 3,
"character": 4
}
}
}
Response:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"isIncomplete": false,
"items": [
{
"label": "Println",
"kind": 3,
"detail": "func fmt.Println(a ...any) (n int, err error)",
"documentation": "Println formats using the default formats for its operands"
}
]
}
}
The editor is responsible only for ranking and rendering suggestions.
3.3 Go-to-Definition (textDocument/definition)
A definition request resolves a symbol reference to its declaration.
The server uses its workspace index and type information to locate the symbol, which may reside in:
- The current file
- Another workspace file
- The standard library
Request:
{
"jsonrpc": "2.0",
"id": 4,
"method": "textDocument/definition",
"params": {
"textDocument": {
"uri": "file:///home/user/main.go"
},
"position": {
"line": 3,
"character": 8
}
}
}
Response:
{
"jsonrpc": "2.0",
"id": 4,
"result": [
{
"uri": "file:///usr/local/go/src/fmt/print.go",
"range": {
"start": { "line": 120, "character": 6 },
"end": { "line": 120, "character": 12 }
}
}
]
}
The editor navigates to the returned location(s).
Key Observations
- Language intelligence is always derived from the server’s internal state
- Partial edits are sufficient because the server maintains a full document snapshot
- Editors do not analyze code; they delegate and render
- JSON-RPC provides a strict request/response boundary
With hover, completion, and navigation in place, the editor can now delegate nearly all semantic reasoning to the language server.
Next, we will cover shutdown, exit, and cleanup, and how editors terminate LSP sessions without losing state or leaking resources.
Section 4: Shutdown and Exit
When the editor closes a workspace or terminates, it must shut down the language server in a controlled way. LSP defines a two-step termination sequence to ensure cleanup happens deterministically.
4.1 shutdown (request)
Before terminating the server process, the editor sends a shutdown request.
This request signals that:
- No new requests will be sent
- The server should finish or cancel ongoing work
- Internal state should be released
Request:
{
"jsonrpc": "2.0",
"id": 5,
"method": "shutdown",
"params": null
}
Response:
{
"jsonrpc": "2.0",
"id": 5,
"result": null
}
After responding, the server must not process any further LSP requests other than exit.
4.2 exit (notification)
Once the shutdown request has completed, the editor sends an exit notification.
This message has no response. On receipt, the server terminates its process immediately.
Notification:
{
"jsonrpc": "2.0",
"method": "exit",
"params": null
}
If the server receives exit without a prior shutdown, it should exit with a non-zero status, signaling an abnormal termination.
4.3 Termination Semantics
- shutdown is a request: orderly cleanup and acknowledgement
- exit is a notification: process termination
This separation ensures:
- Pending work can be completed or canceled
- Resources such as file buffers, caches, and indexes are released
- The editor does not need to forcibly kill the process
4.4 Shutdown Flow
At this point, the LSP session is fully closed. A new session begins with a fresh server process and a new initialization handshake.
Final Notes
The LSP lifecycle is deliberately explicit:
- Editors manage user interaction and rendering
- Language servers own parsing, analysis, and symbol resolution
- JSON-RPC provides a strict, asynchronous boundary
- Document state is driven by editor events
- Startup and shutdown are symmetrical and deterministic
This separation allows editors to remain simple while language tooling evolves independently.
By understanding this workflow, you see how VS Code delegates language intelligence to servers and why this separation matters. This knowledge also equips you to:
- Build custom language tooling
- Debug LSP interactions effectively
- Contribute to language server development
It highlights how a structured, asynchronous protocol can provide real-time language features while keeping editors lightweight and responsive.
Draft - saved



Top comments (0)