Skip to main content
node

Designing Loading States Starts in the Database

The skeleton screen you draw in Figma is determined by the query you write in PostgreSQL. If they don't match, one of them is wrong.


7 min read

There's a specific kind of design-engineering mismatch that looks like a frontend problem but is actually a database problem. The designer hands over a skeleton screen with six distinct zones that reveal progressively. The engineer implements it. Then someone benchmarks the query and discovers it's a 600ms blocking call that returns everything at once. The skeleton never flashes — the data either isn't there or it all arrives simultaneously. The design and the data model are out of sync, and one of them has to change.


Latency determines whether you need a skeleton at all

A spinner is appropriate for operations that take 200–800ms and where the user expects to wait. A skeleton is appropriate for content-shaped loads — when you know the shape of what's coming and want to hold space for it. Instant data (sub-50ms) shouldn't show either; it should just appear.

These thresholds are determined by your queries, not your design system. A count query on an indexed column returns in under 5ms — showing a spinner for that is worse than just rendering a 0 immediately. A full-text search with ranking across unindexed columns can take 800ms — a spinner is appropriate, a skeleton that implies you know the shape is misleading because you don't know if there will be results at all.

Before designing a loading state, you need to know three things about the data it represents:

  1. What is the p95 latency of the query?
  2. Does the query return a known shape (always a list of N items) or a variable shape (search results)?
  3. Can the data be streamed incrementally, or does it block until complete?

The answers to those questions determine the correct loading UI. Most design feedback loops skip this information entirely.

The mismatch pattern on a real project

On a project building an operations dashboard for logistics coordinators, the designer created a skeleton screen with three zones: a metric strip across the top (four cards), a shipments table in the main area, and a map view showing active routes.

The implementation had a problem: the map view required a geospatial join across three tables with carrier position data. That query took 900ms on average and returned all map markers in one payload — there was no way to stream it. The metric strip was four independent sub-50ms count queries. The shipments table was a 120ms paginated query.

The skeleton design implied all three zones would load at roughly the same time. In reality, the metric cards appeared in 50ms, the table filled in at 120ms, and the map spent 900ms showing a skeleton before snapping fully in. The skeleton for the map wasn't communicating "loading" — it was communicating false hope about what the experience would feel like.

The fix was a Suspense boundary restructure and a design revision:

export function OperationsDashboard() {
  return (
    <DashboardLayout>
      {/* Sub-50ms: no skeleton, renders immediately */}
      <Suspense fallback={null}>
        <MetricStrip />
      </Suspense>

      {/* ~120ms: skeleton that matches table shape */}
      <Suspense fallback={<ShipmentTableSkeleton rows={10} />}>
        <ShipmentsTable />
      </Suspense>

      {/* ~900ms: spinner, not skeleton — shape is unknown until loaded */}
      <Suspense fallback={<MapSpinner />}>
        <ActiveRoutesMap />
      </Suspense>
    </DashboardLayout>
  );
}

The MetricStrip gets no fallback — the data is fast enough that a skeleton flash would be worse than a brief empty state. The table gets a skeleton that matches the row count we know we'll return. The map gets a spinner because we don't know how many markers there will be, and a skeleton implying a specific shape would be wrong.

The streaming case

Node.js streaming responses, combined with React's use() hook or server components with streaming, change what's possible. Instead of waiting for an entire query to resolve, you can flush partial results as they arrive.

A practical case: a shipments list where the basic row data is fast but enriched carrier status requires a secondary API call per row. Without streaming, you either wait for all enrichments (slow) or return rows without enrichment and fetch it client-side (two round trips). With streaming you can:

// Route handler using a ReadableStream
export async function GET(req: Request) {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const shipments = await db.query(
        "SELECT id, origin, destination, eta FROM shipments ORDER BY created_at DESC LIMIT 20"
      );

      for (const shipment of shipments.rows) {
        // Flush each row immediately
        controller.enqueue(encoder.encode(JSON.stringify(shipment) + "\n"));

        // Enrich asynchronously — don't block the flush
        enrichCarrierStatus(shipment.id).then((status) => {
          controller.enqueue(encoder.encode(JSON.stringify({ id: shipment.id, ...status }) + "\n"));
        });
      }
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "application/x-ndjson" },
  });
}

The loading state design for this is fundamentally different: each row appears as it flushes, then updates in place when the enrichment arrives. That's a design decision that has to be made before the skeleton is drawn. If the designer doesn't know the data is streaming, they'll design a table that appears all at once — and the engineer will implement the streaming but the UI will feel wrong because the progressive reveal wasn't accounted for.

Suspense boundaries should match data boundaries

React's Suspense system makes this concrete: every <Suspense> boundary you add is a declaration that "this subtree has independent loading behavior." Those boundaries should align with query boundaries, not component hierarchies.

The mistake is placing Suspense boundaries based on what looks neat in the component tree. The correct placement comes from asking: which data dependencies are independent? Which can load in parallel? Which must serialize?

If MetricCard A and MetricCard B fetch from different endpoints, they should each be wrapped in their own Suspense boundary. If they share a query, they should share a boundary. The fallback you show in each boundary should be shaped like what will appear — meaning you need to know the query's return shape before you write the skeleton.

Whose job is this?

The honest answer: it's the engineer's job to surface query latency and shape to the designer before the skeleton screen is designed, and the designer's job to ask.

In practice, designers don't know to ask "what is the p95 latency on this query" and engineers don't know that the skeleton design has latency assumptions baked in. The person who bridges this gap is whoever is doing design engineering — the person who can read a Figma file and a EXPLAIN ANALYZE output on the same day.

That person should be running EXPLAIN ANALYZE on every query that backs a loading state, and flagging when the query latency doesn't match the UX assumption. A 900ms query behind a skeleton that implies 150ms is a design bug, and it's a database issue.

Concretely: before any skeleton screen is finalized, attach a latency number to each zone in the design. Write it in the Figma note. Make it visible. Then the skeleton design is a deliberate choice informed by the actual data, not an assumption that happens to be wrong 40% of the time.

The practical checklist

When you're designing a loading state, answer these before touching Figma or writing the Suspense boundary:

  • Run EXPLAIN ANALYZE on the backing query. What's the actual execution time?
  • Is the return shape fixed (skeleton matches) or variable (spinner or count-based)?
  • Can this data be fetched in parallel with adjacent sections, or does it depend on prior results?
  • Is there a streaming option that changes the progressive reveal story?
  • What should the empty state look like — and is it distinguishable from the loading state?

The skeleton screen is not a design artifact you hand to engineering. It's a contract between the data layer and the UI layer. Get both parties in the room before you draw the first grey rectangle.