Client Architecture
9.1 Data Fetching
Modern clients separate data fetching from UI rendering. Use a data-fetching
layer, not raw fetch() calls scattered in components.
REST calls
- Use a centralized HTTP client (axios, httpx, ky).
- Define typed request functions per domain.
- Use a query library for cache management.
// Centralized HTTP client — not scattered fetch() calls
const api = axios.create({ baseURL: "/api/v1" });
export const getUser = (id: string) => api.get<User>(`/users/${id}`);
GraphQL queries
- Use
gqlwithhttpxor Strawberry client for GraphQL calls. - Cache responses server-side (Redis) keyed by query hash.
- Optimistic updates: assume success, update cache immediately, roll back on error.
gRPC clients
- Generated stubs from
.protofiles. - Generated stubs from
.protofiles withgrpcio-tools. - Used in Python backends, mobile (Swift/Kotlin), and via grpc-web proxy for browsers.
WebSocket subscriptions
- Connect once on app load.
- Subscribe to channels/topics.
- Update local state/cache on each received event.
9.2 State Management
Separate server state from UI state:
| Type | What it is | Tools (Python) |
|---|---|---|
| Server state | Data from API, cached on server | Redis, lru_cache, cachetools |
| Session state | Per-user auth, preferences | Signed cookies, Redis sessions |
| URL state | Filters, pagination in URL | Query params parsed by Pydantic |
| Persistent state | Long-lived user settings | Database (PostgreSQL, SQLite) |
Keep API responses in a cache layer (Redis / in-memory) with TTL-based invalidation. Do not query the database on every request when the data is read-heavy.
Client cache
Cache API responses in memory to avoid redundant requests.
- TTL: revalidate after N seconds.
- Stale-while-revalidate: show stale data immediately, fetch fresh in background.
- Deduplicate: if the same query fires twice simultaneously, fire one request and share the result.
Server state sync
After a mutation, invalidate related queries to trigger a refetch:
User clicks "Save"
→ PATCH /profile
→ on success → invalidate ["profile", userId]
→ profile query refetches automatically
9.3 Network Optimization
Request batching
Group multiple related requests into one.
- GraphQL: one query for all needed data.
- REST:
/users/batch?ids=1,2,3instead of three separate calls. - Reduces round-trips and total latency.
Caching layers
| Layer | Scope | Persistence |
|---|---|---|
In-memory (lru_cache) |
Process lifetime | Lost on restart |
| Redis | Shared / cluster | Across restarts |
| HTTP cache (CDN / proxy) | Per URL | Per Cache-Control |
Debounce and throttle
- Debounce: wait N ms after last call before firing. Use for search-as-you-type (avoid API call on every keystroke).
- Throttle: fire at most once per N ms. Use for scroll events and window resize.
import asyncio
from typing import Callable
_debounce_tasks: dict[str, asyncio.Task[None]] = {}
async def debounced_search(query: str, delay: float = 0.3) -> list[dict]:
"""Cancel previous pending search and schedule a new one after delay."""
if "search" in _debounce_tasks:
_debounce_tasks["search"].cancel()
await asyncio.sleep(delay)
return await user_service.search(query)
Key Rules
- Never call the DB directly from a route handler. Use a service / repository layer.
- Do not store API data in global variables — use a cache with TTL (Redis,
cachetools). - Invalidate cache after mutations; do not manually patch nested data structures.
- Debounce user-input-driven search on the server. Throttle event-driven tasks.
- Keep the number of active WebSocket connections minimal; close on disconnect.