Building a Faster Playwright MCP Server: Compact DOM Deltas, 1-Turn Form Filling, and Submit Guards
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: Full accessibi
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:
- Full accessibility snapshots kept piling up in the conversation context until single turns carried 90,000+ tokens.
- Filling a form step-by-step (
click,fill,click,fill) spent 10 to 15 MCP turns per page, and typing keystrokes into<select>elements caused multi-second stalls. - Passing file uploads as inline Base64 strings through LLM tool arguments forced the model to emit ~20,000 output tokens and stalled tool calls for over three minutes.
- One wrong click on a button labeled
SubmitorPublishsent 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
Here are the four design choices in the codebase, along with where the approach still has limits.
1. Bounded 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.
2. One-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:
- React/plain
<input>and<textarea>elements, single/multi<select>(values),<input type="range">sliders, radios, checkboxes, and ARIA[role="combobox"]widgets -
contenteditableeditors (ProseMirror, Quill, compose boxes) viaexecCommand('insertText')withInputEventfallback - Nested iframes (
frameIdx) and open Shadow DOM fields - Local files via
fileorfiles, which Playwright attaches straight from disk throughsetInputFilesorfilechooser(never through inline Base64 strings)
3. A default 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.
4. Local benchmark numbers (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.
Trade-offs and open gaps
-
Pre-existing closed Shadow DOM and sandboxed frames: An init script hooks
Element.prototype.attachShadowso 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. -
Real network latency still dominates navigation: batching collapses DOM round-trips, while remote page load times still depend on the target server (blocking images, fonts, and media via
network_sessionpreset"turbo"trims asset overhead on heavy sites). -
Long-lived MCP child processes: if your client already has an older MCP stdio process running when you pull updates, you need to reload the MCP client so added schemas (like
open_many,onlyChanged,capture_page, ornetwork_session) register.
Looking for community feedback
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
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.