Frontend UX Playbook — Skeletons, Optimistic UI, Lazy Loading, Caching (Svelte/HTML/Tailwind/daisyUI)


This “what it is + how to” cheat‑sheet rounds up the patterns you see at big shops—implemented simply with Svelte, vanilla HTML, Tailwind, and daisyUI.

1) Skeleton Screens

What it is: Placeholder UI that mimics the final layout while data loads. Reduces perceived wait time and layout shift.

When to use: Lists, profile cards, dashboards, anywhere remote data drives the view.

<!-- Svelte: skeleton component -->
<!-- src/lib/SkeletonCard.svelte -->
<script> export let rows = 2; </script>
<div class="flex gap-3 items-start">
  <div class="skeleton w-12 h-12 rounded-lg"></div>
  <div class="flex-1">
    {#each Array(rows) as _}
      <div class="skeleton h-3 my-2"></div>
    {/each}
  </div>
</div>

<style>
  /* If not using daisyUI .skeleton class, a minimal fallback: */
  .skeleton { background: #e6e6e6; border-radius: .5rem; }
  @media (prefers-reduced-motion: no-preference) {
    .skeleton { animation: pulse 1.2s ease-in-out infinite; }
    @keyframes pulse { 0%{opacity:.7}50%{opacity:1}100%{opacity:.7} }
  }
</style>

<!-- Svelte page using {@await} -->
<script>
  import SkeletonCard from '$lib/SkeletonCard.svelte';
  export let dataPromise; // Promise that resolves to items
</script>

{@await dataPromise}
  <SkeletonCard rows={3} />
{:then items}
  {#each items as item}
    <article class="card bg-base-100 shadow p-4">
      <h3 class="text-lg font-semibold">{item.title}</h3>
      <p class="text-sm opacity-80">{item.summary}</p>
    </article>
  {/each}
{:catch e}
  <p class="text-error">Failed: {e.message}</p>
{/await}

<!-- HTML + Tailwind/daisyUI only -->
<div class="skeleton w-48 h-6 mb-2"></div>
<div class="skeleton w-80 h-3 mb-2"></div>
<div class="skeleton w-64 h-3"></div>

Pitfalls: Too many placeholders = visual noise. Size skeletons to match final layout to avoid jumpiness.

2) Optimistic UI

What it is: Update the screen immediately, then reconcile with the server. On error, roll back. Makes apps feel instant.

// Svelte store with optimistic add + rollback
import { writable } from 'svelte/store';
export const todos = writable([]);

export async function addTodoOptimistic(text) {
  const id = crypto.randomUUID();
  const optimistic = { id, text, done: false, _optimistic: true };
  todos.update(t => [optimistic, ...t]);
  try {
    const res = await fetch('/api/todos', {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ id, text })
    });
    if (!res.ok) throw new Error('rejected');
    const saved = await res.json();
    todos.update(t => t.map(x => x.id === id ? { ...saved, _optimistic:false } : x));
  } catch (e) {
    todos.update(t => t.filter(x => x.id !== id)); // rollback
    throw e;
  }
}

<!-- HTML + Tailwind: mark optimistic rows -->
<li class="flex items-center gap-2 data-[optimistic=true]:opacity-60" data-optimistic="true">
  <input type="checkbox" class="checkbox checkbox-sm" disabled />
  <span>New todo (saving…)</span>
</li>

Tips: Use client‑generated IDs to match server responses; style `_optimistic` items (opacity) to set expectations.

3) Lazy Loading (components, images, code)

What it is: Defer loading heavy chunks until they’re needed or visible.

<!-- Svelte: lazy import when visible -->
<script>
  let Chart, visible = false;
  async function onVisible() {
    if (!Chart) {
      const m = await import('$lib/BigChart.svelte'); Chart = m.default;
    }
    visible = true;
  }
  function intersect(node) {
    const io = new IntersectionObserver(([e]) => e.isIntersecting && (onVisible(), io.disconnect()), { rootMargin: '200px' });
    io.observe(node); return { destroy() { io.disconnect(); } };
  }
</script>

<div use:intersect style="min-height:320px">
  {#if visible && Chart}<Chart />{:else}<div class="skeleton h-64 w-full"></div>{/if}
</div>

<!-- Images: native lazy loading -->
<img src="/img/hero-640.jpg" width="640" height="360" loading="lazy" decoding="async" alt="Hero">

<!-- HTML preload/prefetch hints -->
<link rel="preload" as="image" href="/img/hero-640.jpg">
<link rel="prefetch" href="/next/page" as="document">

Pitfalls: Don’t lazy‑load above‑the‑fold hero images; it can hurt LCP. Pre-size containers to avoid CLS.

4) Client‑side Caching (SWR: Stale‑While‑Revalidate)

What it is: Return cached data instantly, then re‑fetch in the background and update.

// Tiny SWR-style cache helper
const cache = new Map(); // key -> { ts, ttl, data, inflight }
export async function fetchCached(key, ttlMs, loader) {
  const now = Date.now();
  const hit = cache.get(key);
  if (hit && now - hit.ts < hit.ttl) return hit.data;
  if (hit?.inflight) return hit.inflight;
  const p = loader().then(data => (cache.set(key, { ts: now, ttl: ttlMs, data }), data))
                    .finally(() => { const h = cache.get(key); if (h) h.inflight = null; });
  cache.set(key, { ...(hit||{}), ts: now, ttl: ttlMs, inflight: p });
  return hit?.data ?? p;
}

// Svelte usage
import { fetchCached } from '$lib/cache.js';
$: products = await fetchCached('products', 30_000, () => fetch('/api/products').then(r=>r.json()));

Tip: Add jitter to TTLs to avoid thundering herds; expose a `force` option to bypass cache after mutations.

5) Prefetch on Hover / In‑View

What it is: Download the next page’s code/data when the user is likely to need it.

// Svelte: simple hover prefetch for JSON
function prefetch(url) { return fetch(url, { credentials: 'include' }).then(()=>{}).catch(()=>{}); }
<a href="/products" on:mouseenter={() => prefetch('/api/products?prefetch=1')}>Products</a>

<!-- HTML link hint -->
<link rel="prefetch" href="/products" as="document">

Pitfalls: Be respectful on mobile/data caps; throttle prefetching and skip on `save-data` clients.

6) Debounce & Throttle

What it is: Rate-limit noisy UI events (typing/scroll) to reduce reflows and network chatter.

// Debounce helper
export const debounce = (fn, ms=200) => {
  let t; return (...args) => { clearTimeout(t); t = setTimeout(()=>fn(...args), ms); };
};

<!-- Svelte: debounced search -->
<script>
  import { debounce } from '$lib/debounce.js';
  let q = '';
  const onInput = debounce(async (v) => {
    const res = await fetch('/api/search?q=' + encodeURIComponent(v));
    // render results...
  }, 250);
</script>

<input class="input input-bordered w-full" placeholder="Search" bind:value={q} on:input={(e)=>onInput(e.target.value)} />

7) Image Placeholders (blur‑up / dominant color)

What it is: Show a tiny blurred preview or solid color matching the image while the real image loads. Reduces perceived wait + CLS.

<!-- HTML blur-up technique -->
<div class="relative overflow-hidden w-[320px] h-[180px] bg-[#e3d5c3]">
  <img src="/img/preview-20w.jpg" class="absolute inset-0 w-full h-full object-cover blur-sm" aria-hidden="true">
  <img src="/img/full-1280w.jpg" class="absolute inset-0 w-full h-full object-cover" loading="lazy" decoding="async" alt="">
</div>

8) Abortable Fetch + Retry

What it is: Cancel stale requests and back off on failure for resilient UIs.

export async function getJSON(url, { signal, tries=2 } = {}) {
  for (let i=0; i<=tries; i++) {
    try {
      const r = await fetch(url, { signal });
      if (!r.ok) throw new Error(r.statusText);
      return await r.json();
    } catch (e) {
      if (e.name === 'AbortError' || i === tries) throw e;
      await new Promise(res => setTimeout(res, 300 * (i+1))); // backoff
    }
  }
}

// Usage with AbortController
const ac = new AbortController();
const p = getJSON('/api/items', { signal: ac.signal });
// later, if user navigates away:
ac.abort();

9) Progressive Enhancement & No‑JS Fallbacks

What it is: Core content and actions should work without JS; JS enhances UX (optimistic UI, transitions).

<!-- HTML form that works without JS; JS can 'enhance' it later -->
<form action="/subscribe" method="POST" class="flex gap-2">
  <input class="input input-bordered" name="email" type="email" required placeholder="you@example.com" />
  <button class="btn btn-primary">Subscribe</button>
</form>

10) Accessibility & Motion

What it is: Respect user settings and keep skeleton/transition effects subtle.

/* Tailwind config utilities already help, but add this too */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}

Measure: Track Web Vitals (LCP, CLS, INP) and real‑user metrics. Confirm skeletons improve perceived speed without tanking LCP.

Grab bag checklist

□ Skeletons for remote data • □ Optimistic on likely success
□ Lazy‑load heavy components/images • □ Prefetch on hover/in‑view
□ Client cache (SWR) • □ Debounce noisy inputs
□ Abort stale fetches • □ Pre-size images to avoid CLS
□ Respect reduced motion • □ Monitor Web Vitals


Comments

Popular posts from this blog

Learning to Automate My Side Projects with SWE-agent + GitLab

Ship-Ready Web Essentials: Search, Sitemap, Metadata & Icons (SvelteKit)

Kubernetes Secrets Management — SOPS + age (GitOps‑friendly)