diff --git a/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md b/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md
new file mode 100644
index 0000000..4f98e9e
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-16-suspense-streaming-design.md
@@ -0,0 +1,173 @@
+# 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 150–500ms 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 (~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
+
+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 ``, strip secondary fetches from `app/page.tsx`
+
+---
+
+## Section 1: Backend — Enrich top-friends endpoint
+
+### Response type change
+
+```rust
+// crates/api-types/src/responses.rs
+pub struct TopFriendsResponse {
+ pub top_friends: Vec,
+}
+```
+
+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`. 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 }` |
+| `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` |
+| `crates/presentation/src/handlers/users.rs` | Map `Vec` → `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:
+
+```ts
+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)
+
+```tsx
+}>
+
+
+}>
+
+
+}>
+
+
+```
+
+Each widget streams in independently. Feed renders as soon as `getFeed` resolves.
+
+### New skeleton variants
+
+Add to `components/loading-skeleton.tsx`:
+
+```tsx
+export function TagsSkeleton() {
+ return (
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+ )
+}
+
+export function CountSkeleton() {
+ return (
+
+
+
+
+
+ )
+}
+```
+
+### `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`
+
+```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 }` |
+| `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` 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