Paint Before the Network Answers
The procedural wall is not a loading state. It is the first frame, and the database is an enhancement that arrives afterwards and quietly improves it.
The inversion
Most React apps that display remote data have the same silhouette: mount, show a spinner or a skeleton, fetch, replace. The first frame is an apology.
The arthis.land homepage inverts that. It is a wall of bricks — a vertical city, hundreds of DOM nodes, panned and zoomed at 60fps. Every brick has a position, a size, a colour and a label, and the real ones come out of a Supabase table. The first frame does not wait for them, and it is not a placeholder for them either.
The sequence is: build the wall procedurally in synchronous JavaScript, mount React, paint. Only then go and see whether the server has anything to add. The procedural wall is not a fallback. It is the frame the user sees, it is complete, and if the network never answers, nothing about the page reads as broken. Real data is an enrichment — it arrives late, fills in the labels and thumbnails and true colours, and re-renders into the same layout.
That reframing changes what you optimise. You are no longer trying to make the fetch fast. You are trying to make it irrelevant to first paint, which is a far easier problem, because the answer is simply "do not be on the critical path".
The actual boot sequence
Four steps, and their order is the design.
brick-data.js runs buildBricks() at module scope. It is a deterministic generator that lays rooms outward from the centre of the city, 90px per size unit, and produces the full array of brick objects. No network, no promises, no await. It writes the result to window.ARTHIS_DATA and returns. By the time the script finishes executing, the app has a complete dataset.
Then React mounts and the wall paints. That paint owes the network nothing beyond the HTML and the JS bundle itself.
Only after that does enrichment start, and it starts from a setTimeout(0):
// brick-data.js — module scope, synchronous, zero network.
// This is the first frame, not a loading state.
window.ARTHIS_DATA = buildBricks();
// Yield to the browser before touching the network. useEffect would
// run inside the commit, before the browser has had a chance to
// paint — which is exactly the frame we are trying to protect.
setTimeout(() => {
enrichFromRemote(); // snapshot first, live query only if it fails
}, 0);
// app.jsx — mount. Nothing above this line has made a request.
const root = createRoot(document.getElementById('root'));
root.render(<App />);
When enrichment resolves it does not call a setter. There is no shared store, no context provider, no prop drilling from a root fetcher. It updates window.ARTHIS_DATA in place and dispatches a custom event:
// The only cross-module contract between data and view.
// No import connects these two files, which is why the event name
// is declared in project.manifest.json and checked by the guard.
window.dispatchEvent(new CustomEvent('arthis:data-updated'));
// app.jsx
useEffect(() => {
const onUpdate = () => setBricks(window.ARTHIS_DATA);
window.addEventListener('arthis:data-updated', onUpdate);
return () => window.removeEventListener('arthis:data-updated', onUpdate);
}, []);
A global and a custom event are unfashionable. They are also correct here: the data layer does not depend on React and should not be compiled into React's bundle graph. The cost is that a rename is invisible to the bundler — nothing breaks at build time, it silently stops working. Hence both the global and the event name live in project.manifest.json, checked by npm run guard. An undeclared contract is one someone will delete while tidying up.
Why setTimeout(0) and not useEffect
useEffect feels like the right place for a fetch. It is not, when the thing you are protecting is first paint. Effects run after React commits to the DOM but within the same task — the browser has not yet had a chance to paint. Work started there competes for a main thread that has not yet drawn a single brick.
setTimeout(0) yields. It says: finish this task, go and paint, then come back for me. The zero is not a delay, it is a boundary — everything after it runs in a later task, once the browser has had its turn.
If you take one thing from this: a hook is a lifecycle position, not a scheduling primitive. When you need the browser to paint before you do more work, you need to yield to the event loop, and React has no hook that means "after the pixels are on screen".
There is a secondary benefit: because enrichment lives outside the component tree, it is not coupled to mount order, StrictMode double-invocation, or which component owns the fetch. It runs once, from module scope, regardless of what React is doing.
Identical shapes, or it flickers
The rule that makes the re-render invisible: procedural and enriched data must have exactly the same shape. Same fields, same types, same array of the same row objects. The generator is not producing a simplified stand-in; it produces the real structure with generated values in it.
Get this wrong and the pattern fails loudly. If the procedural brick has no thumb field and the enriched one does, the second render changes layout and the wall jumps. If sizes differ, rooms reflow and every brick moves. If a field a memo depends on is missing, the culling calculation changes and nodes flash in and out. The user reads all of that as the page breaking at the moment it should be getting better.
The same constraint already applies one layer down, between the two enrichment sources. The snapshot and the live query return identical shapes too, so nothing downstream can tell which answered — that is the snapshot that answers the enrichment, and it is why there is no branch anywhere in the render path for "which source did this come from".
Every enriched field also passes through safeNum, safeText, safeImageUrl and safeHex. Rows that fail validation are dropped, not defaulted. A size_w of 900000 is valid JSON and would blow the layout apart; a javascript: URL in a thumbnail field is valid JSON too. Dropping a bad row means the procedural brick already on screen stays. The failure mode of enrichment is "no enrichment" — a frame the user has already accepted.
The budget, and why a guard is the only kind that survives
None of this helps if the bundle that runs buildBricks() takes two seconds to arrive. The critical path is capped at 100 kB gzipped, and React spends 45 of it before a line of application code exists.
| Chunk | Size (gz) | On critical path |
|---|---|---|
| Wall HTML | ~3.7 kB | Yes |
| React + ReactDOM | ~45 kB | Yes |
| App bundle (app.jsx) | ~32 kB | Yes |
| Supabase client | ~55 kB | No — dynamic import, often never fetched |
| Critical path total | ≈ 81 kB | Budget: 100 kB |
The number doing the work in that table is the 55 kB that is not there. The Supabase client is never statically imported. It is reached through an accessor that resolves the module the first time anybody needs a live query:
let _client = null;
// ~55 kB gz. Never on the critical path, and on the normal path
// never fetched at all — the snapshot answers before anything
// asks for a database.
window.getSupabase = async function getSupabase() {
if (_client) return _client;
const { createClient } = await import('./supabase-client.js');
_client = createClient(window.SUPABASE_CONFIG.url,
window.SUPABASE_CONFIG.publishableKey);
return _client;
};
When the snapshot answers — the normal path — that import() never fires. The bytes are not deferred, they are not requested. Deferred and never-loaded look identical in a waterfall right up until somebody needs the fallback.
The build emits three chunks rather than one, via manualChunks. React sits in its own chunk with its own content hash, so a change to app.jsx invalidates 32 kB, not 77. Deploys are frequent; React is not. Those hashes are only worth having because of the cache headers behind it — a fingerprinted chunk served with revalidation headers is one you paid for and did not use.
The budgets are declared in project.manifest.json and enforced by npm run guard. This matters more than the numbers. A budget in a document is a preference; a budget that fails the build is a constraint. Regressions in a codebase without a guard all arrive the same way: one reasonable-looking static import, added by someone with no way of knowing what it cost, reviewed by someone with no way of measuring it. The guard is the only participant in that conversation holding a number.
What is still wrong: the fonts
The fonts are third-party and render-blocking, and they undermine everything above.
The page requests from fonts.googleapis.com. Before the first pixel, the browser pays a DNS lookup, a TLS handshake and a round-trip to a host it has never spoken to this session. A boot sequence designed to owe the network nothing still waits on someone else's CDN. Self-hosting the faces as woff2 deletes the entire chain. It has not been done. That is the honest status.
What has been done is an audit of what was requested against what was used. Ten faces were being fetched. Five were in use.
- Jersey 10 was fetched twice, from two different origins — two downloads of identical glyphs.
- Inter 700, 800 and 900 were requested and never used by a single rule.
- Inter 300 was used by two rules and not requested, so the browser was synthesising it from another weight. Nobody noticed, because a synthesised light weight looks approximately right, and approximately right is how this kind of bug survives a year.
- JetBrains Mono 500, 600 and 700 were dead.
Ten faces down to five is a real saving, and it is not the fix. The fix is removing the third-party origin entirely. Until then the site has a first frame independent of the network sitting behind a font request that is not.
When this pattern does not apply
It works here because the placeholder is meaningful. The procedurally generated wall is a real city: structure, depth, colour, geometry. You can pan it and zoom it — see the wall this paints for how that stays at 60fps. The enrichment adds truth to something already worth looking at.
A skeleton shimmer is not the same thing. It is an admission that you have nothing, drawn to feel like progress. It carries no information, and the user reads it as waiting however it animates.
There is a version of this that is actively worse than a spinner: grey boxes that reshuffle. If the placeholder has the wrong number of items, the wrong sizes or the wrong arrangement, the enriched render becomes a layout jump. The eye has already started tracking something, and you move it. Waiting on a blank screen is neutral; being shown a layout and then having it rearranged under your cursor is a small betrayal, and it is what people mean when they say a site feels janky.
So the test before reaching for this is one question: if the network never answers, is what I painted still a legitimate thing to look at? If yes, paint it first and treat the server as an enhancement. If no — if what you would paint is a lie about the shape of the real answer — you do not have a first frame, you have a guess, and you should wait.
Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.
Arthis