A practical look inside browser debugging—from pressing F5 in Visual Studio Code to controlling Chrome through the Debug Adapter Protocol and Chrome DevTools Protocol.
When you press F5 in Visual Studio Code and a breakpoint suddenly stops JavaScript running inside Chrome, it can feel like VS Code is somehow directly controlling the browser.
It isn't.
There's an entire conversation happening behind the scenes.
Visual Studio Code speaks the Debug Adapter Protocol (DAP). Chrome speaks the Chrome DevTools Protocol (CDP). Something has to sit between them, translate those two worlds, manage the debugging session, track breakpoints, inspect variables, control execution, and report everything back to the editor.
While building CloudIDEaaS JavaScript Debugger, I decided to implement that middle layer as a C#/.NET debug adapter.
The resulting architecture is surprisingly straightforward:
Visual Studio Code
|
| Debug Adapter Protocol (DAP)
v
C# Debug Adapter
|
| Chrome DevTools Protocol (CDP)
| WebSocket
v
Chrome
Understanding that pipeline changed the way I thought about browser debugging.
In this article, I'll walk through what actually happens after you press F5, how DAP and CDP fit together, and some of the problems a debug adapter has to solve to make the whole process feel seamless.
What Actually Happens When You Press F5?
The process begins with the debugger configuration in .vscode/launch.json.
For CloudIDEaaS, a minimal configuration looks like this:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug in Chrome",
"type": "cloudideaas-vscode-debugger",
"request": "launch",
"url": "http://localhost:8000/index.html"
}
]
}
When you press F5, Visual Studio Code sees the debugger type and hands control to the extension registered to provide that debugger.
The extension then starts the C# debug adapter as a separate process.
At this point, VS Code isn't talking to Chrome. It's talking to the adapter.
Communication between VS Code and the adapter takes place over standard input and output using DAP messages. Each message is framed with a header similar to HTTP:
Content-Length: 123
{ ...DAP JSON message... }
The first important request VS Code sends is initialize.
The adapter responds by describing the debugging capabilities it supports. VS Code can then send requests such as:
launchsetBreakpointsconfigurationDonethreadsstackTracescopesvariablesevaluatenextcontinuedisconnect
But those are DAP commands.
Chrome doesn't understand them.
The debug adapter's job is to translate those requests into the appropriate Chrome DevTools Protocol commands—and translate Chrome's responses and events back into something Visual Studio Code understands.
That's where CDP enters the picture.
Connecting the Adapter to Chrome
Once the debug adapter is running, it needs a completely different conversation with Chrome.
Chrome exposes the Chrome DevTools Protocol (CDP) through a debugging endpoint. After Chrome is launched with remote debugging enabled, the adapter discovers the appropriate debugging target and establishes a WebSocket connection.
Through that connection, the adapter can enable the Chrome domains needed for debugging:
Page.enable
Runtime.enable
Debugger.enable
These domains expose different pieces of Chrome's debugging functionality.
Page handles things such as navigation and page lifecycle events.
Runtime provides access to JavaScript execution, objects, values, and expression evaluation.
Debugger provides breakpoints, script information, paused execution, call frames, and stepping.
The adapter now sits between two active protocols:
VS Code C# Adapter Chrome
| | |
|-------- DAP -------------> | |
| | -------- CDP -----------> |
| | <------- CDP ------------ |
| <------- DAP ------------- | |
This is one of the most important concepts behind the architecture:
The adapter isn't simply forwarding messages.
DAP and CDP describe debugging differently. The adapter has to maintain state and translate concepts between them.
A VS Code breakpoint needs to become a Chrome breakpoint.
A Chrome call frame needs to become a DAP stack frame.
Chrome remote objects need to become variables that VS Code can display.
A Chrome Debugger.paused event needs to become a DAP stopped event.
Commands such as Step Over, Step Into, Continue, and Pause have to travel in the opposite direction and become the corresponding CDP commands.
So the adapter effectively becomes a translator—and a state manager—between the editor and the browser.
But simply connecting the two protocols isn't enough.
One of the more interesting problems appears before the application's JavaScript has even started running: how do you make sure a breakpoint in startup code is installed before Chrome executes that code?
The Startup Breakpoint Problem
A breakpoint isn't very useful if the code has already executed by the time the debugger installs it.
This becomes especially important with JavaScript that runs immediately when a page loads.
A naive debugger startup sequence might look like this:
Launch Chrome
↓
Load the application
↓
Connect the debugger
↓
Configure breakpoints
There's an obvious race condition.
By the time VS Code sends the breakpoint configuration, Chrome may have already loaded the page and executed the JavaScript you wanted to debug.
CloudIDEaaS handles this by reversing part of that sequence.
Chrome initially launches without navigating to the application. The adapter connects to Chrome, enables the required CDP debugging domains, and tells VS Code that the debugger is ready.
VS Code can then send its breakpoint configuration.
The sequence becomes:
Launch Chrome on about:blank
↓
Connect to Chrome through CDP
↓
Enable Page, Runtime, and Debugger
↓
Tell VS Code the debugger is initialized
↓
Receive breakpoint configuration from VS Code
↓
Receive configurationDone
↓
Navigate Chrome to the application
Only after breakpoint configuration is complete does the adapter navigate Chrome to the application's actual URL.
That seemingly small change is important.
It means a breakpoint placed in JavaScript that executes during page startup can already be registered with Chrome before the application begins loading.
This also demonstrates why a debug adapter needs to do more than translate individual commands.
It has to coordinate timing, state, and lifecycle across two independent systems.
VS Code has its idea of when a debugging session is ready.
Chrome has its own page and JavaScript execution lifecycle.
The adapter has to make those two timelines behave like one debugging experience.
Once execution finally stops at a breakpoint, another translation problem begins: turning Chrome's call frames, scopes, and remote JavaScript objects into the variables and call stack that appear inside VS Code.
From Chrome Call Frames to VS Code Variables
When Chrome reaches a breakpoint, CDP sends the adapter a Debugger.paused event.
That event contains information about why execution stopped and, most importantly, the JavaScript call frames associated with the paused execution.
The adapter translates that into DAP concepts that Visual Studio Code understands.
The flow looks roughly like this:
Chrome
|
| Debugger.paused
v
C# Debug Adapter
|
| DAP stopped event
v
VS Code
|
| threads
| stackTrace
| scopes
| variables
v
C# Debug Adapter
|
| CDP runtime/debugger requests
v
Chrome
VS Code doesn't receive the entire JavaScript object graph when execution stops. Instead, it requests information as it needs it.
For example, VS Code asks for the stack trace. The adapter maps Chrome's call frames into DAP stack frames.
When you expand a stack frame, VS Code requests its scopes.
When you expand a scope or object, VS Code requests its variables.
This is where CDP's remote-object model becomes important.
Chrome often represents JavaScript objects using an objectId rather than returning the complete object. The adapter can retain that reference and later use CDP to retrieve the object's properties when VS Code asks for them.
Conceptually:
Chrome objectId
↓
Adapter reference
↓
DAP variablesReference
↓
VS Code expandable variable
This allows complex objects to appear naturally in the VS Code Variables panel without copying the entire JavaScript runtime state across the connection every time execution pauses.
Expression evaluation follows a similar path.
When you evaluate an expression in VS Code, the editor sends a DAP evaluate request. The adapter determines the appropriate paused JavaScript context, sends the corresponding request to Chrome, receives the result, and translates that result back into a DAP response.
The same basic pattern appears throughout the debugger:
VS Code asks for a debugging concept, the adapter translates it into Chrome's model, and then translates Chrome's answer back into VS Code's model.
That translation layer is what makes two very different protocols feel like a single debugger.
Breakpoints Are More Complicated Than They Look
From the user's perspective, a breakpoint is simple: click beside a line of code and a red dot appears.
Behind the scenes, the debugger has more work to do.
When VS Code sends a DAP setBreakpoints request, the adapter knows the source file and line where the developer wants execution to stop. Chrome, however, ultimately needs to associate that request with JavaScript it knows about.
For browser debugging, URL-based breakpoints provide an important bridge.
The adapter can register the breakpoint with Chrome before the corresponding script has loaded. Initially, that breakpoint may be unresolved.
Later, when Chrome loads a matching script and determines the actual executable location, CDP reports that the breakpoint has been resolved.
The adapter can then update VS Code:
VS Code
|
| setBreakpoints
v
C# Debug Adapter
|
| Debugger.setBreakpointByUrl
v
Chrome
|
| script loads
| breakpoint resolves
v
C# Debug Adapter
|
| DAP breakpoint event
| verified = true
v
VS Code
This is why a breakpoint can begin as unverified and later become verified without the developer doing anything.
The adapter also has to maintain mappings between the identifiers used by each side.
A breakpoint known to VS Code needs to remain associated with the corresponding CDP breakpoint identifier. Stack frames, scopes, objects, and source references require similar bookkeeping.
Page navigation makes this even more interesting.
When Chrome replaces its JavaScript execution environment, references associated with the previous environment can become invalid. Old call frames, object references, scope references, and script information may need to be discarded.
But the user's source breakpoint intentions should survive.
So when Chrome reports that its global object has been cleared, the adapter can clear transient runtime state while preserving the URL-based breakpoint registrations needed for the next page execution.
That distinction is fundamental:
Some debugger state belongs to the current JavaScript execution context. Other state represents the developer's debugging intent and needs to survive changes in that context.
Keeping those two kinds of state separate is one of the less visible responsibilities of a debug adapter.
Stepping Through JavaScript
Once execution is paused, familiar debugger controls such as Step Over, Step Into, Step Out, and Continue become another translation exercise.
Visual Studio Code expresses these actions as DAP requests:
next
stepIn
stepOut
continue
The adapter translates them into the corresponding Chrome DevTools Protocol commands:
Debugger.stepOver
Debugger.stepInto
Debugger.stepOut
Debugger.resume
The sequence for Step Over, for example, looks like this:
VS Code
|
| DAP next
v
C# Debug Adapter
|
| CDP Debugger.stepOver
v
Chrome
|
| JavaScript executes
|
| CDP Debugger.paused
v
C# Debug Adapter
|
| DAP stopped
v
VS Code
Although the command translation itself is relatively simple, the debugger's state changes significantly during this process.
Once Chrome resumes execution, references associated with the previous paused state—such as call frames, scopes, and some object references—can no longer be treated as current.
When Chrome pauses again, the adapter receives a new set of call frames and builds a new representation of the paused execution state for VS Code.
This creates a repeating lifecycle:
RUNNING
↓
Chrome pauses
↓
PAUSED
↓
VS Code inspects frames, scopes, and variables
↓
Developer steps or continues
↓
RUNNING
↓
Chrome pauses again
↓
PAUSED
From the developer's perspective, clicking Step Over simply moves the yellow execution marker to the next location.
Underneath that small UI interaction, two protocols, three processes, and a collection of temporary runtime references are being coordinated.
That's one of the things I found most interesting about implementing a debugger: the best debugging experience is often the one that hides how much work is happening underneath it.
Why C#?
The Debug Adapter Protocol doesn't require a debugger to be written in the same language as the application being debugged.
A debug adapter is a separate process that communicates with Visual Studio Code through a defined protocol. That means the implementation language is largely an architectural choice.
For CloudIDEaaS, I chose C# and .NET.
The VS Code extension itself remains a small JavaScript layer responsible for integrating with VS Code and launching the adapter. The actual debugging logic lives in the C# process.
VS Code Extension
JavaScript
|
| launches
v
C#/.NET Debug Adapter
|
| CDP over WebSocket
v
Chrome
This separation has several advantages.
The extension doesn't need to contain the entire debugger implementation. Its primary job is to register the debugger with VS Code and start the appropriate adapter process.
The C# application can concentrate on protocol handling, Chrome communication, state management, breakpoint translation, object tracking, and debugging behavior.
It also demonstrates something useful about DAP itself.
DAP creates a boundary between the editor and the debugger implementation.
VS Code doesn't need to know whether the adapter behind that boundary was written in JavaScript, TypeScript, C#, C++, Rust, Python, or something else.
As long as both sides follow the protocol, they can communicate.
The same principle applies on the other side of the adapter. Chrome doesn't care that Visual Studio Code initiated the debugging session. It sees a client communicating with its DevTools Protocol.
That leaves the adapter sitting at a very clean architectural boundary:
Editor concerns
|
DAP
|
-----------------
Debug Adapter
-----------------
|
CDP
|
Browser concerns
For me, that separation was one of the most valuable lessons from building the project.
A debugger that appears to be a tightly integrated feature of an editor can actually be several independent systems cooperating through well-defined protocols.
What Building a Debugger Taught Me
Before building CloudIDEaaS, I thought about browser debugging mostly from the developer's side of the screen: set a breakpoint, press F5, inspect some variables, step through the code, and find the problem.
Building the adapter exposed everything underneath that experience.
A debugging session isn't one continuous conversation. It's several independent systems maintaining enough shared state to create the illusion that they are one.
Visual Studio Code has its debugging model.
Chrome has its debugging model.
DAP defines how the editor communicates with a debugger.
CDP defines how a client controls and inspects Chrome.
The adapter has to reconcile them.
That led to a few lessons that extend beyond debugging.
Protocols Create Powerful Boundaries
DAP allowed me to implement the debugger in C# without requiring VS Code to understand anything about the implementation.
CDP allowed that C# application to control Chrome without Chrome knowing anything about Visual Studio Code.
Those protocol boundaries make the architecture surprisingly modular.
State Is Often Harder Than Commands
Sending Debugger.stepOver isn't particularly complicated.
Knowing which stack frames are still valid, which object references belong to the current pause, which breakpoints should survive navigation, and when VS Code should be told that something changed is much more interesting.
A large part of debugger development is really state management.
Timing Matters
The startup-breakpoint problem is a good example.
Every individual component can work correctly and the debugger can still fail simply because things happen in the wrong order.
Launching Chrome on about:blank, configuring the debugger first, and navigating afterward turns that race condition into a predictable sequence.
Simplicity at the Surface Requires Work Underneath
The ultimate CloudIDEaaS workflow is intentionally simple:
Set breakpoint
↓
Press F5
↓
Debug
But making that workflow simple means the tooling has to handle the complexity somewhere else.
That's the tradeoff I find interesting.
Developer tools don't necessarily become better by exposing every capability and configuration option they possess.
Sometimes good tooling means absorbing complexity so the developer doesn't have to.
Try It or Explore the Source
CloudIDEaaS JavaScript Debugger is free and open source.
If you're interested in straightforward F5 browser debugging, you can install it from the Visual Studio Marketplace:
CloudIDEaaS JavaScript Debugger on the Visual Studio Marketplace
If you're more interested in how the debugger works, the complete source is available on GitHub:
CloudIDEaaS JavaScript Debugger on GitHub
The project includes the JavaScript VS Code extension layer and the C#/.NET debug adapter discussed throughout this article.
Why CloudIDEaaS?
The architecture is only part of the story.
CloudIDEaaS is designed around a broader idea: debugging straightforward JavaScript applications shouldn't require a complicated debugging environment.
I've put together a complete breakdown of the problems CloudIDEaaS is designed to solve, who it's for, and the features, advantages, and benefits behind the project:
Why CloudIDEaaS JavaScript Debugger?
The goal is simple: fewer moving pieces between your code and the problem you're trying to solve.
I also wrote about the broader motivation behind the project and why I think reducing developer-tool complexity can sometimes be more valuable than adding another feature:
When Developer Tools Become the Problem: Why I Built a Simpler JavaScript Debugger
If you're working with DAP, CDP, browser debugging, or VS Code extension development, I'd be particularly interested in hearing about the architectural problems you've encountered.
Sometimes the most interesting part of pressing F5 is everything that had to happen to make pressing F5 feel simple.
Top comments (0)