7.3 KiB
Batch-profile endpoint — design
Status: ✅ SHIPPED. Both sessions complete:
session 1 (indexer-side GET /v1/profiles?accounts=...)
landed at apps/indexer/src/api/profiles.ts; session 2
(frontend integration via getProfilesBatch()) at
apps/web/src/lib/indexer/profileCache.ts. Wired into
orderbook, feedback list, profile cross-reference panes.
This file is preserved as the design rationale; the
endpoint is documented under "Public endpoints" in
docs/API.md.
Scope: add a batch GET /v1/profiles?accounts=... endpoint so
pages that render many usernames at once (orderbook rows, feedback
lists, profile cross-reference panes) can fetch their matching
profiles in one round-trip instead of N single /v1/profiles/:account
calls.
Motivation
Users can set custom avatars (SVG or raster, uploaded via settings).
The avatar is stored in profiles.json_metadata. Today, only the
profile page (/@{account}) reads this — so avatars are invisible
on orderbook rows, feedback lists, and comment bylines, which all
fall back to the heart identicon.
A naive fix (one /v1/profiles/:account call per visible account)
is an N+1 query pattern. An orderbook page with 50 rows would make
51 HTTP requests. Unacceptable.
Design decisions
Endpoint shape: GET /v1/profiles?accounts=alice,bob,carol
- Method: GET, not POST. GET is cacheable at both browser and CDN layers. Profile data changes rarely (most users update once per year or never); cache-friendliness is the whole point.
- Query string, comma-separated: fits the largest realistic request (100 accounts × ~16 chars = 1600 chars) well under the 4 KB practical URL limit. Comma is not valid in Blurt account names so unambiguous as a separator.
- Max batch size: 100. Caps the worst-case query cost and prevents a hostile caller from constructing a request that would materialize thousands of rows. An orderbook page is ~50 accounts; feedback list is ~20. 100 is comfortably above both with margin.
Response shape: object keyed by account
{
"profiles": {
"alice": { "account": "alice", "display_name": "...", ... },
"bob": { "account": "bob", "display_name": "...", ... }
}
}
Rationale: callers can do response.profiles[account] directly
without a find-by-key scan on a list. Matches the pattern the
frontend already uses for caches (Map<account, Profile>).
Unknown / missing accounts
Silently dropped from the response. If the caller requests
?accounts=alice,bob,nonexistent, the response contains alice
and bob under profiles, and nonexistent is simply absent.
Rationale:
- Batches should degrade gracefully. A 404 for the whole batch because one account is unknown is hostile.
- The frontend already has to handle "no profile for this account" (legitimate case: user has a Blurt account but never set a display name). Same code path handles "account doesn't exist."
- An explicit "missing" list in the response would be symmetrical but adds noise for the 99% case where everything is found.
Caching
Cache-Control: public, max-age=90, stale-while-revalidate=60— but ONLY when the batch is COMPLETE (every requested account resolved to a profile row).Cache-Control: no-storewhen the batch is PARTIAL (at least one requested account is absent from the response). #2 — an absent account is only provisionally authoritative: the usual cause is indexer lag, in the 1–2 block window after the account broadcast its profile op or signed up. Caching that negative result pinned it in the browser's HTTP cache, which replays it on every load of the same URL — so the fresh profile stayed invisible for up to 150s and, crucially, survived a page refresh (a reload clears the client's in-memory cache but not the browser's disk cache). The symptom was a display name falling back to@accountand an avatar falling back to the identicon, with refreshing changing nothing. Positive results are still cached for the full 90s; only negatives are excluded. Mirrors the client's soft-null policy (cp428) and the dynamic-data service-worker exclusion (cp324).- 90 seconds = 90 blocks on Blurt (3s block time). A user who
updates their avatar waits ~90 seconds for it to propagate via
the cache to other visitors' orderbook views. That's acceptable
because the orderbook-row avatar is a nice-to-have, not a
correctness surface. (The updating user themselves sees it
immediately: the client re-fetches their own profile with
cache: 'reload'after a confirmed broadcast, bypassing both the in-memory and browser HTTP caches.) stale-while-revalidate=60lets the CDN serve slightly stale responses while refetching, keeping p99 latency low.- Browser cache also honors this; repeated orderbook navigations within 90s don't re-request.
Rate limiting
Uses the existing ratelimit middleware (Finding B). Each batch
request costs 1 token regardless of batch size — the max-size cap
(100) already bounds the worst-case abuse. No separate bucket
needed.
Authentication
None. Profiles are fully public on-chain data. No reason to gate the endpoint; doing so would break CDN caching.
Rejected designs
B. Push avatars inline with /v1/orderbook
Rejected in §G during the decision-landing discussion. Reasons preserved here for durability:
- Tight-couples orderbook and profile caches. Orderbook changes every block (3s); profiles change once a year. Forcing them into one response means either the orderbook gets cached too aggressively (stale prices) or the profiles too briefly (constant refetch of static data).
- Bloats every orderbook response. 50 rows × 3 KB per raster avatar = 150 KB extra per response. CDN bandwidth cost + worse p50 latency for every caller, including ones that don't care about avatars.
- Orderbook responses become unique per (user-set)-avatar-state, killing the CDN hit rate.
C. Don't batch — N+1 calls
Rejected because it scales linearly with orderbook size. A user scrolling an active orderbook with 200 rows would trigger 200 HTTP requests. Unacceptable.
Implementation plan
Session 1 (this session — indexer)
- Extend
apps/indexer/src/api/profiles.ts— new handler forGET /(batch) alongside the existingGET /:account. - Extend
packages/indexer-client/src/index.tswithBatchProfilesResponsetype. - Add integration tests covering: happy path, empty batch, single-item batch, over-limit rejection, mix of known + unknown accounts, malformed account name rejection.
Session 2 (next session — frontend)
getProfilesBatch(accounts[]): Promise<Map<string, Profile>>inapps/web/src/lib/indexer/client.ts.- Wire into orderbook page: fetch orderbook first, then batch the distinct accounts, swap identicons for custom avatars.
- Same wiring in feedback list, profile cross-reference pane.
- Client-side in-memory cache with 90s TTL to match server
Cache-Control.
Open questions parked for session 2
- Do we want client-side deduplication across the 3 consumer surfaces? An orderbook view that then navigates to a profile would ideally reuse the batch-fetch result. Design in session 2.
- Is it worth a local storage cache to survive page reloads? Probably not — 90s TTL plus the in-memory cache handles the common "refresh after a minute of inactivity" case.