DEV Community

joseph0926
joseph0926

Posted on

Building FirstTx: A Different Approach to CSR Performance

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
Enter fullscreen mode Exit fullscreen mode

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,

  1. Reads the snapshot from IndexedDB
  2. Checks if it's still valid (7-day TTL)
  3. 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
      });
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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();
  })();
}, []);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

1. Vite plugin

// vite.config.ts
import { firstTx } from '@firsttx/prepaint/plugin/vite';

export default defineConfig({
  plugins: [firstTx()]
});
Enter fullscreen mode Exit fullscreen mode

2. Entry point

// main.tsx
import { createFirstTxRoot } from '@firsttx/prepaint';

createFirstTxRoot(
  document.getElementById('root')!,
  <App />
);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Browser quirks - Have you seen hydration patterns that might break this?
  2. Router compatibility - Does this work with your routing setup?
  3. API ergonomics - Does syncOnMount: 'stale' feel natural? Should it be different?

Use case questions

  1. Would this solve a problem in your apps?
  2. What's missing that would make this useful for you?
  3. Are there edge cases in your domain I haven't considered?

Philosophical questions

  1. Is "making CSR feel like SSR" even the right goal?
  2. Should these layers be separate packages or unified?
  3. 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

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)