First paint is the app
There's one number we treat as the product: how fast a user first sees their app. Not when generation finishes, not time-to-interactive, not a Lighthouse score — the moment something app-shaped is on screen. It's written down as a first-class engineering priority in agents/time-to-first-glance.md, and every agent that touches the prompt → preview path reads it before "simplifying" any of this machinery away.
This week that priority turned into a coordinated arc — eight pull requests, each taking one wait (or, in the closing act, one embarrassment) out of the two journeys a vibe can take to your eyeballs. Journey one: you open a vibe's URL and pixels have to happen. Journey two: you type a prompt and an app has to appear. This post is the whole arc as one story, including the part at the end where we got humbled.
Journey one: URL → pixels
The cache goes outside
We'd already made vibe pages server-rendered. The next obvious move was "cache the database lookup." jchris asked for the opposite: cache the entire root-HTML serve — so a cache hit skips auth resolution, the D1 queries, the doc reads, and the SSR isolate all at once, not just one query inside them (PR #3197).
The interesting part was where the cache can live. Our serve path has layers, and stale-while-revalidate needs two hands: caches.default to store with, and waitUntil to refresh in the background after responding. The inner serve layer has no waitUntil; the middle layer has no caches.default. Only the raw worker entry — workers/app.ts — holds both, so that's where the SWR layer landed. Fresher than ten minutes: serve it, don't even touch it. Ten minutes to a day: serve it and re-render in the background. Older: render for real.
Safety comes from store-time gating, which keeps knowledge where it belongs. The outer layer knows only mechanics — GET, bare root path, no query string. The serve path, which actually knows whether an app is public, marks storable responses with an internal header. Nothing gated ever enters the cache, so a hit can't leak by construction.
Two artifacts from the dig are worth their own frame. First: the repo already had a caches.default.put(...) on every response — fire-and-forget, no eligibility gate, and no reader anywhere in the codebase. Write-only caching. It looks like performance and does nothing; check your own repo, you might have one. Second: the Cache API refuses to store no-cache bodies, but our client contract requires exactly those headers — so the stored internal copy carries s-maxage and the serve path swaps the public headers back on the way out.
The HTML carries your data
SSR shipped, and jchris immediately curled his new feed app for a post he'd just written. Zero hits. The server was rendering the app's data-empty first paint by design: on the server, data RPCs park forever, so useLiveQuery renders its synchronous initial state — an empty list.
The fix (#3197 again, slice two) seeds the render with real documents, and the two constraints that shaped it matter more than the plumbing:
- Privacy picks the view. The rendered page is cached and served to everyone, so the seed must be exactly what an anonymous reader is allowed to see — resolved through the very same access gates the live read path applies. A signed-in viewer gets the public first paint, then their own view after hydration.
- Parity comes free if you share the JSON. The seed is written once into the mount parameters, which are both the server render's input and the client's hydration argument. Server HTML and client first-frame agree by construction — there is no second codepath to drift.
And one cache lesson: once documents are in the body, the page's ETag has to cover them. It was previously keyed on code + metadata, which would have let a repeat visitor's If-None-Match 304 onto stale posts forever. Doc writes now flip the validator, so revalidation keeps telling the truth.
Get posts like this in your inbox
One email field. Real updates. No algorithm required.
Styled and revealed with zero JavaScript
Even with cached, data-bearing HTML arriving fast, the viewer page still greeted everyone with a gray loading grid. Nothing was slow — the finished app was painted underneath an overlay that waited for client JS to hydrate, load the auth SDK, and complete a grant round-trip before revealing what was already there.
PR #3211 inverted it. For world-readable apps — a fact the server already knows and can hint in the first byte — there is no overlay at all while the grant check is in flight. First paint is the app. The overlay mounts only if the check resolves negative: revoked, gone-private, not found. We accepted a brief flash-of-app before the "not available" card in those rare cases, in exchange for taking the entire client-side auth stack off the visual critical path of every normal view.
The deepest supporting move: Tailwind, compiled on the server. The exact utility classes the SSR body uses are compiled with Tailwind v4's core and inlined into the cached document, so the first frame is fully styled before the browser Tailwind runtime ever downloads. Vibes' utilities get rewritten through canonical CSS variables by a client-injected stylesheet with a known element id — and the injector skips itself if that id already exists — so server-inlining the remap under the same id made the two paths naturally idempotent. No double stylesheet, no cascade drift: the first paint is pixel-identical to the settled state.
Hold that phrase — "fully styled" — for the coda.
Journey two: prompt → running app
The prompt was already on the server
Here's an architecture confession. When you typed a prompt, the mint call that reserved your app's slug already carried the full prompt text. The server used it to pick a slug… then waited for your browser to navigate to the app page, open a second connection, and send the very same prompt back before starting generation. We were mailing ourselves a letter we'd already read.
Worse than the latency: close the tab inside that window and the vibe was stranded as "App not available" forever — a gap found by a headless probe that closed the browser 15 seconds after submit (#3195). PR #3199 makes the server dispatch the first generation turn itself, fire-and-forget, the moment the mint is acked — synthesizing the exact request the client would have sent, same auth token, same admission accounting. When your browser arrives at the app page, it doesn't start the build; it attaches to one already running.
The lesson generalizes past our stack: when the payload you need is already in the first request, any architecture that waits for the client to echo it back is a latency tax and a reliability gap. The client's only irreplaceable job is rendering.
Stop polling for a fact you've been told
After a first build finished streaming, the app sat on "almost ready…" while the client polled every two seconds for a database row — whose creation the server announces on the very WebSocket the client was holding open. The poll had a ten-try cap, and the server's persist work could outlast it: when the budget ran out, nothing ever refetched again, and "almost ready…" was permanent until reload. Polls don't just add latency; capped polls add deadlines you didn't mean to set.
PR #3091 keys the reveal on the push signal instead (useFirstBuildReveal): refetch once, immediately, when the persist event lands. Happy path: exactly one request, zero poll latency. But push signals need a fallback story or they trade "slow" for "stuck" — the one refetch can be silently dropped by the route's own in-flight dedupe — so a capped backstop interval remains, ticking only while the reveal hasn't happened.
Then make the fact itself arrive sooner
With the poll gone, the remaining wait was the server's turn-end persist — which turned out to be a dozen independent awaits standing in single file: three database reads that could have hidden under the code transforms, and a billing HTTP round-trip serialized in front of the chat-history inserts. Bonus find: the "how many apps does this user have" guard was doing SELECT * of every release row the user owns — full file-system JSON included — just to count them.
PR #3093 fanned the pipeline out into three Promise.all stages that preserve every load-bearing ordering. That last clause is the real work: parallelizing a persist pipeline is mostly an exercise in enumerating which orderings were the design and which were accidents of await placement. Speculative execution changes which failures reach which paths — a guard that throws would now fail codepaths that never ran it before, so its promise is caught into a result object and only consulted where the old code consulted it.
And PR #3175 shaved the last 100–500 ms — with a correctness trap in the middle. The obvious move was to emit the existing "turn done" event earlier. But that event is three things at once: the reveal signal, the convergence anchor a reconnecting client trusts, and the replayed terminal after a reconnect — and replay is purely DB-backed, so emitting it before the turn's sections are durable would let a client converge against history that's silently missing a turn. You can't even fire a reveal right after the app row insert, because revealing makes the client load the app through its access policy, which must be written first. The shape that works (from the #3100 investigation): a second-class, live-only event with exactly one meaning — "the row and its access bindings exist" — and zero convergence semantics. Type hygiene as latency engineering.
The coda: the black screen inside a "fully styled" first paint
We shipped the server-compiled styles, verified them live, screenshotted the result, merged. Then jchris loaded production in dark mode and got a black screen.
Every byte of the SSR CSS was present and correct. But vibes style themselves with bg-[var(--background)] — and it turned out nothing in the platform had ever defined those variables. The only source was a :root block that codegen bakes into each app's own code, which arrives when the client boots. Until then: undefined variable → invalid background → transparent → whatever the document body paints. Near-white in light mode, where the bug was invisible. Near-black in dark mode: dark text on a black void.
Two fixes, one day. PR #3224 made the vibe document's own backdrop the platform's gray loading grid — the texture that honestly means "vibes is loading your app" in both color schemes — deliberately reversing an earlier rule that said the grid must never sit behind a live app. The black screen argued better; the stylesheet comment now records the new decision and its evidence. Then PR #3231 fixed the root cause: the server now inlines the active theme's full variable block — light, dark, and structural tokens, rendered with the exact same code codegen uses, so the two sources can't skew — into the document head. The app's own baked block still renders later and wins by cascade order. The server block is a floor, not a ceiling.
Two lessons we wrote down. "Design tokens" are a contract with two sides, and shipping only the consumer side compiles fine, renders fine in light mode, and fails silently everywhere else. And dark mode is a second rendering target, not a color preference — no "first paint" claim counts until it comes with a dark-mode screenshot.
The review heuristic
The arc adds up to a simple invariant, now enforced in agents/time-to-first-glance.md at review time: any change that adds a round-trip, a cold start, a poll, or a "wait for X to settle before showing anything" gate to the prompt → preview path needs a strong justification — ideally with a measurement. Latency work isn't a sprint you run once; it's a property you defend in review, forever. The waits removed this week were each individually reasonable when they were added. That's how waits get in.
See your app before you finish reading this sentence
Type a prompt. The build starts before your browser even lands on the page.
Start building →