Back to Blog

Loading States and Skeleton Screens: Patterns for Perceived Performance

Loading States and Skeleton Screens: Patterns for Perceived Performance

Users do not experience milliseconds. They experience perception. A page that loads in 800ms but shows nothing until it is done feels slower than a page that loads in 1,200ms but shows a skeleton layout at 100ms. This is not a trick — it is how human attention works, and building for it is a core part of frontend engineering.

We have built loading experiences across several products at Threshline — from MindHyv’s dashboard feeds to Trackelio’s real-time feedback boards. The patterns we have settled on are not clever or original. They are just consistent, and consistency is what makes loading states feel polished rather than janky.

The Three Loading Patterns

There are exactly three loading patterns worth knowing. Everything else is a variation.

1. Spinners. A centered loading indicator. Best for actions where the user expects to wait — form submissions, file uploads, page transitions. Worst for content-heavy layouts where a spinner leaves the page empty.

2. Skeleton screens. Placeholder shapes that match the layout of the content being loaded. Best for lists, cards, dashboards — anything where the layout is predictable before the data arrives. Worst for unpredictable content or very fast loads (the skeleton flashes and feels broken).

3. Progressive loading. Content appears piece by piece as it becomes available. The page header loads first, then the sidebar, then the main content. Best for pages with multiple independent data sources. Worst when the loading order creates a jarring visual shift.

The right choice depends on two things: how long the load takes and how predictable the layout is.

Load TimePredictable LayoutUnpredictable Layout
< 200msNo indicator neededNo indicator needed
200ms - 1sSkeleton screenSpinner
1s - 3sSkeleton screenSpinner + progress text
> 3sProgressive loadingProgress bar

Under 200ms, any loading indicator is worse than nothing — it flashes and disappears, making the experience feel less stable. This is why we always add a minimum delay before showing a loading state.

Building a Skeleton Screen

A skeleton screen is a gray placeholder that mimics the shape of the real content. Here is a base CSS approach that works everywhere:

/* Base skeleton animation */
@keyframes skeleton-pulse {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.4;
  }
}

.skeleton {
  background-color: #e5e7eb;
  border-radius: 4px;
  animation: skeleton-pulse 2s ease-in-out infinite;
}

/* Common skeleton shapes */
.skeleton-text {
  height: 16px;
  margin-bottom: 8px;
}

.skeleton-text:last-child {
  width: 60%;
}

.skeleton-heading {
  height: 24px;
  width: 40%;
  margin-bottom: 16px;
}

.skeleton-avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
}

.skeleton-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: 8px;
}

.skeleton-button {
  height: 36px;
  width: 120px;
  border-radius: 6px;
}

For Tailwind CSS projects, which is what we use on most builds, you can skip the custom CSS entirely:

<!-- Skeleton card in Tailwind -->
<div class="rounded-lg border border-gray-200 p-4">
  <div class="flex items-center gap-3 mb-4">
    <div class="h-10 w-10 animate-pulse rounded-full bg-gray-200"></div>
    <div class="flex-1">
      <div class="h-4 w-1/3 animate-pulse rounded bg-gray-200 mb-2"></div>
      <div class="h-3 w-1/4 animate-pulse rounded bg-gray-200"></div>
    </div>
  </div>
  <div class="space-y-2">
    <div class="h-4 w-full animate-pulse rounded bg-gray-200"></div>
    <div class="h-4 w-full animate-pulse rounded bg-gray-200"></div>
    <div class="h-4 w-3/5 animate-pulse rounded bg-gray-200"></div>
  </div>
</div>

The key principle: the skeleton should match the exact layout of the loaded content. If your card has a 40px avatar in the top left, the skeleton should have a 40px circle in the top left. When the real content replaces the skeleton, nothing should shift.

Layout shift during loading is one of the most common UX problems we see. It happens when the skeleton and the real content have different dimensions. The fix is simple — use the same container dimensions, the same padding, the same gap values. In practice, we build the skeleton and the real component in the same file so they stay in sync.

Mobile app displaying a loading spinner while content loads

The Minimum Delay Pattern

One of the most important details in loading UX is preventing flash. If data loads in 50ms, showing a skeleton for 50ms is worse than showing nothing. The skeleton appears and vanishes before the user can even register it, creating a visual stutter.

We use a minimum delay pattern that ensures loading indicators are either not shown at all (for fast loads) or shown for at least 300ms (so they do not flash):

function createLoadingState(delayMs = 200, minDisplayMs = 300) {
  let showTimeout: ReturnType<typeof setTimeout> | null = null;
  let shownAt: number | null = null;
  let isLoading = $state(false);
  let shouldShow = $state(false);

  return {
    get isLoading() { return isLoading; },
    get shouldShow() { return shouldShow; },

    start() {
      isLoading = true;
      // Only show loading indicator after delay
      showTimeout = setTimeout(() => {
        shouldShow = true;
        shownAt = Date.now();
      }, delayMs);
    },

    async stop() {
      isLoading = false;

      if (showTimeout) {
        clearTimeout(showTimeout);
        showTimeout = null;
      }

      if (shownAt) {
        // Ensure minimum display time to prevent flash
        const elapsed = Date.now() - shownAt;
        if (elapsed < minDisplayMs) {
          await new Promise((r) => setTimeout(r, minDisplayMs - elapsed));
        }
        shownAt = null;
      }

      shouldShow = false;
    },
  };
}

The logic:

  1. When loading starts, wait 200ms before showing any indicator.
  2. If loading finishes within 200ms, the user sees nothing — the content just appears.
  3. If loading takes longer, the skeleton appears and stays for at least 300ms, even if the data arrives at 250ms.
  4. This prevents the flash while keeping fast loads feeling instant.

This pattern is subtle but it makes a huge difference in perceived quality. Without it, fast connections get a jarring flash of skeleton, and slow connections get a skeleton that disappears too quickly to be useful.

Skeleton Components in Svelte

Since we build most of our apps in SvelteKit, here is how we structure skeleton components in practice:

<!-- ProjectCard.svelte -->
<script lang="ts">
  type Props = {
    project?: {
      name: string;
      description: string;
      status: string;
      memberCount: number;
      updatedAt: string;
    };
    loading?: boolean;
  };

  let { project, loading = false }: Props = $props();
</script>

{#if loading}
  <div class="rounded-lg border border-gray-200 p-4">
    <div class="mb-3 flex items-center justify-between">
      <div class="h-5 w-2/5 animate-pulse rounded bg-gray-200"></div>
      <div class="h-5 w-16 animate-pulse rounded-full bg-gray-200"></div>
    </div>
    <div class="mb-4 space-y-2">
      <div class="h-4 w-full animate-pulse rounded bg-gray-200"></div>
      <div class="h-4 w-4/5 animate-pulse rounded bg-gray-200"></div>
    </div>
    <div class="flex items-center justify-between">
      <div class="h-3 w-24 animate-pulse rounded bg-gray-200"></div>
      <div class="h-3 w-20 animate-pulse rounded bg-gray-200"></div>
    </div>
  </div>
{:else if project}
  <div class="rounded-lg border border-gray-200 p-4">
    <div class="mb-3 flex items-center justify-between">
      <h3 class="text-lg font-semibold">{project.name}</h3>
      <span class="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
        {project.status}
      </span>
    </div>
    <p class="mb-4 text-sm text-gray-600">{project.description}</p>
    <div class="flex items-center justify-between text-xs text-gray-400">
      <span>{project.memberCount} members</span>
      <span>Updated {project.updatedAt}</span>
    </div>
  </div>
{/if}

And the list view that uses it:

<!-- ProjectList.svelte -->
<script lang="ts">
  import ProjectCard from "./ProjectCard.svelte";

  type Project = {
    name: string;
    description: string;
    status: string;
    memberCount: number;
    updatedAt: string;
  };

  let projects: Project[] | null = $state(null);
  let loading = $state(true);

  async function loadProjects() {
    loading = true;
    const response = await fetch("/api/projects");
    projects = await response.json();
    loading = false;
  }

  $effect(() => {
    loadProjects();
  });
</script>

<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
  {#if loading}
    {#each Array(6) as _}
      <ProjectCard loading={true} />
    {/each}
  {:else if projects}
    {#each projects as project}
      <ProjectCard {project} />
    {/each}
  {/if}
</div>

Notice that we render 6 skeleton cards. This number should roughly match the expected number of items. If the user typically sees 3-5 projects, showing 6 skeletons is fine. Showing 20 skeletons would be misleading. The skeleton count is a minor detail, but it contributes to the overall feeling that the app “knows” what is coming.

User interface elements loading progressively on screen

Progressive Loading With Streaming

For pages with multiple independent data sources, progressive loading is the best approach. Instead of waiting for everything to load before showing anything, each section loads and renders independently.

In SvelteKit, you can achieve this with streaming by returning promises in your load function:

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ locals }) => {
  const supabase = locals.supabase;

  // These all start immediately and run in parallel
  // But they stream to the client as they resolve
  return {
    // Critical data: awaited before page renders
    workspace: await supabase
      .from("workspaces")
      .select("*")
      .single()
      .then(({ data }) => data),

    // Non-critical data: streams in after page renders
    recentProjects: supabase
      .from("projects")
      .select("*")
      .order("updated_at", { ascending: false })
      .limit(5)
      .then(({ data }) => data ?? []),

    activityFeed: supabase
      .from("activity_log")
      .select("*")
      .order("created_at", { ascending: false })
      .limit(20)
      .then(({ data }) => data ?? []),

    teamStats: supabase
      .rpc("get_team_stats")
      .then(({ data }) => data),
  };
};

Then in the page component, use Svelte’s {#await} blocks:

<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  import type { PageData } from "./$types";
  import ProjectCard from "$lib/components/ProjectCard.svelte";
  import ActivityItem from "$lib/components/ActivityItem.svelte";

  let { data }: { data: PageData } = $props();
</script>

<h1 class="text-2xl font-bold mb-6">
  {data.workspace.name}
</h1>

<div class="grid gap-6 lg:grid-cols-3">
  <div class="lg:col-span-2">
    <h2 class="text-lg font-semibold mb-4">Recent Projects</h2>
    {#await data.recentProjects}
      <div class="space-y-3">
        {#each Array(3) as _}
          <ProjectCard loading={true} />
        {/each}
      </div>
    {:then projects}
      <div class="space-y-3">
        {#each projects as project}
          <ProjectCard {project} />
        {/each}
      </div>
    {/await}
  </div>

  <div>
    <h2 class="text-lg font-semibold mb-4">Activity</h2>
    {#await data.activityFeed}
      <div class="space-y-2">
        {#each Array(5) as _}
          <div class="h-12 animate-pulse rounded bg-gray-200"></div>
        {/each}
      </div>
    {:then activities}
      <div class="space-y-2">
        {#each activities as activity}
          <ActivityItem {activity} />
        {/each}
      </div>
    {/await}
  </div>
</div>

This gives you the best of both worlds: the page shell and critical data render immediately, while secondary sections show skeletons that resolve independently. The user sees meaningful content within milliseconds and the rest fills in progressively.

Empty States Are Loading States

One pattern that often gets overlooked is the transition from loading to empty. If a user has no projects yet, the flow is: skeleton, then… nothing? An empty white box feels broken.

Empty states need the same design attention as loading states:

{#if loading}
  {#each Array(3) as _}
    <ProjectCard loading={true} />
  {/each}
{:else if projects.length === 0}
  <div class="flex flex-col items-center justify-center rounded-lg border-2
              border-dashed border-gray-300 p-12 text-center">
    <svg class="mb-4 h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24"
         stroke="currentColor">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
            d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
    </svg>
    <h3 class="text-lg font-medium text-gray-900">No projects yet</h3>
    <p class="mt-1 text-sm text-gray-500">
      Create your first project to get started.
    </p>
    <button class="mt-4 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium
                   text-white hover:bg-blue-700">
      New Project
    </button>
  </div>
{:else}
  {#each projects as project}
    <ProjectCard {project} />
  {/each}
{/if}

The empty state should always include a clear call to action. The user just waited for content to load and got nothing — guide them to the next step immediately. This is closely related to onboarding flow design, where empty states play a crucial role in helping new users find value.

Loading States for Actions

Loading is not just about fetching data. User-initiated actions like saving a form, deleting a record, or sending an invitation also need loading states.

The pattern is simpler but the details matter:

<script lang="ts">
  let saving = $state(false);

  async function handleSave() {
    saving = true;
    try {
      await fetch("/api/projects", {
        method: "POST",
        body: JSON.stringify(formData),
      });
      // Success handling
    } catch (err) {
      // Error handling
    } finally {
      saving = false;
    }
  }
</script>

<button
  onclick={handleSave}
  disabled={saving}
  class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2
         text-sm font-medium text-white hover:bg-blue-700
         disabled:opacity-50 disabled:cursor-not-allowed"
>
  {#if saving}
    <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
              class="opacity-25" />
      <path fill="currentColor" class="opacity-75"
            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
    </svg>
    Saving...
  {:else}
    Save Project
  {/if}
</button>

Rules for action loading states:

  1. Disable the trigger. Prevent double-submission by disabling the button immediately.
  2. Show inline feedback. The loading indicator should be on the button itself, not a full-page spinner.
  3. Preserve the button width. If “Save” becomes “Saving…”, the button should not resize and shift the layout. Use a minimum width or keep the text the same length.
  4. Handle errors gracefully. If the action fails, the button returns to its normal state and an error message appears. Never leave the user stuck in a loading state.

Web application code displaying progressive loading patterns

Perceived Performance vs Actual Performance

Everything in this post is about perceived performance — making the app feel fast. But perceived performance and actual performance are not independent. Skeleton screens are not a substitute for fast APIs.

If your API takes 5 seconds, no amount of skeleton screens will make the experience good. The skeleton just makes the wait slightly less painful. The real fix is making the API faster.

Our approach is to do both: optimize actual performance first, then layer on perceived performance patterns. Make the query fast with proper indexes. Cache what you can. Use edge functions to reduce latency. Then add skeletons and progressive loading to handle the remaining wait time gracefully.

The combination of fast data and good loading UX is what makes apps feel professional. Users cannot articulate why one dashboard feels “snappier” than another, but they always notice the difference.

If you are building a product and want loading states that feel intentional, reach out at [email protected].