Files
thoughts/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md

5.8 KiB
Raw Blame History

Suspense Boundaries & Streaming Design

Date: 2026-05-16 Status: Approved

Problem

The home feed page (app/page.tsx) blocks rendering until all data is ready:

  • getFeed + getMe — critical path, fine
  • getFriends + getTopFriends — sidebar data, runs after the feed fetch, adds 150500ms before any HTML ships
  • PopularTags, TopFriends, UsersCount are async server components that all resolve before the page renders

Additionally, TopFriends has an N+1 problem: it fetches a list of usernames then makes N separate getUserProfile calls in parallel (~500ms2s+ for 510 friends).

No React Suspense boundaries exist anywhere in the app. ThoughtSkeleton and ProfileSkeleton are available from the previous overhaul.

Solution

  1. Backend: enrich /users/{username}/top-friends to return full UserResponse objects — eliminating the N+1
  2. Frontend: move sidebar fetches into the sidebar components themselves, wrap each in <Suspense>, strip secondary fetches from app/page.tsx

Section 1: Backend — Enrich top-friends endpoint

Response type change

// crates/api-types/src/responses.rs
pub struct TopFriendsResponse {
    pub top_friends: Vec<UserResponse>,
}

Replaces the existing { topFriends: string[] } shape. Same URL: GET /users/{username}/top-friends.

Use case change

The existing use case calls TopFriendRepository::get_top_friends(username) returning Vec<UserId>. Add a UserRepository::find_many(&[UserId]) call (or a single JOIN query) to resolve the full user objects in one extra query. No N+1.

Files changed

File Change
crates/api-types/src/responses.rs Add TopFriendsResponse { top_friends: Vec<UserResponse> }
crates/domain/src/ports.rs Add find_many(ids: &[UserId]) to UserRepository (or equivalent)
crates/adapters/postgres/src/user.rs Implement find_many with WHERE id = ANY($1)
crates/application/src/use_cases/users.rs Update top-friends use case to return Vec<User>
crates/presentation/src/handlers/users.rs Map Vec<User>TopFriendsResponse

Section 2: Frontend — Suspense boundaries + self-contained sidebars

Strip app/page.tsx

Remove getFriends, getTopFriends, shouldDisplayTopFriends, and the conditional sidebar rendering logic. Page-level critical path becomes:

const [feedData, me] = await Promise.all([
  getFeed(token, page).catch(() => null),
  getMe(token).catch(() => null),
])

Pass me.username and token as props to TopFriends.

Self-contained TopFriends

TopFriends receives username: string and token: string as props and fetches getTopFriends(username, token) internally. The response is now { topFriends: UserResponse[] } — render directly, no secondary fetches.

Suspense wrapping (both desktop and mobile sidebars)

<Suspense fallback={<ProfileSkeleton />}>
  <TopFriends username={me.username} token={token} />
</Suspense>
<Suspense fallback={<TagsSkeleton />}>
  <PopularTags />
</Suspense>
<Suspense fallback={<CountSkeleton />}>
  <UsersCount />
</Suspense>

Each widget streams in independently. Feed renders as soon as getFeed resolves.

New skeleton variants

Add to components/loading-skeleton.tsx:

export function TagsSkeleton() {
  return (
    <Card>
      <CardContent className="pt-4 space-y-2">
        <Skeleton className="h-4 w-24" />
        {[...Array(5)].map((_, i) => (
          <Skeleton key={i} className="h-6 w-full rounded-full" />
        ))}
      </CardContent>
    </Card>
  )
}

export function CountSkeleton() {
  return (
    <Card>
      <CardContent className="pt-4">
        <Skeleton className="h-6 w-32" />
      </CardContent>
    </Card>
  )
}

loading.tsx files for pages missing them

Currently only /users/[username]/loading.tsx exists. Add:

Page Skeleton content
app/loading.tsx ThoughtSkeleton × 3
app/tags/[tagName]/loading.tsx ThoughtSkeleton × 3
app/search/loading.tsx ThoughtSkeleton × 3
app/thoughts/[thoughtId]/loading.tsx ThoughtSkeleton × 2

Update lib/api.ts

// Before
export const getTopFriends = (username: string, token: string | null) =>
  apiFetch(..., z.object({ topFriends: z.array(z.string()) }), token)

// After
export const getTopFriends = (username: string, token: string | null) =>
  apiFetch(..., z.object({ topFriends: z.array(UserSchema) }), token)

Files Changed

Backend

File Change
crates/api-types/src/responses.rs TopFriendsResponse { top_friends: Vec<UserResponse> }
crates/domain/src/ports.rs UserRepository::find_many
crates/adapters/postgres/src/user.rs find_many impl
crates/application/src/use_cases/users.rs Return Vec<User> from top-friends use case
crates/presentation/src/handlers/users.rs Map to TopFriendsResponse

Frontend

File Change
thoughts-frontend/lib/api.ts Update getTopFriends schema
thoughts-frontend/app/page.tsx Strip sidebar fetches; pass username+token to TopFriends
thoughts-frontend/components/top-friends.tsx Self-contained fetch; use UserResponse[]
thoughts-frontend/components/loading-skeleton.tsx Add TagsSkeleton, CountSkeleton
thoughts-frontend/app/loading.tsx New — feed skeleton
thoughts-frontend/app/tags/[tagName]/loading.tsx New
thoughts-frontend/app/search/loading.tsx New
thoughts-frontend/app/thoughts/[thoughtId]/loading.tsx New

What This Does Not Cover

  • Suspense on the feed content itself (feed is already the critical path — streaming it would show partial thought lists, which is awkward UX)
  • Left sidebar "Filters & Sorting" placeholder (still empty)
  • Streaming on other pages beyond loading.tsx coverage