5.8 KiB
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, finegetFriends+getTopFriends— sidebar data, runs after the feed fetch, adds 150–500ms before any HTML shipsPopularTags,TopFriends,UsersCountare 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 (~500ms–2s+ for 5–10 friends).
No React Suspense boundaries exist anywhere in the app. ThoughtSkeleton and ProfileSkeleton are available from the previous overhaul.
Solution
- Backend: enrich
/users/{username}/top-friendsto return fullUserResponseobjects — eliminating the N+1 - Frontend: move sidebar fetches into the sidebar components themselves, wrap each in
<Suspense>, strip secondary fetches fromapp/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