# Building a Faster Playwright MCP Server: Compact DOM Deltas, 1-Turn Form Filling, and Submit Guards

> Source: <https://dev.to/tthanh2k6/building-a-faster-playwright-mcp-server-compact-dom-deltas-1-turn-form-filling-and-submit-guards-2o2p>
> Published: 2026-11-15 04:17:17+00:00

While using a browser automation agent over Model Context Protocol (MCP) to prefill job applications and publishing forms, four bottlenecks showed up in the execution logs before any site rate limit did:

`click`, `fill`, `<select>` elements caused multi-second stalls.`Submit` or `Publish` sent half-checked data live before a human could inspect the page.
I built `turbo-browser-mcp` (`v0.4.0`, MIT, `playwright-core` with no other runtime dependencies) to group those patterns into 10 consolidated MCP tools:

[https://github.com/Tthanh2k6/turbo-browser-mcp](https://github.com/Tthanh2k6/turbo-browser-mcp)

Here are the four design choices in the codebase, along with where the approach still has limits.

`data-tbm-ref` snapshots and `onlyChanged` deltas
`inspect_compact` walks the main document, nested accessible frames, and open Shadow DOM roots (`el.shadowRoot`), stamps deterministic `f<frameIdx>:e<N>` IDs onto `data-tbm-ref`, and caps output at `maxChars` (`4000` by default).

Instead of dumping a full accessibility tree after every action, the server hashes each line's signature per tab (`frameIdx`, tag, label, value/checked state) and supports `onlyChanged: true` to return only added or modified controls. Calling `act_fast`, `batch_actions`, or `smart_fill_form` with `returnSnapshot: true` folds the updated view into the same tool response so the agent does not spend a separate read turn.

`smart_fill_form` for React inputs, rich text, and disk uploads
Standard `.value = "..."` writes often vanish on React forms on blur or submit because React tracks internal input state via `_valueTracker`. In `src/dom-engine.mjs`, `smart_fill_form` updates inputs through the native `HTMLInputElement.prototype` or `HTMLTextAreaElement.prototype` value setter after calling `_valueTracker.setValue('')`, then dispatches `input`, `change`, and `blur`.

In the same single MCP call, fields can target:

`<input>` and `<textarea>` elements, single/multi `values`), `<input type="range">` sliders, radios, checkboxes, and ARIA `[role="combobox"]` widgets`contenteditable` editors (ProseMirror, Quill, compose boxes) via `execCommand('insertText')` with `InputEvent` fallback`frameIdx`) and open Shadow DOM fields` file` or `files`, which Playwright attaches straight from disk through `setInputFiles` or `filechooser` (never through inline Base64 strings)`allowSubmit=false` guard that checks coordinates too
By default, `resolveAllowSubmit` evaluates to `false` unless `allowSubmit: true` is passed per call or `TURBO_BROWSER_ALLOW_SUBMIT=true` / `--allow-submit` is enabled on launch.

In `src/dom-engine.mjs`, `SUBMIT_LABEL_RE` blocks clicks, `Enter` key presses, and `type` `submit: true` actions matching `submit`, `publish`, `post`, `send`, `apply`, `confirm and pay`, `pay`, `checkout`, `delete`, and Vietnamese equivalents (` gửi`, `đăng bài`, `nộp đơn`, `thanh toán`), plus `<input type="submit">` elements outside an explicit safe list (`next`, `continue`, `search`, `filter`, `save draft`). Raw viewport `{ x, y }` mouse clicks resolve the element at that screen coordinate first so a coordinate click cannot bypass the label guard.

`npm run benchmark`)
Measured locally on Windows (`Node v24.15.0`, Chromium headless) against the self-contained HTTP fixture in `benchmarks/benchmark.mjs`:

| Scenario | Baseline | `turbo-browser-mcp` | Measured change ( `o200k_base` ) | 
|---|---|---|---|
| Dense 140-button SaaS DOM snapshot | 18,654 tok raw HTML / 3,568 tok full ARIA | 1,135 tok compact (28 ms) / 56 tok delta (18 ms) | 93.9% vs HTML, 68.2% vs ARIA, 98.4% on delta | 
| 10-field form fill + snapshot | 11 MCP turns (574 ms, 12,197 dir / 72,614 cum tok) | 1 MCP turn (82 ms, 811 tok, `filled=10` ) | 11 -> 1 turns, 7x faster, 93.4% dir / 98.9% cum saved | 
| 48 KiB PDF file upload ( `49,152` bytes) | 44,728 tok inline Base64 tool arg | 85 tok disk path (68 ms) | 99.8% tokens saved, no Base64 encode | 
| Open 6 tabs ( `60 ms` simulated TTFB) | 6 turns (998 ms sequential) | 1 turn (420 ms parallel `open_many` ) | 6 -> 1 turns, 2.38x faster | 
| Heavy media load ( `turbo` vs clean) | 252 ms ( `304 KiB` , 9 asset hits) | 165 ms ( `0 KiB` , 0 asset hits) | 1.53x faster, 100% media bandwidth cut | 
| Article Markdown + Diagnostics errors | 3,595 tok HTML / 3,460 tok all logs | 156 tok Markdown / 601 tok errors-only | 95.7% and 82.6% tokens saved | 

The 405 tests across 85 suites (`25` test files under `test/*.test.mjs`: core integration, DOM/form and session/stdio stress suites, multi-round adversarial simulations, and 10 independent audit suites) run via `npm test` and pass on GitHub Actions CI for Node 20 and Node 22.

`Element.prototype.attachShadow` so closed shadow roots created after page initialization stay inspectable and guarded, while closed roots that already existed on an attached CDP tab or live inside strict sandboxed frames fall back to Playwright frame handles, coordinate clicks, and keyboard input.`network_session` preset `"turbo"` trims asset overhead on heavy sites).`open_many`, `capture_page`, or If you use Playwright MCP or browser agents in daily workflows, what breaks most often in practice for you? I am especially curious about custom virtualized dropdowns, tricky iframe embed patterns, diagnostics you miss from Chrome DevTools, or places where `SUBMIT_LABEL_RE` would misclassify a safe navigation button.

Issues, PRs, and edge-case reports are welcome on GitHub:

[https://github.com/Tthanh2k6/turbo-browser-mcp](https://github.com/Tthanh2k6/turbo-browser-mcp)
