Architecture

Data Loading

Strategies for fast, reliable data fetching.
Fast first paint, smooth updates.

SSR vs SPA

Choose the right rendering strategy based on your requirements.

Server-Side Rendering

Use when:

  • SEO is critical (crawlers need content)
  • First contentful paint matters
  • Data is mostly static or cacheable
  • Users have slow devices

Single Page App

Use when:

  • Behind authentication (no SEO needed)
  • Highly interactive dashboards
  • Real-time data updates
  • Rich client-side interactions
🎯

SSR for important content

If you need SSR (for SEO or initial load performance), render important content on the server. Less critical data can be loaded client-side.

Trigger Fetching Early

Start data fetching as soon as possible — don't wait for components to render.

🚀

Use route loaders

React Router and TanStack Router support loaders that fetch data during navigation, before the component renders.

⏳

Don't block the render

Start the fetch early but defer non-critical data. Promises are handled automatically — show the page immediately with skeletons.

📦

Batch database calls

Group related queries when possible to reduce round trips. But if a query is heavy, keep it separate to avoid blocking.

Route Loader with TanStack Query
Prefetch with TanStack Query during navigation. Promises are automatically deferred — no defer() wrapper needed.

Stale-While-Revalidate

Show cached data immediately, fetch fresh data in the background.

Dashboard Stats

Updated 2 min ago

User count: 1,234

Showing cached data
⚡

Show stale data first

Display cached data immediately for instant perceived performance. Users see content right away.

🔄

Show refetch indicator

When fetching fresh data, show a subtle spinner or indicator so users know an update is coming.

✨

Smooth updates

When new data arrives, update the UI smoothly without jarring layout shifts.

Stale-While-Revalidate Pattern

Spin Delay

Prevent flickering skeletons for fast responses.

Data Loaded!

This content was fetched from the server.

⏱️

Wait before showing skeleton

Only show loading UI if the request takes longer than ~200ms. Fast responses don't need skeletons.

🚫

Avoid UI flicker

A skeleton that appears and disappears in 50ms is jarring. Better to show nothing for quick loads.

Spin Delay Implementation
spin-delay library or a simple timeout prevents skeleton flicker.

Error Handling

Show errors inline, not as toasts. Make them recoverable.

User Profile

Alice Johnson

[email protected]

✓ Error shown inline, not as toast
🎯

Inline error states

Show errors where the data was supposed to appear. Toasts get lost and overwhelm users with multiple failures.

🔄

Always offer retry

Include a retry button so users can recover without refreshing the entire page.

📊

Report to observability

Log errors to your monitoring tools (Sentry, DataDog, etc.) so you can track and fix issues.

Error State Pattern
Inline errors are contextual and don't overwhelm users like toasts.

Realtime Data

Choose the right approach based on how "real" your realtime needs to be.

WebSocket

Use when:

  • Updates need to be instant (<1 second)
  • High frequency updates (chat, live scores)
  • Bi-directional communication needed
  • Many small messages over time

Interval HTTP

Use when:

  • Updates every 30+ seconds is acceptable
  • Simpler infrastructure needed
  • Server doesn't support WebSocket
  • Lower frequency dashboard updates
⚡

WebSocket for true realtime

HTTP requests have overhead (headers, connection setup). WebSocket maintains a persistent connection — much faster for frequent small updates.

🔄

Interval polling for 30s+ updates

If data only needs to refresh every 30 seconds or more, start with interval HTTP polling. Simpler to implement and debug.

📊

Consider the trade-offs

WebSocket requires more infrastructure (connection management, reconnection logic). Don't over-engineer if polling works fine.

Cache Persistence

Not all data should be persisted. Some data must always be fresh.

Don't Persist

Data that must always be accurate:

  • ✗Bank balance — Stale balance could cause overdrafts or confusion
  • ✗Stock prices — Users make decisions based on current prices
  • ✗Inventory count — Could lead to overselling

OK to Persist

Historical or reference data:

  • ✓Transaction history — Past records don't change
  • ✓User preferences — Settings rarely change
  • ✓Static content — Blog posts, documentation
🏦

Think about the consequences

Ask: 'What happens if users see stale data?' If the answer is 'they might make bad decisions' — don't persist.

🕐

Show 'last fetched' timestamp

When showing persisted data, display when it was last fetched. Users need to know if they're seeing data from 5 minutes or 5 days ago.

📴

Offline-first is an exception

If your app needs to work offline (like a notes app), persisting cache is required. But revalidate immediately when back online.

⏰

Set appropriate TTL

If you do persist, set a reasonable time-to-live. Stale cache from 3 months ago is probably useless.

Persistence Methods

LocalStorage

~5MB limit

Simple key-value storage. Best for small, string-based data.

  • Synchronous API — can block main thread
  • Only stores strings (requires JSON.stringify)
  • Good for: user preferences, tokens, small cache

IndexedDB

No practical limit

Full database in the browser. Best for structured, large datasets.

  • Asynchronous API — doesn't block UI
  • Supports indexes, transactions, and cursors
  • Good for: offline-first apps, large cache, complex queries

Embedded Database

ElectricSQL, Turso, PGlite, etc.

Full SQL database running in the browser with sync capabilities.

  • Real SQL queries (SQLite-based)
  • Automatic sync with remote database
  • Good for: offline-first apps, local-first architecture, complex data
Persistence Implementation
Choose the right persistence method based on your data size and complexity. LocalStorage for simple needs, IndexedDB for larger cache, embedded databases for full offline-first experiences.

Parsing Data from External Source

Always validate and parse data from APIs or databases to catch issues early.

Safe Parsing with Fallbacks

1Input: Raw API response or database query result
Missing "city"
2Process: Zod (or any) Schema
3Output: Safe Data
Fallback Applied

Without Parsing

What can go wrong:

  • ✗Runtime crashes — Accessing user.name.toUpperCase() when name is undefined
  • ✗Type mismatches — Expecting number, getting string like "42"
  • ✗Silent failures — Missing fields that cause subtle bugs later

With Parsing

Benefits:

  • ✓Early error detection — Catch invalid data at the boundary
  • ✓Type safety — TypeScript knows the exact shape after parsing
  • ✓Graceful handling — Use fallbacks or show meaningful errors
🛡️

Parse at the boundary

Validate data as soon as it enters your application — right after fetch() or database query. Don't let invalid data propagate.

🔄

Use fallback values

For optional fields, provide sensible defaults. role: 'User' is better than role: undefined crashing your UI.

📝

Show meaningful errors

When parsing fails, display a user-friendly message. Log the technical details to your observability tools.

🎯

Keep schemas in sync

When your API changes, update your validation schemas. Zod/Yup schemas are living documentation of your data contracts.

Data Validation with Zod
Zod provides runtime validation with TypeScript type inference. Parse external data to catch issues early.

Quick Reference

SSR — Use for SEO-critical, important content
Loader — Trigger fetches on navigation, not render
Defer — Don't block render, show skeleton first
Batch — Group queries when possible, separate if heavy
SWR — Show stale data, indicate background refresh
Spin Delay — Wait 200ms before showing skeleton
Errors — Inline, not toast. Include retry. Log to observability.
WebSocket — Use for realtime data that needs instant updates
Cache — Don't persist client cache unless app works offline or needs latest data on revisit
Parse — Validate external data with Zod. Use fallbacks or show meaningful errors.