Status: Beta - Core features complete, seeking real-world feedback
Demo: firsttx-playground.vercel.app
Source: github.com/joseph0926/firsttx
I am not a native English speaker, so I used a translation tool.
The Context That Matters
SSR and RSC dominate the conversation right now. Next.js, Remix, Astro—the narrative is clear.
But I kept running into contexts where SSR wasn't an option, internal tools where SEO is irrelevant, apps requiring rich offline capability, teams with large CSR codebases, architectures where server complexity is a deal-breaker.
In these contexts, CSR is often the right choice. But you still pay the cost: blank screens on every revisit.
The question became, could we address this specific weakness while keeping CSR's strengths intact?
The Problem Space
When you revisit a CSR app,
User clicks link
-> Blank screen (~1-2 seconds)
-> JavaScript loads
-> React mounts
-> API fetch
-> Finally, content
Even if the data barely changed. Even if they were just here 30 seconds ago.
SSR solves this for first visits. But CSR's strength—rich client state, offline capability, architectural simplicity—comes at this cost: every visit feels like a first visit.
I wanted to challenge that assumption.
The Approach: Three Independent Layers
Rather than one monolithic solution, I explored whether three focused layers could work together.
1. Render Layer (Prepaint)
The idea: What if we could replay the last visual state instantly?
Not "cache the data" - cache the entire rendered output. DOM, styles, everything. Then inject it before JavaScript even loads.
If we can show something in ~0ms, the blank screen disappears.
2. Data Layer (Local-First)
The idea: How do we keep client state and server state synchronized without boilerplate?
IndexedDB exists, but the synchronization story is fragmented. Every team writes their own sync logic.
If we make IndexedDB the source of truth and sync in the background, we might get offline capability and instant reads as side effects.
3. Execution Layer (Tx)
The idea: What happens when optimistic updates fail?
Partial rollbacks are brittle. Rolling back step 2 while step 1 remains creates inconsistency.
If we treat UI updates as transactions with automatic rollback, we might make optimistic updates safe by default.
Layer 1: Prepaint - The Time Machine
❌ Before prepaint | ✅ After prepaint |
![]() |
![]() |
Slow 4G: Blank screen exposed | Slow 4G: Instant restore |
The Core Concept
Before the user leaves the page, we capture the exact DOM structure, all computed styles, and the current route. Store it in IndexedDB.
On the next visit, an inline boot script (~1.7KB) runs before the main bundle.
The boot script does three things,
- Reads the snapshot from IndexedDB
- Checks if it's still valid (7-day TTL)
- Injects it into
#root
before React loads
The user sees the last UI state in ~0ms.
// Simplified version - actual implementation in TypeScript
async function boot() {
const snapshot = await getSnapshot(currentRoute);
if (snapshot && !isExpired(snapshot)) {
document.getElementById('root').innerHTML = snapshot.body;
injectStyles(snapshot.styles);
markAsPrepainted();
}
}
When React Takes Over
Then React loads and tries to hydrate. But here's where it gets interesting: hydration might fail.
CSS-in-JS with dynamic props, timestamps, random IDs—these cause mismatches.
Current results: ~80-85% hydration success. The remaining 15-20% fail due to dynamic content (timestamps, CSS-in-JS props, random IDs).
When this happens, the system falls back to a clean render wrapped in ViewTransition. In testing, most users don't notice the switch—but this is still an area that needs validation in diverse environments.
hydrateRoot(container, element, {
onRecoverableError: (error) => {
// Hydration failed - transition smoothly to clean render
if ('startViewTransition' in document) {
document.startViewTransition(() => {
container.innerHTML = '';
createRoot(container).render(element);
});
}
}
});
The "stale" snapshot transitions smoothly to the "fresh" render. The user might not even notice.
An Unexpected Edge Case
During early testing with React Router and TanStack Router, I discovered a problem: some routers append siblings to #root
, violating the single-child contract that hydration expects.
The solution became a "Root Guard" - a MutationObserver that watches for violations
function installRootGuard(container) {
const observer = new MutationObserver(() => {
if (container.children.length !== 1) {
// Router added a sibling - reset cleanly
container.innerHTML = '';
createRoot(container).render(app);
}
});
observer.observe(container, { childList: true });
}
It feels like defensive programming, but it made the system robust against framework quirks.
Layer 2: Local-First - The Sync Manager
The Synchronization Problem
IndexedDB is asynchronous. React state is synchronous. This impedance mismatch creates boilerplate
// Traditional approach - manual wiring everywhere
const [data, setData] = useState(null);
useEffect(() => {
// Load from IndexedDB
getFromDB('cart').then(setData);
// Subscribe to changes
return subscribeToChanges('cart', setData);
}, []);
useEffect(() => {
// Sync with server
if (shouldSync) {
fetch('/api/cart')
.then(r => r.json())
.then(serverData => {
setData(serverData);
saveToDB('cart', serverData);
});
}
}, [shouldSync]);
// This pattern repeated in every component that needed persistent data.
The question: could we abstract this away?
The Solution: useSyncedModel
The approach I explored, treat IndexedDB as the source of truth, use an in-memory cache for synchronous access, and leverage useSyncExternalStore
for React integration.
// Define once
const CartModel = defineModel('cart', {
schema: z.object({
items: z.array(z.object({
id: z.string(),
qty: z.number()
}))
}),
ttl: 5 * 60 * 1000 // 5 minutes
});
// Use anywhere
function CartPage() {
const { data, patch, sync, isSyncing } = useSyncedModel(
CartModel,
() => fetch('/api/cart').then(r => r.json())
);
if (!data) return <Skeleton />;
return <CartView items={data.items} />;
}
The hook handles initial load from IndexedDB (async), synchronous reads via memory cache, staleness detection (TTL-based), automatic background sync, and race condition prevention.
The Critical Timing Fix
Early versions had a bug: the hook would check isStale
immediately on mount, before IndexedDB finished loading. This caused every mount to trigger a sync, defeating the purpose.
The fix required waiting for the history to load
useEffect(() => {
const mode = options?.syncOnMount ?? 'stale';
if (mode === 'never') return;
(async () => {
// Wait for IndexedDB to load
const history = await model.getHistory();
const shouldSync = mode === 'always' ||
(mode === 'stale' && history.isStale);
if (shouldSync) await sync();
})();
}, []);
This single change made the difference between "always fetching" and "fetching only when needed".
The Prepaint Synergy
When Prepaint and Local-First work together,
[Revisit]
1. Prepaint shows snapshot (yesterday's data) - ~0ms
2. React mounts
3. useSyncedModel checks: "Is this stale?"
4. Yes (24 hours old) → sync in background
5. ViewTransition from stale → fresh
The user sees something immediately, then a smooth update to fresh data.
Layer 3: Tx - The Safety Net
The Optimistic Update Problem
Optimistic updates are powerful but fragile
// Optimistic update
setCart(prev => [...prev, newItem]);
try {
await fetch('/api/cart', { method: 'POST', body: newItem });
} catch (error) {
// Rollback... but how?
setCart(prev => prev.filter(i => i.id !== newItem.id));
// What if the state changed in the meantime?
}
Partial rollbacks create inconsistency. If step 2 of a 3-step update fails, rolling back step 2 while leaving steps 1 and 3 is dangerous.
The Transaction Approach
I explored treating UI updates as atomic transactions
const tx = startTransaction({ transition: true });
await tx.run(
// Optimistic update
() => CartModel.patch(draft => draft.items.push(newItem)),
{
// Automatic rollback if anything fails
compensate: () => CartModel.patch(draft => draft.items.pop())
}
);
await tx.run(
// Server request
() => fetch('/api/cart', { method: 'POST', body: newItem })
);
await tx.commit();
If the server request fails, the transaction automatically runs compensations in reverse order (LIFO). If compensation itself fails, the transaction enters a failed
state and throws CompensationFailedError
.
The Timeout Problem
Early implementations had an edge case: if the server hung, the transaction would wait indefinitely.
The solution, Promise.race
with a timeout
async run(fn, options) {
const execution = fn();
if (this.options.timeout) {
await Promise.race([
execution,
this.createTimeoutPromise()
]);
}
// If timeout wins, rollback starts automatically
}
When timeout occurs, completed steps roll back, and the user sees their original state restored via ViewTransition.
The React Hook Version
For React contexts, I created useTx
- a higher-level API
const { mutate, isPending, error } = useTx({
optimistic: async (item) => {
await CartModel.patch(draft => draft.items.push(item));
},
rollback: async (item) => {
await CartModel.patch(draft => {
draft.items = draft.items.filter(i => i.id !== item.id);
});
},
request: async (item) => {
return fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(item)
});
},
retry: { maxAttempts: 3 }
});
It handles transaction lifecycle, state management, and unmount safety automatically.
What I've Learned
Performance Characteristics
Measured on slow 3G (Chrome DevTools throttling),
- BlankScreenTime (revisit): ~0ms (target: ~0ms)
- PrepaintTime: ~15ms (target: <20ms)
- HydrationSuccess: 80-85% (target: >80%)
- BootScriptSize: 1.74KB (target: <2KB)
- TxRollbackTime: ~85ms (target: <100ms)
Trade-offs Discovered
What works well
- Revisit experience feels instant
- Automatic sync eliminates boilerplate (~90% reduction)
- Atomic rollbacks make optimistic updates safe
- Graceful degradation (no ViewTransition? Still works)
What's challenging
- CSS-in-JS dynamic props cause hydration mismatches (~15-20%)
- Fixed 7-day TTL for snapshots (should be configurable)
- Retry strategy is simple (fixed delay, no exponential backoff)
- E2E testing is incomplete
Unexpected benefits
- Offline capability emerged naturally from Local-First
- ViewTransition made failures nearly invisible
- Modular design allows using each layer independently
The Beta Reality
This is beta software. What that means,
Implemented and tested
- All core features
- Unit + integration tests
- 9 playground scenarios
- Browser compatibility (Chrome 111+, Firefox, Safari with fallbacks)
Still needed
- E2E test suite (Playwright)
- Production case studies
- Feedback from diverse environments
- API refinement based on real usage
Try It Yourself
Quick Start
npm install @firsttx/prepaint @firsttx/local-first @firsttx/tx
1. Vite plugin
// vite.config.ts
import { firstTx } from '@firsttx/prepaint/plugin/vite';
export default defineConfig({
plugins: [firstTx()]
});
2. Entry point
// main.tsx
import { createFirstTxRoot } from '@firsttx/prepaint';
createFirstTxRoot(
document.getElementById('root')!,
<App />
);
3. Define a model
import { defineModel } from '@firsttx/local-first';
import { z } from 'zod';
const CartModel = defineModel('cart', {
schema: z.object({
items: z.array(z.object({
id: z.string(),
name: z.string(),
qty: z.number()
}))
}),
ttl: 5 * 60 * 1000
});
4. Use it
import { useSyncedModel } from '@firsttx/local-first';
function CartPage() {
const { data: cart, patch } = useSyncedModel(
CartModel,
() => fetch('/api/cart').then(r => r.json())
);
if (!cart) return <Skeleton />;
return <div>{cart.items.length} items</div>;
}
Live demo: firsttx-playground.vercel.app
Try visiting the playground twice - the second visit should feel instant.
What I'm Looking For
This is beta. I need feedback from contexts I haven't tested.
Technical questions
- Browser quirks - Have you seen hydration patterns that might break this?
- Router compatibility - Does this work with your routing setup?
-
API ergonomics - Does
syncOnMount: 'stale'
feel natural? Should it be different?
Use case questions
- Would this solve a problem in your apps?
- What's missing that would make this useful for you?
- Are there edge cases in your domain I haven't considered?
Philosophical questions
- Is "making CSR feel like SSR" even the right goal?
- Should these layers be separate packages or unified?
- Where does this fit in the SSR/RSC-dominated landscape?
Final Thoughts
This isn't about replacing SSR—SSR excels at first-visit performance.
This is about a specific problem: blank screens on CSR revisits.
The three-layer approach might not be the final answer. But the exploration has already revealed some interesting patterns, instant visual feedback is achievable without servers, sync boilerplate can be dramatically reduced, and atomic rollbacks make optimistic updates safer.
Whether FirstTx itself becomes the solution or inspires better approaches, I'm curious to see what real-world usage reveals.
If this resonates with challenges you've faced, I'd genuinely appreciate your feedback—positive or critical.
Links
- Demo: firsttx-playground.vercel.app
- Source: github.com/joseph0926/firsttx
- Docs: README
If you try it, I'd genuinely appreciate hearing what breaks, what feels weird, or what would make this more useful for your context.
Top comments (0)