The UI Runtime That Can't Flake
Why UI tests flake — a runtime-architecture answer, not a tooling one. Make every frame a pure state transition, quantize the network onto frame boundaries, and time becomes data: 60 runs of the same journey, one histogram bar. Then turn the clock rate into a dial and a whole user session becomes 13 replayable frames — the world an agent actually wants to live in.
Written by Yifeng "Evan" Wang
Somewhere in your CI, right now, there is a test like this:
await page.getByRole("button", { name: "Place order" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();It passed four times today. On the fifth run — same commit, same code, same test — it failed. Your team calls it flaky. There is a retry policy for it, a quarantine list, maybe a dashboard that tracks the flake rate the way one tracks the weather. And that is the tell: we treat flakiness as weather — an ambient hazard to be endured, mitigated, budgeted for.
This post argues that flakiness is not weather. It is a property of the runtime's model of time, it has a precise cause, and a UI runtime can be designed so that flakiness is not merely rare but impossible by construction — the way a sorted list doesn't usually keep order, it cannot lose it. Then it shows the receipts: a runtime that works this way, a real app scenario with async fetches and mutations, an experiment where the same code runs under two different clocks — one produces a histogram, the other produces a single bar — and a CI suite where the assertion is byte equality on every pixel of every frame.
And "can't flake" is only the doorway — the claim behind it is bigger than testing. ui = f(state) — the idea React planted — made the view a pure function and changed how a generation thinks about interfaces. But it purified space and said nothing about time: when state changes, in what order, interleaved with what. Everything that still hurts — flaky tests, unreproducible bug reports, "works on my machine," debugging by staring at videos — lives in that gap. The missing half of the equation is:
state[n+1] = F(state[n], input[n])
pixels[n] = G(state[n])Space and time, both pure. The rest of this post is what falls out when a UI runtime actually commits to that pair — and it turns out "your tests can't flake" is only the first and least interesting consequence.
Flakiness is a hidden input
Start with a definition sharp enough to build on. Run a program twice from the same initial state with the same recorded inputs. If it can produce two different results, then — by definition — something varied that your recording didn't capture. A test is flaky precisely when the program has inputs its tape doesn't record.
Written as an equation: you wanted your app to be
state[n+1] = F(state[n], input[n])but on a real browser runtime the function that actually executes is
state[n+1] = F(state[n], input[n],
⏱ wall clock, 🎲 scheduler interleaving,
📶 network arrival order, 🗑 GC pauses,
🖥 vsync phase, 🔤 font-load completion, …)Every argument after input[n] is a hidden input: it changes the trajectory, nobody records it, and nobody could record all of it. You cannot replay what you did not record. That single sentence is the whole theory of flaky tests.
Seen this way, the industry's testing stack is a catalogue of coping strategies for hidden inputs. Mock Date.now() — one hidden input down. Fake timers — another. Stub fetch, stub requestAnimationFrame, preload the fonts, disable animations in test mode. Each mock is an admission that an unrecorded input exists, and the list never ends, because the runtime is free to invent new ones (an IntersectionObserver callback, an image decode completing, the microtask interleaving between two awaits). Playwright's auto-waiting — the state of the art — doesn't remove the nondeterminism at all: it wraps every assertion in a retry loop and polls until the world happens to pass through a state where the assertion holds. That converts a hard failure into a slow pass. It also quietly forfeits an entire class of assertions: you can no longer ask "was the confirmation visible within 500 ms?", because the framework's answer to "when?" is "keep asking until yes." Timing itself has become untestable.
Why the big runtimes flake by construction
None of this is an accident or an oversight, and it is worth being precise about why, because the answer is architecture, not sloppiness.
Chromium is a distributed system wearing a rendering engine's clothes. Your page's JavaScript runs on the main thread — alongside style recalculation and layout. Scrolling and CSS animations run on a separate compositor thread so they stay smooth while your JS is busy. Rasterization happens on a pool of worker threads; actual GPU submission lives in a different process; so does the network stack. These components coordinate through asynchronous IPC, and every boundary is a place where ordering is decided at runtime by whichever message lands first. On top of that, the main thread itself schedules macrotasks, microtasks, requestAnimationFrame callbacks and idle callbacks by rules with real degrees of freedom. When your fetch resolves, what you observe is the last hop of an inter-process race. The frame you screenshot is an emergent artifact of thread scheduling. Chromium's frame is a deadline — "whatever has committed by vsync ships" — not a transaction.
Flutter made the same trade with its eyes open. A Flutter app runs its Dart on a UI thread while a raster thread draws and a platform thread feeds in events; animations, by explicit design, sample the wall clock — an AnimationController maps elapsed real time onto a curve, so if a frame drops, the animation doesn't slow down, it skips ahead. That is the correct choice for a phone: the user lives in wall time, and catching up to reality beats replaying it. But look at what Flutter's own test story has to build to compensate: FakeAsync to virtualize timers, tester.pump(duration) to advance a simulated clock frame by frame, pumpAndSettle() to spin until the UI stops moving. Flutter testing works by simulating time — which concedes the whole argument. The simulation covers what the framework owns; the moment a plugin channel, an isolate, or a real network call crosses the boundary, the hidden inputs return.
The pattern generalizes. These runtimes treat time as something to sample: view = render(state, wallClock). The in-between moments have no committed semantics — which frees the runtime to drop, coalesce, and race them, and that freedom is exactly where the smoothness comes from and exactly where determinism dies. Once the wall clock is an input anywhere, it is an input everywhere, and no test harness bolted on afterward can un-ask the question "what time was it?"
The frontend has actually brushed against the alternative once before: Redux time travel. Record the actions, fold them over a reducer, scrub back and forth — state = actions.reduce(reducer, state0) is precisely the right shape. But Redux could only purify the store. The runtime around it — rendering, animation, layout, the event loop, every setTimeout in every component — stayed on wall time, so the replay was always an approximation that ended at the edge of the state tree. The pixels never came along. The lesson wasn't that the idea was wrong; it's that the fold has to own the whole world, or the world leaks.
The other tradition
There is a lineage of software that refused to leak, because for them replay wasn't a developer convenience — it was the product, or the only path to correctness.
Game developers solved this in the 1990s under duress. A StarCraft or Factorio replay file contains no video: it is a list of inputs per simulation tick, and the simulation is deterministic, so replaying the inputs regenerates the entire match — which is also how lockstep multiplayer ships only keystrokes over the wire. Fighting games run the idea backwards: GGPO's rollback netcode predicts the remote player, and when the real input arrives late, rewinds the world and re-simulates several frames inside one frame budget — feasible only because a frame is a pure function you can call as fast as the CPU allows. Databases got there next: FoundationDB ran its entire distributed database inside a deterministic simulator and injected years of network partitions and disk failures into CI; TigerBeetle's VOPR does this today, and Antithesis productized the idea as deterministic hypervisor time travel for arbitrary software. And rr gave systems programmers record-and-replay debugging by capturing exactly the nondeterministic inputs (syscalls, signals, thread scheduling) so execution becomes reproducible.
One move, every time: make the program a pure function of a recorded event log, and bugs become data. Tests cannot flake because there is nothing left to vary. The UI world never adopted the move — not because it can't work for UI, but because UI runtimes were never designed as simulations. So we designed one that is.
The frame is a transaction
PocketJS is a UI runtime we built for constrained machines — real Solid (and Vue Vapor) components with Tailwind-style classes, compiled to run against a Rust core; it drives a real 2004 Sony PSP, a browser canvas, and a headless test host from one bundle. From day one it has had one load-bearing rule, inherited from the game-loop tradition:
vblank paces; it does not define. The display's refresh — PSP vblank, browser requestAnimationFrame — only decides when to run the next step. What a step is belongs to the runtime, and it is a fixed-order transaction:
frame n:
advance the virtual clock (frame counter += 1; due timers fire)
apply queued effect deliveries (results from the outside world)
run app frame hooks (onFrame, button edge detection)
run input pass (focus navigation, onPress → reactive updates)
end-of-frame sweep (reclaim detached subtrees)
advance the core (animations — EXACTLY 1/60 s per tick)
render (pure projection of state)Nothing is allowed to touch the world between transactions. Core animation ticks advance by exactly 1/60 s of simulated time per tick — never by measured elapsed time. Text layout is deterministic. There is no Date.now() in the reactive path. The wall clock doesn't merely rarely interfere; it has no door.
This is not aspiration; it has been load-bearing in this repo for a while. The test suite holds 35 pixel goldens that assert byte equality of encoded PNGs — not screenshot-diff-with-tolerance, byte equality — across nine demo apps with springs, staggered mounts, and keyframe choreography. A 180-frame recorded interaction session replays as a session golden on every build: same tape, same 180 framebuffer hashes, or CI names the first divergent frame. The DevTools time-travel seek is implemented as nothing more than "re-run the fold from frame 0" — which only works because the fold is real. OpenStrike, the Counter-Strike-shaped FPS that ships on the PSP, is byte-replayable the same way, bots and tracers included.
But until this week the fold had a famous asymmetry. Buttons were on the tape. The network was not.
The effect shell: putting the outside world on the tape
"Fine for a game HUD," says the app developer, correctly, "but my app talks to servers." This is where UI determinism usually goes to die: a fetch resolves whenever, a promise's .then runs at the scheduler's discretion, and the fold's purity is broken by the first spinner.
The fix is to stop letting the outside world push into the program, and make it queue instead. PocketJS now ships an effect shell (DETERMINISM.md): an app never awaits a promise and never registers a native callback. It emits a command, and the result comes back as a delivery — applied at the start of a later frame's transaction, as part of that frame's input[n]:
import { runEffect } from "@pocketjs/framework/effects";
runEffect<Receipt>("order", { items }, (receipt) => {
setReceipt(receipt); // runs at a frame boundary — always
setPhase("confirmed");
});Two details carry all the weight. First, the API takes a callback, not a promise — deliberately. A promise resolution is timed by the microtask queue, and the microtask queue is a hidden input owned by the JS scheduler — precisely the thing being exiled. In this runtime, the frame boundary is the event loop. Second, the thing that actually performs the work — the driver — is swappable per host. A live host installs a driver that does real fetches and queues results as they arrive (still only ever applied on frame boundaries). A test host installs a driver that delivers from a recorded tape, at recorded frame indices. The app cannot tell the difference, because the app only ever sees deliveries at frame edges. Every command and every delivery is also streamed out with its frame index — so an interactive session writes, as a side effect of running, the complete causal record needed to replay itself.
Determinism ends where an unrecorded input enters; the shell's job is to make sure every entrance goes through the recorder. The equation grows one term and closes again:
state[n+1] = F(state[n], input[n])
input[n] = buttons[n] ⊕ deliveries[n] ⊕ timers-due[n]To prove this on something shaped like real work rather than a toy counter, the repo now includes Pocket Café — a little ordering app with everything that makes UI tests miserable: it boots into a CONNECTING… state and fetches the menu (async, 500 ms), the user browses with focus navigation and adds drinks, placing the order is a mutation with real latency (async, 1 s), a PLACING ORDER… phase animates while it's in flight, and the confirmation toast auto-dismisses on a timer. The timer is the third leg of the tape: after(1.5, reset) — 1.5 seconds of virtual time, a frame-indexed deadline, setTimeout with the wall clock amputated.

One journey through Pocket Café: fetch in flight → cart building → confirmation landed. Async everywhere — and every run of it, on every machine, produces these exact pixels at these exact frames.
The experiment: same code, two clocks
Here is the part I like most, because it isolates the variable the way a lab would. We did not compare our runtime against a browser — too many confounds. Instead we took our own runtime and made it flaky, by changing exactly one thing: what "time" means.
- Runtime W (wall) drives the café app the way mainstream runtimes drive apps. A rAF-style accumulator loop paced by real
setTimeout— with the timer jitter every OS delivers — plus occasional injected main-thread stalls (the GC/raster pauses every real machine has), and the order result delivered by a real wall-clock timer with network-like jitter. Nothing exotic; this is an ordinary Tuesday for a browser test. - Runtime V (virtual) runs the identical bundle and the identical journey through the deterministic sim host, where time is the frame counter.
Both measure the same event: the frame at which the order confirmation enters the world. And both run the same Playwright-style assertion: "the confirmation is visible by t = 2.5 s." Sixty runs each:
The wall runtime's delivery frame took 22 distinct values across 60 runs, spread over frames 140–164 — a spread produced by nothing more sinister than timer jitter and simulated GC pauses on a fast, idle machine. The timing assertion passed 9 times out of 60. Not because the app is wrong — the app is identical — but because "visible by 2.5 s" is a question about a race, and every run resolves the race differently. This is the histogram your flaky test lives inside. A test-tooling response would now widen the timeout until the failures hide; the timing requirement itself — is the product actually responsive? — silently becomes untestable.
The virtual runtime's delivery frame is 144. Every run. Forever. Not "passed 60 times" — cannot fail, in the sense that there is no remaining variable to make it fail: the delivery frame is a deterministic function of the tape, so asserting it is asserting arithmetic. And because pixel trajectories are deterministic too, the assertion vocabulary gets stronger than anything a wall-clock harness can offer: the CI suite that ships with this post asserts run-to-run byte identity of the framebuffer hash of all 390 frames of the café journey. It also runs the whole journey with chaos injected between frames — real wall-clock sleeps of random length, allocation garbage, forced GC — and asserts the trace does not change by one bit. It doesn't. The wall clock isn't an input, so torturing the wall clock changes nothing. That test is the architecture's signature, executable in about a third of a second: bun test test/sim.test.ts.
One more honesty note: Runtime W is our runtime too. Determinism is not a property a good implementation earns; it is a property the clock contract grants. Change the contract, lose the property — instantly, in both directions.
Time as a dial
Once time is a fold index instead of a physical fact, something strange and useful becomes possible: the rate of time becomes a parameter.
PocketJS's core has always advanced in fixed 1/60 s ticks. The new virtual clock makes the simulation rate a host policy: a world can run at simulationHz = 2, meaning one frame transaction per half-second of virtual time, with the core catching up 30 fixed ticks inside each transaction. Durations don't warp — a 300 ms transition still takes 300 ms of virtual time at any rate, because it's defined in time, not in frames; the 2 Hz world just observes it coarsely. On the browser host this is literally a URL parameter — ?hz=2 runs the two-frames-per-second world on a real screen, animations easing in slow, deliberate steps.
Why on earth would you want a 2 FPS world? Because of what the CI suite proves next — the result I find genuinely beautiful. For an app whose logic lives on events and virtual time (not per-frame counters), the low-rate world is not a degraded approximation of the 60 Hz world. It is the same world, observed less often:
pixels_hz[m] == pixels_60[(60/hz)·(m+1) − 1] for EVERY frame mThe test asserts this per frame, by hash, for 4 Hz and 2 Hz against 60 Hz: every one of the 2 Hz world's 13 framebuffers is byte-identical to the corresponding framebuffer of the 60 Hz world's 390. Same journey, same trajectory, same effects landing at the same virtual seconds (menu at 0.5 s, order at 3.5 s, confirmation at 4.5 s — at every rate), same settled final screen, byte-equal.
The rate dial has an obvious first customer: cost. The full 60 Hz café journey — 390 frames, every framebuffer rasterized and hashed — replays in 352 ms, eighteen times faster than the 6.5 real-time seconds it represents. The 2 Hz journey replays in 22 ms, three hundred times faster than real time. There is no waiting inside a fold; sleep() is just an index that increments.
But the deeper customer is whoever reads the trace.
The world an agent wants to live in
Here is every frame the 2 Hz world produces for the entire café session. Not highlights — all of it:

The complete 2 Hz session: 13 frames, 6.5 virtual seconds, every phase of the journey legible — and each of these framebuffers is byte-identical to its counterpart in the 390-frame 60 Hz run.
Look at that strip the way an AI agent would. An agent driving a UI today — through a browser — screenshots an unrepeatable process at arbitrary wall-clock moments, guesses whether the spinner it sees is still spinning or newly spinning, waits pessimistically, and can never re-examine a past state, only its stale screenshot of one. Every one of those pains is a hidden-input pain. Now give the agent this runtime instead:
- Observation is enumerable. The world advances in numbered transactions. "The state at frame 9" is a well-posed expression, not a race with a screenshot API. At 2 Hz, a whole session is 13 observations — and the subsampling theorem says these are the same states the 60 Hz user saw, not a lossy summary.
- History is a data structure. The input tape plus the effect trace is the session — a few hundred bytes of causality. Any past state is
O(n)to revisit exactly, and at fold speed, "revisit frame 200" costs milliseconds. Time-travel debugging stops being a product feature and becomes an index lookup. - Hypotheticals are cheap. Fork the tape at frame 9, splice in a different press, re-run the fold: a counterfactual world in 22 ms. An agent can search over futures the way a chess engine searches moves — because the world, like a chess position, finally has a transition function.
- Verification is equality. "Did my change break the flow?" is not a judgment call over screenshots; it is
trace == trace, per frame, per byte. The agent-written test cannot flake for the same reason the human-written one can't: there is nothing left to vary.
We think this is what "agent-friendly UI" actually means. Not a better accessibility tree bolted onto a nondeterministic process — a legible world: enumerable time, recorded causes, replayable history, byte-checkable outcomes. The PSP taught this runtime to be small; determinism is what makes it knowable. It is probably not a coincidence that the tradition this borrows from — deterministic simulation — is also how we train and evaluate game-playing agents.
What this doesn't claim
Boundaries, stated plainly, because a claim of "impossible" earns scrutiny.
The fold is only as complete as its tape. A live network is nondeterministic by nature; the shell doesn't change that — it records it, and the replay is then exact. Determinism here is a property of replay and simulation, not a denial that the outside world varies. Likewise, Math.random() and Date.now() are not fenced off by force; the contract is to express randomness as seeded state and time through the virtual clock, and the linters of the future can enforce what the architecture makes natural.
A 2 Hz run validates the 2 Hz world. The subsampling theorem covers the shared instants; if your app hard-codes per-frame logic ("on frame 37..."), its meaning legitimately differs across rates — so write seconds, which is what the clock API hands you. And the theorem's precondition (event- and time-driven logic) is a discipline the runtime encourages, not a law it can force on a counter you increment every frame.
Wall-clock runtimes are not wrong. Flutter skipping animation frames to stay glued to real time is the right call for a phone in a hand. Sampled time buys fluidity under jitter and pays in replayability; owned time buys replayability and pays by scheduling its world. The mistake is not either choice — it is making the first choice and then spending two decades of test-infrastructure effort pretending you made the second.
Determinism is a choice
Here is the whole post in one sentence: tests flake because runtimes let things change your app that nobody writes down — and a runtime can refuse.
Every UI runtime decides what is allowed to change your program. The mainstream stack allows almost everything: the wall clock, the OS scheduler, whichever network packet lands first, a GC pause. Each guest was let in for a good reason, and each one makes your app's history unrepeatable — which is why your tests retry, your bug reports say "sometimes," and your debugger can only walk forward. Flakiness was never a tooling gap. It is simply what "anything can change the program at any moment" looks like from inside a test.
The games industry showed the other choice decades ago: let nothing change the program except the recorded input of each frame. Make the frame a transaction. Make time a counter. Make the network queue at the door. Then the entire life of an interface — every animation frame, every spinner, every confirmation toast — collapses into one expression:
history = inputs.reduce(F, state0)and anything you can write as a reduce you can replay, subsample, fork, diff, and prove. ui = f(state) told us what a view is. state[n+1] = F(state[n], input[n]) tells us what a moment is. The first equation gave interfaces to declarative programming; the second gives them to simulation — with byte-exact tests as the appetizer and legible worlds for agents as the meal.
Time is an input. Record it, and it will testify. Refuse, and it will flake — not sometimes; for as long as you refuse.
The receipts, runnable from the PocketJS repo: bun test test/sim.test.ts (byte-identity, chaos immunity, the subsampling theorem), bun scripts/flake-lab.ts (the two-clock histogram on your own machine), ?hz=2 on the web host (the 2 FPS world, on a real screen), and DETERMINISM.md (the contract). The café demo is demos/cafe — an ordinary PocketJS bundle; every host, including the PSP one, drives it through the same frame transaction.