DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Resilient Frontend Data Layer with Suspense, Caching, and Optimistic UI

Building a Resilient Frontend Data Layer with Suspense, Caching, and Optimistic UI

Building a Resilient Frontend Data Layer with Suspense, Caching, and Optimistic UI

In modern frontend development, the UI often feels fast and fluid, but beneath the hood there’s a web of data fetching, caching, and synchronization that can trip you up. This guide shows you a practical, end-to-end approach to architecting a resilient frontend data layer. You’ll learn patterns that work well with React, but the ideas translate to other frameworks too. We’ll cover real code, concrete decisions, and a step-by-step path from project setup to a polished user experience.

Goals and overview

  • Create a robust data layer that handles loading, errors, and retries gracefully.
  • Use a layered approach: server state, client cache, and UI state.
  • Implement optimistic updates for responsive UX without compromising correctness.
  • Leverage Suspense-like patterns (or equivalent) and a small custom cache to avoid over-fetching.
  • Provide clear strategies for error boundaries, caching policies, and eviction. ### 1) Plan your data domains

Before writing code, map what data your UI consumes and how it changes.

  • UI data types
    • User data: profiles, preferences
    • Feeds: lists of items with pagination
    • Settings: feature flags, toggles
  • Mutation patterns
    • Create/update/delete operations
    • Local edits vs. server edits
  • Consistency guarantees
    • Stale-while-revalidate
    • Strong consistency for critical actions (e.g., voting)

Illustrative decision table:

  • Use server state for freshness, client cache for speed.
  • Accept eventual consistency for non-critical UI; enforce immediate consistency for critical actions. ### 2) Set up a minimal data layer

We’ll build a small, opinionated data layer that sits on top of fetch and caches results. It’s framework-agnostic in spirit but shown with React in mind.

Key components:

  • ApiClient: wraps fetch with common behaviors (baseURL, headers, error handling)
  • Cache: a simple in-memory map with eviction and versioning
  • Resource hooks: loadResource, useResource or a Suspense-like wrapper
  • Mutation helpers: mutateResource, optimistic updates

Code: ApiClient and Cache

// apiClient.js
export class ApiClient {
  constructor(baseURL, defaultHeaders = {}) {
    this.baseURL = baseURL;
    this.defaultHeaders = defaultHeaders;
  }

  async request(path, options = {}) {
    const url = `${this.baseURL}${path}`;
    const headers = { ...this.defaultHeaders, ...options.headers };
    const res = await fetch(url, { ...options, headers });
    const contentType = res.headers.get('content-type') || '';
    const isJson = contentType.includes('application/json');
    const data = isJson ? await res.json() : await res.text();
    if (!res.ok) {
      const error = new Error(res.statusText || 'Request failed');
      error.status = res.status;
      error.data = data;
      throw error;
    }
    return data;
  }

  get(path, opts) { return this.request(path, { ...opts, method: 'GET' }); }
  post(path, body, opts) { return this.request(path, { ...opts, method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', ...opts?.headers } }); }
  put(path, body, opts) { return this.request(path, { ...opts, method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', ...opts?.headers } }); }
  delete(path, opts) { return this.request(path, { ...opts, method: 'DELETE' }); }
}
Enter fullscreen mode Exit fullscreen mode
// cache.js
export class Cache {
  constructor(maxEntries = 1000) {
    this.store = new Map();
    this.maxEntries = maxEntries;
  }

  _evictIfNeeded() {
    if (this.store.size <= this.maxEntries) return;
    // Simple eviction: remove oldest entry
    const oldestKey = this.store.keys().next().value;
    this.store.delete(oldestKey);
  }

  get(key) {
    const entry = this.store.get(key);
    if (!entry) return undefined;
    const { value, expiry } = entry;
    if (expiry && expiry < Date.now()) {
      this.store.delete(key);
      return undefined;
    }
    return value;
  }

  set(key, value, ttlMs) {
    const expiry = ttlMs ? Date.now() + ttlMs : undefined;
    this.store.set(key, { value, expiry });
    this._evictIfNeeded();
  }

  delete(key) { this.store.delete(key); }

  clear() { this.store.clear(); }
}
Enter fullscreen mode Exit fullscreen mode

3) Fetch with caching and loading states

We’ll implement a simple resource loader that caches results and provides loading/error state. This mirrors the idea of “server state” management.

Code: resource hook (React)

// useResource.js
import { useEffect, useState, useRef } from 'react';
import { ApiClient } from './apiClient';
import { Cache } from './cache';

const api = new ApiClient('https://api.example.com', { 'X-Custom-Auth': 'token' });
const cache = new Cache(500);

export function useResource(key, fetcher, { ttl = 1000 * 60 } = {}) {
  const [state, setState] = useState({ status: 'idle', data: undefined, error: undefined });
  const startedRef = useRef(false);

  useEffect(() => {
    let mounted = true;

    // Check cache first
    const cached = cache.get(key);
    if (cached !== undefined) {
      setState({ status: 'loaded', data: cached, error: undefined });
    }

    // Load from network
    const load = async () => {
      setState((s) => ({ ...s, status: 'loading' }));
      try {
        const data = await fetcher(api);
        if (!mounted) return;
        cache.set(key, data, ttl);
        setState({ status: 'loaded', data, error: undefined });
      } catch (err) {
        if (!mounted) return;
        setState({ status: 'error', data: undefined, error: err });
      }
    };

    if (!startedRef.current) {
      startedRef.current = true;
      load();
    }

    return () => { mounted = false; };
  }, [key, ttl, fetcher]);

  const refresh = async () => {
    try {
      setState((s) => ({ ...s, status: 'loading' }));
      const data = await fetcher(api);
      cache.set(key, data, ttl);
      setState({ status: 'loaded', data, error: undefined });
    } catch (err) {
      setState({ status: 'error', data: undefined, error: err });
    }
  };

  return { ...state, refresh };
}
Enter fullscreen mode Exit fullscreen mode

Example fetcher for a user:

// fetchers.js
export const fetchUser = (api, userId) => async (client) => {
  // you can add query params, headers, etc.
  return client.get(`/users/${userId}`);
};

export const fetchFeed = (userId, page = 1) => async (client) => {
  return client.get(`/users/${userId}/feed?page=${page}`);
};
Enter fullscreen mode Exit fullscreen mode

Usage in a component:

import React from 'react';
import { useResource } from './useResource';
import { fetchUser } from './fetchers';

function UserProfile({ userId }) {
  const { data: user, status, error, refresh } = useResource(
    `user:${userId}`,
    fetchUser(null, userId) // the fetcher will receive the api client internally
  );

  if (status === 'loading' || status === 'idle') return <div>Loading user...</div>;
  if (status === 'error') return <div>Error: {error?.message ?? 'Unknown'}</div>;

  return (
    <section>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      <button onClick={refresh}>Refresh</button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: The fetcher pattern above is simplified. In a real app you’d pass the API instance and any required parameters more cleanly, possibly via context or a dedicated data manager.

4) Implement optimistic UI for mutations

Optimistic updates feel instantaneous, but you must handle rollback if the server rejects.

Example: liking a post

// mutations.js
export async function toggleLikePost(api, postId, currentLiked) {
  const path = `/posts/${postId}/like`;
  const method = currentLiked ? 'DELETE' : 'POST';
  return api.request(path, { method });
}
Enter fullscreen mode Exit fullscreen mode

Usage with optimistic update:

import { useState } from 'react';
import { ApiClient } from './apiClient';

const api = new ApiClient('https://api.example.com');

export function useOptimisticLike(postId, initialLiked) {
  const [liked, setLiked] = useState(initialLiked);
  const [status, setStatus] = useState('idle');

  const toggle = async () => {
    const previous = liked;
    // optimistic update
    setLiked((v) => !v);
    setStatus('mutating');
    try {
      await api.request(`/posts/${postId}/like`, { method: previous ? 'DELETE' : 'POST' });
      setStatus('idle');
    } catch (e) {
      // rollback on error
      setLiked(previous);
      setStatus('error');
    }
  };

  return { liked, status, toggle };
}
Enter fullscreen mode Exit fullscreen mode

ui integration:

function PostActions({ post }) {
  const { liked, status, toggle } = useOptimisticLike(post.id, post.liked);
  return (
    <button onClick={toggle} disabled={status === 'mutating'}>
      {liked ? 'Unlike' : 'Like'} {status === 'mutating' ? '' : ''}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tips:

  • Always show a fallback UI during mutation (e.g., a spinner or disabled button).
  • Store the previous state to rollback if the mutation fails.
  • For non-idempotent actions, consider server-side deduplication to avoid duplicates.

    5) Error handling and retry strategies

  • Global error boundary for UI-level crashes.

  • Network errors should trigger a retry with backoff.

  • Distinguish transient vs. persistent errors.

Simple retry helper:

export async function withRetry(fn, { retries = 3, delayMs = 500 } = {}) {
  let lastError;
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (e) {
      lastError = e;
      await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
    }
  }
  throw lastError;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const data = await withRetry(() => api.get('/data'), { retries: 4, delayMs: 400 });
Enter fullscreen mode Exit fullscreen mode

UI pattern:

  • Show a non-blocking retry button in the error state.
  • Offer a "Retry all" action if multiple resources failed.

    6) Pagination, infinite scroll, and cache coherence

  • Cache pages independently with a stable key: feed:userId:page:2.

  • Invalidate or refresh pages when a mutation affects the data (e.g., new post in feed).

Example: prefetch next page after loading current

// in a feed component
useEffect(() => {
  // after loading page N, prefetch N+1
  if (pageLoaded) {
    fetcher(api, userId, page + 1).then((data) => {
      cache.set(`feed:${userId}:page:${page + 1}`, data, ttl);
    });
  }
}, [page, pageLoaded]);
Enter fullscreen mode Exit fullscreen mode

Infinite scroll pattern:

  • Maintain a small cached page cache.
  • Stop prefetching when user stops scrolling or data is exhausted. ### 7) Server-Side rendering considerations

If you’re rendering on the server (Next.js, Remix), hydrate with initial data to avoid a loading state.

  • Use server-provided initial data to seed the cache.
  • Revalidate on the client after hydration to ensure freshness.

A practical approach:

  • On the server, fetch and embed data in the HTML as initial state.
  • On the client, initialize the cache with that state and proceed as usual.

    8) Observability: logging, metrics, and user experience signals

  • Capture latency, cache hit rate, and mutation success rate.

  • Instrument error rates per endpoint.

  • Show user-friendly indicators: skeletons for loading, inline toasts for errors.

Simple telemetry idea:

  • Collect metrics in a central store (e.g., window.__telemetry or a tiny analytics hook).
  • Log cache hits/misses and mutation outcomes.

Example minimal hook:

export function useTelemetry() {
  const log = (event, payload) => {
    // replace with real analytics in production
    console.debug('[telemetry]', event, payload);
  };
  return { log };
}
Enter fullscreen mode Exit fullscreen mode

9) Accessibility and UX considerations

  • Ensure loading indicators are announced by screen readers (aria-live regions).
  • Provide clear error messages with actionable steps (Retry, Contact support).
  • Maintain visual stability during data updates to avoid jank.

    10) Deployment and performance tips

  • Keep cache TTLs sane; too long can serve stale data, too short increases fetches.

  • Use HTTP cache headers when possible for server responses to leverage CDNs.

  • Prefer optimistic UI for high-signal interactions (likes, saves) and conservative updates for critical data (account changes, payments).

    11) A small, end-to-end example app

What you’ll build:

  • A user profile page with:
    • Cached user data
    • A feed list with pagination
    • A “follow” mutation with optimistic update
    • Global error boundary and retry UI

Folder layout (conceptual):

  • src/
    • apiClient.js
    • cache.js
    • fetchers.js
    • useResource.js
    • mutations.js
    • components/
    • UserProfile.jsx
    • Feed.jsx
    • pages/
    • UserPage.jsx

High-level flow:

  • On UserPage mount, useResource loads user data (checks cache, fetches if needed).
  • Feed component uses a similar hook to load and paginate data.
  • Follow button uses optimistic update via a small mutation helper.

Code excerpts (tight integration sketch):

// UserPage.jsx
import React from 'react';
import { useResource } from '../useResource';
import { fetchUser } from '../fetchers';
import { Feed } from './Feed';

export function UserPage({ userId }) {
  const { data: user, status: userStatus } = useResource(
    `user:${userId}`,
    fetchUser(null, userId)
  );

  if (userStatus === 'loading') return <div>Loading user...</div>;
  if (userStatus === 'error') return <div>Error loading user</div>;

  return (
    <section>
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      <Feed userId={userId} />
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Feed.jsx
import React from 'react';
import { useResource } from '../useResource';
import { fetchFeed } from '../fetchers';

export function Feed({ userId }) {
  const { data: items, status, refresh } = useResource(
    `feed:${userId}:page:1`,
    fetchFeed(userId, 1)
  );

  if (status === 'loading') return <div>Loading feed...</div>;
  if (status === 'error') return <div>Failed to load feed</div>;

  return (
    <div>
      {items?.length ? (
        items.map((it) => (
          <div key={it.id} className="post">
            <h4>{it.title}</h4>
            <p>{it.summary}</p>
          </div>
        ))
      ) : (
        <div>No posts yet.</div>
      )}
      <button onClick={refresh}>Refresh Feed</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a compact blueprint. In a real project, you’d flesh out error boundaries, a shared context for the API client, and more sophisticated cache eviction policies.

12) Next steps

  • Start small: implement ApiClient and Cache, then a simple useResource hook for one endpoint.
  • Introduce an optimistic mutation for a single action (e.g., toggle a bookmark) to validate the mental model.
  • Add an error boundary, and a simple retry UI to handle transient failures.
  • Gradually expand to pagination, prefetching, and Suspense-like orchestration if your framework supports it.

If you’d like, I can tailor this to a specific framework (React, Vue, Svelte) and wiring (Vite, Next.js, Remix) you’re using, and provide a repo scaffold with these patterns wired up. Would you prefer a React + Vite setup or a Next.js-based example?

-

Rizwan Saleem | https://rizwansaleem.co

Sources

Top comments (0)