Why this is harder than timing a command

You type vibium click "#submit" and a real Chrome window clicks something. Test suites and AI agents do that thousands of times a day, so every millisecond of overhead gets multiplied by a large number. The obvious move is to time a command and start shaving.

That is where almost everyone goes wrong, including — repeatedly — the investigation behind this article.

A browser command's cost is a stack of unrelated things: starting a process, talking to the browser, and the web page itself doing work. Speed up the wrong layer and you measure a real improvement that helps nobody. Compare two approaches that differ in two ways and you will confidently credit the whole gap to the wrong one.

Three habits that make the numbers mean something

Time a journey, not a command. Timing one command flatters whatever removes fixed overhead. The unit here is a realistic sequence against a real site — open a product, add to cart, check out, fill fields, verify.

Measure the noise before measuring the change. Running the same unchanged setup repeatedly says how precise the rig even is: ±22.6 ms for a single difference. An effect smaller than that is not an effect.

Lead with milliseconds, not ratios. A ratio depends entirely on what you divide by. One identical effect in this project measured 1.72× and 3.87× from the same data, purely by changing the denominator.

And every run had to prove it worked. Each journey checked that the cart really held the right product — not merely that a button existed. An earlier version checked "the element was found," which a fake click satisfies while accomplishing nothing. A fast run that didn't do the job isn't a fast run.

The scoreboard

Five techniques, measured on two sites: a heavy JavaScript shop and a lighter login flow. Positive means faster.

TechniqueVerdictShopLoginWhat it costs you
Skip the wrapper scriptcall the real program directly Works +405 ms+691 msNothing at all
Attach straight to the browserskip the CLI, open a socket Narrow +80 ms+45 msLoses the safety checks
Fake the clicks in JavaScriptdispatch events instead of clicking Not a win +253 ms+665 msCorrectness
Keep one process openstream commands down a pipe Loses -740 ms-212 msTime, ironically
Pause before the next commandlet the page settle first No effect nothing measurable, 10–75 ms

Read the milliseconds rather than the ratios: the two journeys differ in length and page weight, so an absolute saving transfers between them far better than a ratio does.

The one that works and costs nothing

Start by reading the tool instead of timing it. The vibium command on your path is not Vibium. It is a 41-line Node.js script whose entire job is to work out where the real program lives and then run it.

So every command you type starts two programs. The first is a JavaScript runtime that exists only to point at the second — and booting it is not free.

One journey, same commands, same results

Through vibiumthe command you were told to use
1,635.4ms
The real programsame verbs, same checks
1,230.7ms

Five repeats of fifteen runs each, averaged. Both arms use the real click and fill commands, so the wrapper is the only difference.

This is the rarest kind of finding: a real saving with no tradeoff. You are simply not paying to boot a JavaScript runtime that had nothing to do.
How to take it

The wrapper is a packaging choice, not part of the tool — build from source and it doesn't exist. Resolve the platform binary once and call it directly; the verbs and arguments are identical, so nothing else in your scripts changes.

The number that refused to multiply

Here the article stops being a product recommendation and starts being about measurement.

The wrapper costs about 106 ms per command. The shop journey runs 6 commands. Removing it should save about 637 ms. It saved 405.

A 232 ms hole, more than ten times the noise floor — too large to shrug off. Either the per-command figure was wrong, or something was giving time back.

The hypothesis, written down before running anything along with the result that would disprove it: the wrapper is partly refunding itself. A slower command arrives later at a page that is still loading — and a call that arrives later is a cheaper call, because it waits less. If that is right, the refund should shrink as pages get lighter, and vanish on a page with nothing to load.

How much of the per-command cost comes back

Heavy JavaScript pagean e-commerce product page
82.5ms
Lighter pagea QA practice site
55.2ms
Inert local pageno framework, nothing to hydrate
19.2ms

The prediction was half right — and the half that failed is the useful part.

The refund shrinks with page weight exactly as claimed — and then refuses to reach zero. On a page with no framework and nothing to hydrate, 19.2 ms survives. So page loading explains about 77% of the effect and is the right account of why it varies. It is not the whole account of why it exists.

The practical rule

Don't multiply a per-command overhead by your command count. It over-predicts, because some of what you remove was buying you a shorter wait somewhere else. Measure the journey.

The two that look like wins and aren't

Faking the clicks. Dispatching a synthetic click event instead of asking the browser to click is genuinely faster — 253 ms on the shop journey. It is also not an optimization. A real click checks the element is visible, stable and not covered by something else. A dispatched event skips all of it and reports success on a button no user could have pressed. That is the class of bug browser tests exist to catch: your suite gets faster at the exact moment it stops telling you the truth.

Waiting for the page to settle. The first command after a page load is slower than the ones after it, so the tempting fix is to pause. Every pause between 10 and 75 ms was tested: none produced a saving whose confidence interval excluded zero. Past that the arithmetic takes over rather than the measurement: the largest refund seen anywhere in this project is 82 ms, so a pause longer than that cannot repay itself no matter what the page is doing.

You cannot ask "are you ready yet?" without taking a ticket in the same queue you are asking about. The question is itself a command, and it waits exactly as long as the real one would have.

Why the persistent connection loses

One process, kept open, streaming commands — no repeated startup cost. It should be the fastest thing here. It is the slowest. The chart below settles that, and it also introduces the fifth technique, which has been sitting in the scoreboard without an explanation.

One journey, three transports

One process per commandthe ordinary way
981ms
Raw socket to the browserskips the CLI entirely
901ms
One persistent processsupposedly the cheap one
1,721ms

Five repeats of fifteen runs each, averaged. Every version ran on its own fresh browser and all produced the correct result every time. All three dispatch the same way, so transport is the only thing that differs.

The middle bar is the fifth technique: skip the CLI and drive the browser's own protocol over a socket. It wins, and only narrowly — 80 ms on the shop journey and 45 ms on the login one, against a noise floor of ±22.6 ms. That is the honest size of the architecture, once you stop letting it take credit for things it did not do: this comparison holds the click-and-fill technique fixed on both sides, so what is left is the transport and nothing else. Weigh it against writing and maintaining a protocol client, and against giving up the safety checks the bar beside it keeps.

The obvious suspect is the extra hop — your commands go through a router before reaching the browser. Measured directly, that hop is not slower. On identical commands it is indistinguishable, to within hundredths of a millisecond.

The real cause is one line of setup. On connect, the tool asks the browser to notify it about every network request, every navigation, every console message — nine categories in the release measured here, fourteen in the current development version, both including the two that fire on network activity. Generating and delivering those costs the browser real time, on exactly the commands that move the page. And nothing ever cancels the subscription.

The list length turns out not to matter much, which is the first hint about where the cost really sits: on a like-for-like journey the nine cost 2.17× and the fourteen 2.13× — the same tax, not one that grows with the number of things you asked for. Cancelling the subscription puts it back exactly: 838 ms before subscribing, 839 ms after cancelling. Nothing is permanently damaged. It is simply never undone.

Which notifications actually cost anything

Both network eventsthe pair, together
13.5ms
responseCompletedone event, alone
8.2ms
beforeRequestSentthe other, alone
0.6ms
The other twelveall of them, together
0.1ms

Extra work per page load against a control that subscribes to nothing, splitting the fourteen-event list. The first three bars come from one campaign and the fourth from a companion run, so read them as four separate answers rather than a sum — the same pair measured 13.5 ms in the first and 10.6 ms in the second, which is the honest size of the run-to-run spread on a quantity this small.

It is two of the fourteen — and really one. The two that fire on network activity carry the whole effect; the other twelve together produce nothing measurable. Split the pair and responseCompleted alone accounts for most of it, while beforeRequestSent alone costs nothing — though the two together cost more than the sum of their parts, so it isn't free in company.

And it accumulates. Each client that attaches leaves its own subscription behind, so the browser ends up generating several copies of the same notifications for an audience that has gone home. Four attach-and-leave cycles on one browser take the same journey from 848 ms to 4,173 ms — 4.9× — with no sign of a ceiling. A build that releases the subscription on the way out, run through the identical cycle, finishes at 1.00× of where it started.

That is the difference between vague advice and a change someone can make. "Subscribe to fewer things" is not actionable. "Only subscribe to the response notification when a feature needs it" is specific and small.

Is this a bug? Read the standard, then read the code

It is tempting to call this a leak and move on. The standard says otherwise, and it says so deliberately. In WebDriver BiDi a subscription belongs to the session, not to the socket that asked for it — the specification's record of a subscription has four fields, and none of them names a creator — and the algorithm for a closing connection removes the connection and nothing else, under an explicit note that this does not end any session. A subscription outliving the client that made it is not a malfunction. It is the specified behaviour.

The standard also hands out the means to clean up. session.subscribe replies with an id — that is the entire content of its return value — and session.unsubscribe accepts either that id or a list of event names.

Now read Chromium's implementation, because it does not merely follow the spec here — it adds something. Each subscription is tagged with the channel that created it (goog:channel, surfaced as googChannel in chromium-bidi's SubscriptionManager), and cancelling by event name skips any subscription belonging to another channel. Cancelling by id performs no such check.

Scope, corrected after publication

The vendor prefix is the point: goog: marks this as a Chromium extension, not something WebDriver BiDi requires. Other implementations need not carry the field at all — Jim Evans notes that Firefox's current BiDi does not, which I have not tested myself. The original wording said only "a field the standard does not define," which was true but let the reader generalise, and my own notes had the prefix the article dropped.

Everything measured below ran on Chrome for Testing, so the numbers are unaffected — but they are Chromium numbers. Vibium is rolling out Firefox support in its next release, at which point this section needs re-measuring there rather than assumed to transfer.

Reading is not enough, though, so the obvious thing was to try it. Connect a second client to the same running browser and ask it to cancel those subscriptions by name: refused — and not because the command is unavailable to it, since the same client subscribes to something of its own and cancels that without trouble a moment later. Asking for a subset of them is refused too. Whatever the precise mechanism, releasing these is the subscriber's job and nobody else's, and the id is the handle the platform hands it for the purpose.

The tool throws that id away. It subscribes, discards the reply into an ignored variable, and never unsubscribes — the word does not appear in its implementation in any language. What makes this more than an oversight is that the same teardown path already reasons carefully about ownership: it distinguishes a browser session it created from one it merely borrowed, noting that an attached session belongs to whoever handed us the URL. That is the exact situation measured here. The care is present. It covers the session, and not the state the tool leaves inside it.

So: not a violated promise, because nothing promised otherwise — but not an unavoidable one either. The platform issues a key at the moment of subscribing, and the only party who could ever use it drops it on the floor.

What we got wrong, and how we found out

Every number above survived an audit. Several didn't, and those are more useful to you than the ones that did.

A summary statistic that lied. One measurement appeared to drift between runs — the same quantity coming out differently each time. It wasn't drifting. Each run summarised its samples with a median, the reflexive defence against a few weird readings. But those samples don't scatter, they clump: after a page load the work is either still running or already finished, with almost nothing in between. A median over two clumps doesn't average them. It reports whichever clump holds the majority, and jumps the moment the majority flips. Switching to a plain mean and re-running everything cut the variation between runs by more than half. Nothing about the browser had changed.

The transferable version

A median is the automatic choice for robustness, and it is the wrong choice whenever your samples have modes rather than outliers. Look at the shape of the distribution before picking the summary.

A counter that could not have told us anything. One finding rested on a performance counter reading exactly zero. That looked like evidence — until someone burned a known 300 ms of JavaScript and read the counter across it. It still read zero. It never moves. Its zero had never meant anything, and the claim built on it was withdrawn.

A check that never fires may simply be incapable of firing. The same goes for a counter that never moves: before reading zero as absence, make it show you a presence.

The takeaway

Of five plausible optimizations, one is worth taking, one is worth taking carefully, and three are worth knowing about so you don't reach for them.

  • Call the real binary, not the wrapper. Free, identical behaviour, and the largest single win measured here.
  • Attach directly only if you understand what you're giving up. A modest win that costs you the actionability checks.
  • Don't fake clicks. The speed is real and it is bought with correctness.
  • Don't keep a persistent connection against a browser someone else will reuse. The subscription outlives the client that made it, and the cost accumulates with every client that has ever attached.
  • Don't pause and hope. A wait cannot outrun the queue it is waiting in.

The wider point is not about browsers. Three of these five look like wins and aren't, and in each case the way to tell was the same: hold everything else fixed, know your noise floor before you claim an effect, and check that your instrument can register the thing you are asking it about.