DEV Community

Keshav Varshney
Keshav Varshney

Posted on

My Go tests never executed a line of the JavaScript they were testing

TL;DR: I built a Real User Monitoring tool as one Go binary with an empty
dependency manifest, zero entries in go.mod. 245 test functions, go vet
clean, CI green on every push, a reproducible build verified on every commit.
Six bugs survived all of it. This is the story of a build that stamped a git
SHA into a binary I had called deterministic, an int64 that saturated into
six-year chart buckets, a demo page that was genuinely slow and reported
green, and a fix that was correct, deployed, and invisible for an hour.

None of them were findable from Go. That is the actual subject of this post.


The premise is a joke that turned out to be the product

RUM tells you how slow your site feels to real visitors. Every mainstream way
of collecting it ships 30 to 60KB of third-party JavaScript from someone
else's CDN, executing before your own code, on every page view. The tool that
measures page weight is itself page weight.

So the target was a beacon small enough that the size is the argument. It
landed at 942 bytes, against 7,226 for Google's web-vitals. Everything
else followed from refusing to make that number worse: no framework, because
a framework is a bundle; no chart library, because a chart library is 60KB to
draw six lines; no database driver, because the thing being stored is five
floats per page view.

Writing that was the easy half. The question that decides whether any of it
is real is: how do you know it works?


The gap: no Go test can execute JavaScript

Testing a browser beacon from a Go suite means testing everything except the
beacon. I could prove /b.js was served with the right MIME type and ETag,
that it stayed under its 1024-byte budget, that the ingest endpoint parsed a
payload I hand-wrote and the API read it back. All of that passed, and none
of it proves PerformanceObserver fires, that visibilitychange triggers
the flush, or that CLS session-window arithmetic produces a number a browser
would agree with.

Go tests embed the file, serve it, assert on its bytes, and never execute a
line of it. To a Go test, JavaScript is a byte slice.

So I drove real Chrome over the DevTools Protocol. Node 22 ships a global
WebSocket, so the harness needed nothing installed:

const { webSocketDebuggerUrl } = await (await fetch(`${CDP}/json/version`)).json();
ws = new WebSocket(webSocketDebuggerUrl);
Enter fullscreen mode Exit fullscreen mode
tests/*.mjs (external harness)     drives real Chrome over CDP
        │  Page.navigate, Runtime.evaluate, Input.dispatchMouseEvent
        ▼
vitals binary                      serves dashboard, demo pages, beacon.min.js
        │  beacon executes for real, PerformanceObserver fires for real
        ▼
/api/collect  →  /api/series, /api/events (EventSource)
        │
        ▼
45 scripted assertions against what the browser actually measured
Enter fullscreen mode Exit fullscreen mode

The harness lives outside the repository, because a browser automation
dependency inside a zero-dependency project would be absurd.

Two details from building it are worth passing on. Chrome 152 removed
Emulation.setPageVisibilityState, the usual way to fake a tab switch, so I
replaced it with what a visitor actually does: open a second tab and activate
it. And I was clicking with element.click(), which made INP appear only
intermittently, because synthetic clicks do not reliably produce Event Timing
entries. Switching to Input.dispatchMouseEvent made it deterministic and
produced the single best piece of evidence in the project:

metric inp collected by the browser  p75=600.00 ms (poor, n=1)
Enter fullscreen mode Exit fullscreen mode

600.00ms against a handler deliberately blocking for exactly 600ms.


Six bugs, in increasing order of "that would have shipped"

1. The reproducible build that wasn't

make repro builds twice and compares hashes. CI does the same on a clean
runner and runs cmp. Both passed from the day I wrote them. The README
said the binary contains no build timestamp, git SHA, or injected version,
which is what makes the output byte-identical. False. I caught it only
because a hash changed between two commits that touched nothing but
Markdown.

$ go version -m vitals
build   vcs.revision=6acd604c28b79a3db52850287e061b98c0018d7e
build   vcs.time=2026-08-30T10:56:20Z
Enter fullscreen mode Exit fullscreen mode

Since Go 1.18 the toolchain stamps the git revision, a timestamp, and a
module pseudo-version into any binary built inside a repository. On by
default. The fix is one flag, -buildvcs=false.

The fix is not the interesting part. My test checked a weaker property than
my claim. "Two builds at the same commit produce identical bytes" and "the
binary contains no commit metadata" are different sentences. Only the first
was under test. The second was what the README asserted, and a passing test
sat next to it the whole time looking like evidence.

I proved the fix the way I should have proved the claim: build, make an
empty commit so the SHA changes, build again, compare. Most
reproducible-build advice stops at -trimpath and -ldflags=-buildid=. The
VCS stamp is newer, quieter, and it undermines you without saying anything.

2. The int64 that saturated

I loaded the dashboard and read the caption under the chart:

1 of 48 buckets have samples, 3202560 min per bucket
Enter fullscreen mode Exit fullscreen mode

Six years per bucket. On a chart labelled "last 24 hours".

The dashboard calls /api/series?from=24h and sends no to, because "now"
is the obvious default. An open range end was normalised to the year 9999.
Then:

span := rng.To.Sub(rng.From)
width := span / time.Duration(q.Buckets)
Enter fullscreen mode Exit fullscreen mode

time.Duration is an int64 of nanoseconds, topping out near 292 years.
Subtracting 1970 from 9999 does not error, does not panic, does not warn. It
saturates. Divide by 48 and you get six-year buckets, rendered without
complaint.

No test caught it because I wrote the tests alongside the API, thinking
about the API, so every one passed both from and to like a well-behaved
caller. The dashboard, the only real client this API has, passes one. The
bug lived exactly in the gap between how I documented the endpoint and how
the only thing calling it actually calls it.

The regression test does not assert a bucket width. It asserts that no
combination of parameters can produce a span longer than a century, because
a span that long means an open end leaked through and the arithmetic
saturated.

3. The demo that demonstrated nothing

The project ships four deliberately broken demo pages so the dashboard shows
something other than a wall of green. One is "heavy image": a hero of 2,600
inline SVG shapes, meant to tank Largest Contentful Paint. It is genuinely
expensive to render.

In Chrome it measured 220ms. Good band. Green.

An inline <svg> element is not an LCP candidate. The spec is specific
about what counts: <img>, <image> inside an SVG, video poster frames,
background images, and block-level text. A root inline <svg> is not on
that list. The page burned real paint time while LCP quietly reported on a
paragraph above it.

This one stings because it is precisely the failure a performance dashboard
exists to prevent: the page was slow, the metric said fast, both were true,
and the gap was a spec detail I had not read carefully enough.

Fixed with an <img> and a data URI so it is actually a candidate, plus a
deliberate main-thread block so it is slow on any hardware rather than only
on a bad laptop. It reports 2852ms now. My first attempt used a 4200ms block
to force the poor band, which broke differently: the page was still blocked
when the tab hid, so the beacon flushed before any paint entry existed and
sent a record containing only TTFB. Worse demo, worse data. The page now
explains the trap in its own copy, because the trap is more interesting than
the demo.

4. I measured my own tooling and called it a result

The headline claim is a size comparison. That claim is the entire pitch, so
it had better be clean. My first numbers were not: I measured our beacon
with Go's gzip and theirs with Python's. Same algorithm, different
implementations, a few percent apart. Nobody would ever have caught it. It
would have been a property of my toolchain, presented as a property of my
code, in the one number the project rests on.

So I wrote a tool that runs every file through one compressor:

File Raw Gzipped
vitals beacon 942 B 571 B
vitals full beacon 2,656 B 1,415 B
web-vitals.iife.js 7,226 B 2,601 B
web-vitals.attribution.iife.js 12,505 B 4,172 B

7.7x smaller raw, 4.6x gzipped. And still not a fair fight, in both
directions.

Unfair to us: web-vitals only measures. It hands each metric to a
callback and leaves transport entirely to you, so a real deployment adds
reporting code on top of those bytes. Our 942 already include JSON
serialisation, sendBeacon, a fetch fallback, and flush-on-hide.

Unfair to them: web-vitals does more per metric: back-forward cache
restoration, prerendering and activationStart, soft navigations,
attribution to the element that caused the bad LCP, real INP grouped by
interactionId rather than the longest single event, and years of Safari
workarounds.

At 942 bytes I did none of that. So I wrote a second beacon that does five
of those six, at 2,656 bytes, and kept it as a separate file rather than
folding it into the first. Two reasons, one technical and one not. The
technical one: most sites want the small one, and paying 1.7KB for prerender
correction on a site that never prerenders is exactly the trade this project
exists to argue against. The other: the sub-1KB number is the headline
claim, and quietly redefining "the beacon" to mean the bigger file is the
move I would criticise in someone else's README.

Neither beacon does the Safari and Firefox work. That is years of
accumulated browser bug knowledge, not something you reproduce by reading a
spec. A comparison is more convincing with its caveats than without, because
a reader who spots an unstated one stops trusting the rest of your numbers
too.

5. The cache that outlived the bug

I broke dash.js with a one-character mistake, a literal newline inside a
string literal, which is a syntax error. The whole script failed to parse,
so the dashboard rendered nothing. Every Go test passed. Of course they did.
node --check is now in the pre-finish checklist, alongside make check.

The instructive part came next. I fixed it, rebuilt, restarted, and the page
was still blank:

Cache-Control: public, max-age=3600
Enter fullscreen mode Exit fullscreen mode

The asset names carry no content hash. A browser that had already fetched
the broken script would not ask again for an hour. Server fixed, disk
fixed, user still broken, and no amount of restarting changes that, because
nothing on the server participates in the decision.

The fix is a policy split rather than a blanket. The dashboard's own scripts
are now no-cache, which still caches them but forces a revalidation that
answers 304 with an empty body. The beacon keeps the long max-age,
because it is fetched by every page view of an instrumented site, where a
conditional request per view is a real cost. Same file server, two
policies, chosen by who is asking and how often.

A long cache lifetime is a promise you cannot revoke. It is only safe when
the URL changes with the content. If you are not hashing names, you are
betting you will never ship a bug.

6. The half-open window that dropped the present

An end-to-end test started failing about half the time. Same code, same
machine, same command.

The dashboard's default window ends at "now". The store's range is
half-open, [from, to). A measurement recorded in the same clock tick as
the request that reads it lands exactly on the exclusive bound and falls
outside its own window. On Windows, where the wall clock is coarse enough
that two calls microseconds apart return the same value, that is a coin
flip.

if to.IsZero() {
    to = now            // excludes anything stamped at this instant
}
Enter fullscreen mode Exit fullscreen mode

Every test passing an explicit to was fine. Only the default was wrong,
and the default is what the only real client sends: the same gap as bug 2,
in a different corner of the same function. The failure mode was "I just
recorded a measurement and it is not there", intermittently, which is the
worst kind of bug report to receive from yourself.

The fix is one millisecond, and the reasoning matters more than the line.
Records are stored at millisecond resolution, so ending the default window
one millisecond after now includes the present instant and nothing that has
not happened yet. An explicit to is left exactly where the caller put it.

A flaky test is a race you have already reproduced. I nearly re-ran it and
moved on.


The bug the whole suite was structurally blind to

This one arrived last and it is the one I would most want a reviewer to
see. The dashboard has a button that copies a report as a prompt for an AI
agent. Clicking it threw:

Cannot read properties of undefined (reading 'good')
Enter fullscreen mode Exit fullscreen mode

The Go struct tagged one field json:"Distribution" with a capital D. The
dashboard, the API documentation, and the contract test all said
distribution.

Here is why nothing caught it: encoding/json matches field names
case-insensitively when decoding. The contract test unmarshals the response
into a struct tagged json:"distribution", and it bound happily to
"Distribution". It passed against either spelling. Every Go test in the
repository was structurally blind to this, and only a browser, which is not
case-insensitive about anything, could ever see it.

The regression test therefore does not use a struct. It decodes into
map[string]any and walks every key in the document:

if first := k[0]; first >= 'A' && first <= 'Z' {
    t.Errorf("key %s%s starts upper-case; a JavaScript client "+
        "reading the documented lower-case name gets undefined", path, k)
}
Enter fullscreen mode Exit fullscreen mode

Decoding into a map is the entire point. It is the only way to assert the
key a non-Go client actually receives, rather than the key Go is willing to
accept.


What I did not build

Stated here rather than left for someone to notice.

The binary segment storage format was planned and cut. The design called
for compacting sealed day logs into a hand-written binary format with varint
delta timestamps and a string dictionary. It stayed JSONL. That decision now
has a number attached rather than a shrug: replaying 100,000 records takes
593ms, of which roughly 500ms is encoding/json on the read path. A
million records is about six seconds and 1.6GB to open. That 500ms is
exactly what the segment format would have removed, and it is the honest
size of what cutting it gave up.

There is no authentication. The dashboard and API are open to anyone who
can reach the port. Collection is rate limited per client address, but
forwarded headers are ignored because they are trivially spoofable, which
means behind a reverse proxy every visitor shares one bucket and one
derived session id.

reportAllChanges is not implemented. web-vitals can emit every LCP
candidate as it changes. This sends one record per page view on hide, which
is the whole reason it fits in 942 bytes.


The decision I would take back

The beacon exists as two files: beacon.src.js, readable and commented, and
beacon.min.js, minified by hand. There is no minifier in the project,
because a minifier is a build dependency and the project has no build step.

The cost is that a human keeps two files in sync, and humans drift. I
mitigated it rather than solved it. Tests assert that every metric key,
every observed entry type, and the collection endpoint appear in both
files, and that the minified file is actually minified rather than a copy
of the readable one under the wrong name. That catches the catastrophic
mistake. It does not catch a subtle logic change applied to one file and
not the other.

A real minifier would make the drift structurally impossible, and I would
take the build step. The zero-dependency rule was about runtime
dependencies, and I let it bleed into build tooling where it bought nothing
but risk.


By the numbers

Beacon 942 B raw, 571 B gzipped, 1024 B budget enforced by the build
Full beacon 2,656 B raw, 1,415 B gzipped, separate 2816 B budget
vs web-vitals 4.2.4 7.7x smaller raw, 4.6x gzipped
Dependencies 0. go list -m all prints one line
Go ~13,750 lines, of which ~8,100 are tests
Tests 245 functions, 10 benchmarks, 1 fuzz target, 20+ table-driven
Frontend ~3,400 lines, no framework, no bundler, no build step
Packages replaced 41, each with a note on where the original is better
Browser checks 45 scripted, in real Chrome 152
Commits 50, with the tree building and make check passing at every one

Two of the later features came from measuring rather than reasoning.
Benchmarking the store turned up a genuine quadratic path: an out-of-order
insert rebuilt both secondary indexes, 0.7 to 1.0 ms against 66 µs for
shifting them instead, and out-of-order arrival was not the rare event I had
assumed. The collector stamps the wall clock and takes the store lock as a
separate step, so concurrent page views land reversed constantly. And "you
lose at most two seconds on a crash" was still only a sentence, so I wrote a
test that builds the binary, runs it as a child process, feeds it 500
measurements, and kills it. A clean Close had never tested the thing the
sentence claimed.

Said out loud rather than buried: percentiles are read from histogram
buckets and carry up to 4.9% relative error on millisecond metrics and
0.0025 absolute on CLS, while band counts are exact. The small beacon
approximates INP as the longest event over 16ms, so it is pessimistic in
the tail, which is why the full beacon exists. Firefox and Safari are
untested, and so are the full beacon's own bfcache, soft-navigation, and
prerender paths: reviewed against the specs, never watched running in a
browser. That is the largest honest gap in the project, and it is why the
small beacon remains the default.


What the time actually went on

Anyone can write a PerformanceObserver that compiles and a Go server that
serves. The beacon is 942 bytes; it took an afternoon. What the rest of it
went on was building enough ways to catch myself being wrong: a dependency
checker that fails the build on a require block, a CDN reference, or a web
font; a size tool that measures every file with one compressor; a CI job
that builds twice and runs cmp; a fuzz target on the only place the
program reads untrusted input; a test that kills a real process; forty-five
scripted checks in a real browser, because my own suite could not execute
the file it was shipping.

Every one of those exists because a claim I had written down in English
turned out not to be a claim anything was checking. That translation step,
from the sentence in the README to the assertion in the test, is where all
six bugs lived.

Write the claim in plain English, then ask what would have to be true. It
is the cheapest tool in the list and it found more than any of the others.

The tool measures page weight and contributes 942 bytes of it. That was the
whole idea, and it survived contact with a real browser. Eventually.

Built for the Zero Dependency Hackathon 2026, the
72 hour dependency-free build hackathon run by Hackathon Raptors, Track D (Data & Storage). The full project,
STDLIB.md, the browser test harness, and the size comparison tool are all in
the repo.

Repo: https://github.com/ikeshavvarshney/vitals

Top comments (0)