Craftznake
Webterm
Aug 1, 2026
This is my short note from building Webterm. It is not a complete browser architecture guide. It is just the things I learned while trying to stream a real browser into a web terminal UI.
The idea behind Webterm is simple: a browser that can run somewhere else and still give everyone the same experience as the normal browser 🤓.
Personally, I also built it out of curiosity, to understand how browsers work underneath the surface. And, admittedly, that ended up being a big motivation for the project.
The current implementation is built around Chrome with Chrome DevTools Protocol (aka CDP). At a glance, it works, but it also has some limitations that I found out recently.
Architecture
At a high level, the system looks like this:
Rendering diagram...
At first, this gave me a boundary between layers, which I found useful later when things get messy. And also, by separating it into different abstraction layers, the debug seems less painful (still painful), where I could hold expectation data from/state at each layer separately, which in turn helped me to scope the abnormal point in the data flow later.
Transport today
Currently the client talks to the server through WebSocket.
But I do not think this is the final transport; in other words, this could be moved to something like QUIC, which offers better latency, stream separation, loss handling, and lower-latency delivery - due to the nature of this kind of app, data flows constantly and requires as little latency as possible, because they all affect the experience on the client. (I also have another idea 😛 of packaging this into a single binary and executing it locally on the client device, meaning transporation could be moved to some local-specific protocol like UDS).
By the way, it's for future work, and for the sake of simplicity during development, WebSocket works fine for now. It is easy to inspect, easy to proxy, and fits the current web client naturally (which is currently the only client of Webterm).
Engine today: headless Chrome and CDP
The current active engine supported by Webterm is Chrome controlled by CDP through the chromiumoxide crate. Chrome DevTools provides a set of APIs to interact with Chrome via CDP, you could find more detail here.
At first, this gave me a really huge set of APIs, and offers me all the things that I need for this simple project 🤦♂️.
Most user interactions could be understood from the CDP surface as:
Page.navigatefor navigation,Input.dispatchKeyEventfor keyboard input,Input.dispatchMouseEventfor mouse input,Emulation.setDeviceMetricsOverridefor changing viewport size,
This model is nice because CDP is explicit. Meaning, it offers me explicit logs with actual args, which gives me an understanding of what the engine actually did under the hood (this is nice to understand the lower browser operations).
And also, as CDP defines a fixed schema for every command, during the test, we don't have the need to define sets of engine-specific command schemas, and I could reuse exactly the ones given by CDP to define the input shape for automated testing.
But life is not always easy like that 😅. Soon after, I realized it is "very exact". In other words, it offers a different abstraction level than the one that we actually interact with the browser.
- For example, for doing a "send Enter" action, I'm under the impression that only a simple dispatchKeyEvent call, like other characters, is enough.
But, this is a low-level API, unlike other high-level automation tools that simulate typing by injecting the text directly, CDP forces me to mimic the literal lifecycle of the keypress, which in turn, needs the engine to emit 3 events in sequence, including keydown, keypress and keyup.
I will not go into the specific technical explanation for this, but overall, while it gives a nice explicit API, it gives it as a different abstraction layer, which is lower level than the one that we are interacting with, and to make the browser fully work, there is a lot of back-and-forth work for Webterm to translate the client's interaction into detailed, ad-hoc CDP calls - #1, to give the engine the same interaction with the real browser.
And this is, to me, painful, and too massive for a simple project like this.
Even though the current implementation is working, I just feel this work is to make another wrapper layer to maintain, and it is very fragile considering its dependencies and API changes from the upstream dependency.
Imagine, Chromium introduces 1 interaction change, and in turn, CDP changes its interface (hopefully we will receive the support from CDP in time, sometimes, I also find some interactions are not defined by CDP as well - #2), and then, we change the whole data flow from the translation logic, to make the engine translate user activities into new CDP calls correctly (which to adopt new engine/CDP implementation), and then, make the engine understand the data format produced by the new CDP change (which to adopt new changes from CDP).
The browser has its own way of thinking
One thing I had to relearn is that a browser is not event-by-event in the way a normal app feels. It is more like a state machine with a lot of awareness about states.
Simple navigation is not understood as "go to this URL". Under the hood, we will have different states that the browser needs to move through. You could find more detailed information about Page.LifeCycle. Some states need us to take care imediatlly, some are only useful after the browser finishes settling.
With that being said, Webterm, in the middle of this chain, needs to have the awareness about this state, in order to understand what needs to be done at the moment, and in the moment after that, correctly.
Sometimes, with the pain point that we have previously #1, the engine needs to emit different calls into Chrome "correctly". By correctly, it means, we need to emit that event with correct values at the correct moment, and this moment depends on the state.
Current difficulty
The biggest issue now is browser completeness.
Headless Chrome plus CDP is great for automation and regression tests, but it still does not always behave like a normal human browser. Some sites detect headless mode. Meaning, some flows might trigger CAPTCHA behavior. Moreover, some features depend on browser chrome might be not well-supported by CDP (as said in #2).
Even though I have some ad-hoc pre-flight scripts, which hide the identity of the client, with hope that outgoing requests from the headless browser didn't carry any headless information. These help in some cases, but overall, it's not sustainable considering the growth and development of browser sites to detect more and more sophisticated mimic.
This is just hard due to its nature, stemming from the fact that headless/CDP is not the same as a full browser surface from the implementation.
That is the main architectural pressure right now. And I found out an approach to move to another engine recently, by using offscreen Chrome. This, at first, gives me a simple enough, separation-of-concerns solution to make the Webterm offer the native experience. I'm still researching and experimenting with this one before adopting it into Webterm.
Future plan
As said, the direction I want to explore next is offscreen rendering with a real browser window model, likely through Electron in an offscreen mode.
Here is the why: CDP headless screencast is good for control, but it's not enough for the native browser experience considering our current painpoint, which seems to require massive work with CDP. And also, to get rid of the ad-hoc, manual man-in-the-middle API management, which offers minimal benefits considering its effort.
About the delivering model, I prefer make it work first before thinking about the packaging model. Perhaps, It can be a sidecar process in the first version. And websocket as the traporation is okay, Later, this can be optimized, but is not high priority at the moment.
The current architecture will not be thown away though. The useful parts should remain unchanged, including: client <-> server interaction, API design and transportation around that. The engine behind that boundary can change from CDP headless Chrome to an offscreen browser window without rewriting the whole app.
Lessons learnt
At this point, here are some thoughts about browsers as well as this project:
- a browser is combination of a stateful machine that maintains session state, input semantics, navigation lifecycle, frame lifecycle, transport behavior, profile behavior, and many small compatibility details.
- Headless Chrome gives access to a lot of this through CDP, but it also exposes how much normal browsers do for us invisibly.
- CDP gives a very exact low-level API to interact with browser, and sometimes, due to its exact, it is not on the same surface with normal web interaction.
- For Webterm, the current CDP engine is still useful, at least, it gives me a better view of browsers, which is valuable. But for the product to feel like a real browser, the next step is probably not more CDP polishing forever 🤦♂️.
- In the end, this project feels a lot like my feeling about the terminal. Both are stateful machines and depend on layers sharing the same understanding of states.
Related Articles
Webterm
Aug 1, 2026
This is my short note from building Webterm. It is not a complete browser architecture guide. It is just the things I learned while trying to stream a real browser into a web terminal UI.
The idea behind Webterm is simple: a browser that can run somewhere else and still give everyone the same experience as the normal browser 🤓.
Personally, I also built it out of curiosity, to understand how browsers work underneath the surface. And, admittedly, that ended up being a big motivation for the project.
The current implementation is built around Chrome with Chrome DevTools Protocol (aka CDP). At a glance, it works, but it also has some limitations that I found out recently.
Architecture
At a high level, the system looks like this:
Rendering diagram...
At first, this gave me a boundary between layers, which I found useful later when things get messy. And also, by separating it into different abstraction layers, the debug seems less painful (still painful), where I could hold expectation data from/state at each layer separately, which in turn helped me to scope the abnormal point in the data flow later.
Transport today
Currently the client talks to the server through WebSocket.
But I do not think this is the final transport; in other words, this could be moved to something like QUIC, which offers better latency, stream separation, loss handling, and lower-latency delivery - due to the nature of this kind of app, data flows constantly and requires as little latency as possible, because they all affect the experience on the client. (I also have another idea 😛 of packaging this into a single binary and executing it locally on the client device, meaning transporation could be moved to some local-specific protocol like UDS).
By the way, it's for future work, and for the sake of simplicity during development, WebSocket works fine for now. It is easy to inspect, easy to proxy, and fits the current web client naturally (which is currently the only client of Webterm).
Engine today: headless Chrome and CDP
The current active engine supported by Webterm is Chrome controlled by CDP through the chromiumoxide crate. Chrome DevTools provides a set of APIs to interact with Chrome via CDP, you could find more detail here.
At first, this gave me a really huge set of APIs, and offers me all the things that I need for this simple project 🤦♂️.
Most user interactions could be understood from the CDP surface as:
Page.navigatefor navigation,Input.dispatchKeyEventfor keyboard input,Input.dispatchMouseEventfor mouse input,Emulation.setDeviceMetricsOverridefor changing viewport size,
This model is nice because CDP is explicit. Meaning, it offers me explicit logs with actual args, which gives me an understanding of what the engine actually did under the hood (this is nice to understand the lower browser operations).
And also, as CDP defines a fixed schema for every command, during the test, we don't have the need to define sets of engine-specific command schemas, and I could reuse exactly the ones given by CDP to define the input shape for automated testing.
But life is not always easy like that 😅. Soon after, I realized it is "very exact". In other words, it offers a different abstraction level than the one that we actually interact with the browser.
- For example, for doing a "send Enter" action, I'm under the impression that only a simple dispatchKeyEvent call, like other characters, is enough.
But, this is a low-level API, unlike other high-level automation tools that simulate typing by injecting the text directly, CDP forces me to mimic the literal lifecycle of the keypress, which in turn, needs the engine to emit 3 events in sequence, including keydown, keypress and keyup.
I will not go into the specific technical explanation for this, but overall, while it gives a nice explicit API, it gives it as a different abstraction layer, which is lower level than the one that we are interacting with, and to make the browser fully work, there is a lot of back-and-forth work for Webterm to translate the client's interaction into detailed, ad-hoc CDP calls - #1, to give the engine the same interaction with the real browser.
And this is, to me, painful, and too massive for a simple project like this.
Even though the current implementation is working, I just feel this work is to make another wrapper layer to maintain, and it is very fragile considering its dependencies and API changes from the upstream dependency.
Imagine, Chromium introduces 1 interaction change, and in turn, CDP changes its interface (hopefully we will receive the support from CDP in time, sometimes, I also find some interactions are not defined by CDP as well - #2), and then, we change the whole data flow from the translation logic, to make the engine translate user activities into new CDP calls correctly (which to adopt new engine/CDP implementation), and then, make the engine understand the data format produced by the new CDP change (which to adopt new changes from CDP).
The browser has its own way of thinking
One thing I had to relearn is that a browser is not event-by-event in the way a normal app feels. It is more like a state machine with a lot of awareness about states.
Simple navigation is not understood as "go to this URL". Under the hood, we will have different states that the browser needs to move through. You could find more detailed information about Page.LifeCycle. Some states need us to take care imediatlly, some are only useful after the browser finishes settling.
With that being said, Webterm, in the middle of this chain, needs to have the awareness about this state, in order to understand what needs to be done at the moment, and in the moment after that, correctly.
Sometimes, with the pain point that we have previously #1, the engine needs to emit different calls into Chrome "correctly". By correctly, it means, we need to emit that event with correct values at the correct moment, and this moment depends on the state.
Current difficulty
The biggest issue now is browser completeness.
Headless Chrome plus CDP is great for automation and regression tests, but it still does not always behave like a normal human browser. Some sites detect headless mode. Meaning, some flows might trigger CAPTCHA behavior. Moreover, some features depend on browser chrome might be not well-supported by CDP (as said in #2).
Even though I have some ad-hoc pre-flight scripts, which hide the identity of the client, with hope that outgoing requests from the headless browser didn't carry any headless information. These help in some cases, but overall, it's not sustainable considering the growth and development of browser sites to detect more and more sophisticated mimic.
This is just hard due to its nature, stemming from the fact that headless/CDP is not the same as a full browser surface from the implementation.
That is the main architectural pressure right now. And I found out an approach to move to another engine recently, by using offscreen Chrome. This, at first, gives me a simple enough, separation-of-concerns solution to make the Webterm offer the native experience. I'm still researching and experimenting with this one before adopting it into Webterm.
Future plan
As said, the direction I want to explore next is offscreen rendering with a real browser window model, likely through Electron in an offscreen mode.
Here is the why: CDP headless screencast is good for control, but it's not enough for the native browser experience considering our current painpoint, which seems to require massive work with CDP. And also, to get rid of the ad-hoc, manual man-in-the-middle API management, which offers minimal benefits considering its effort.
About the delivering model, I prefer make it work first before thinking about the packaging model. Perhaps, It can be a sidecar process in the first version. And websocket as the traporation is okay, Later, this can be optimized, but is not high priority at the moment.
The current architecture will not be thown away though. The useful parts should remain unchanged, including: client <-> server interaction, API design and transportation around that. The engine behind that boundary can change from CDP headless Chrome to an offscreen browser window without rewriting the whole app.
Lessons learnt
At this point, here are some thoughts about browsers as well as this project:
- a browser is combination of a stateful machine that maintains session state, input semantics, navigation lifecycle, frame lifecycle, transport behavior, profile behavior, and many small compatibility details.
- Headless Chrome gives access to a lot of this through CDP, but it also exposes how much normal browsers do for us invisibly.
- CDP gives a very exact low-level API to interact with browser, and sometimes, due to its exact, it is not on the same surface with normal web interaction.
- For Webterm, the current CDP engine is still useful, at least, it gives me a better view of browsers, which is valuable. But for the product to feel like a real browser, the next step is probably not more CDP polishing forever 🤦♂️.
- In the end, this project feels a lot like my feeling about the terminal. Both are stateful machines and depend on layers sharing the same understanding of states.