Image Egress Optimization
Scope: Chat attachment images/videos in @tesark/chat-react, GET /v1/attachments/:id/download-url, Supabase Storage bucket chat-attachments.
Goal: Reduce Supabase storage egress caused by repeated image downloads.
Date: 2026-06-08
Problem
Chat attachments live in the private chat-attachments bucket. The client never stores URLs on messages; each render path calls GET /v1/attachments/:id/download-url, which mints a new Supabase signed URL (1h TTL) via createSignedUrl in supabase/functions/api/attachments.ts. The browser then fetches image bytes from that URL.
Egress adds up because:
- Signed URLs are not stable — each
createSignedUrlcall produces a different query token. Browsers cache by full URL, so a reminted URL is a cache miss and re-downloads the full file. - No shared client cache —
packages/chat-react/src/use-attachment-preview.tskeepsdownloadUrlin per-componentuseState. EveryAttachmentImageBody/SharedContentThumbnailinstance fetches independently. - Gallery cache is ephemeral —
useGalleryUrlCacheis auseRefinsideAttachmentLightbox; it is discarded when the lightbox closes and does not share with inline previews. - Thread unmount on load —
MessageThread.tsxreturns a loading placeholder whenloadingis true (lines 845–847).useMessages.refreshsetsloading=trueon conversation switch,jumpToMessage, and initial load — unmounting every attachment component and re-triggering fetches when the thread returns. - Short object cache headers — uploads set
cacheControl: "3600"inattachment-api.ts; even with a reused signed URL, browser/CDN retention is only 1 hour.
sequenceDiagram participant UI as AttachmentImageBody participant API as EdgeFunction participant Storage as SupabaseStorage
Note over UI: Component mounts after thread loading UI->>API: GET download-url API->>Storage: createSignedUrl API-->>UI: signedUrl_A UI->>Storage: GET image bytes Note over UI: Thread loading=true unmounts UI Note over UI: Component remounts UI->>API: GET download-url again API->>Storage: createSignedUrl API-->>UI: signedUrl_B UI->>Storage: GET image bytes againRecommended approach (phased)
Phase 1 — Shared signed-URL cache (highest impact, low risk)
Add a module-level or ChatProvider-scoped cache in packages/chat-react:
- Key:
${attachmentId}:${variant}(original|preview) - Value:
{ downloadUrl, expiresAt } - Logic:
- Return cached URL if
expiresAtis more than ~5 minutes away - Coalesce in-flight requests per key (single promise while one fetch is pending)
- On expiry/near-expiry, refresh once and update cache
- Return cached URL if
Refactor consumers to use one helper (e.g. resolveAttachmentDownloadUrl in attachment-api.ts):
| File | Change |
|---|---|
use-attachment-preview.ts | Replace local useState fetch with shared cache |
use-gallery-image-url.ts | Remove per-lightbox Map; use same cache |
ConversationInfoSidebar.tsx | Benefit automatically via shared hook |
Expected effect: Conversation switches, scroll remounts, and lightbox open/close reuse the same URL within the TTL — one storage download per attachment per hour (or until tab close), instead of per mount.
Phase 2 — Stop unmounting the thread during refresh
Split loading states in useMessages.ts:
initialLoading— true only whenmessages.length === 0and first fetch is in flightrefreshing— true duringrefresh()/jumpToMessage()when messages already exist
Update MessageThread.tsx:
- Gate the full-page “Loading messages…” UI on
initialLoadingonly - When
refreshing, keep the message list mounted (optional subtle overlay or preserve scroll)
Expected effect: Switching conversations or jumping to a message no longer destroys attachment components; combined with Phase 1, URLs are not re-requested unless expired.
Phase 3 — Storage and signed-URL TTL (server)
In supabase/functions/api/attachments.ts and client upload path:
| Setting | Current | Proposed | Rationale |
|---|---|---|---|
DOWNLOAD_URL_TTL_SECONDS | 3600 | 86400 (24h) | Objects are immutable at stable paths; reduces remint frequency for long sessions |
Upload cacheControl | "3600" | "31536000, immutable" | Lets browsers reuse bytes for the same signed URL longer |
Security tradeoff: A signed URL remains valid until expiry even if conversation membership is revoked. Document this; if unacceptable, keep 1h TTL — Phase 1 still fixes remount churn within the hour.
New uploads pick up the longer cacheControl automatically. Existing objects keep old headers unless you run a one-off storage metadata update (optional, out of scope unless egress from old files is large).
Phase 4 — Optional: thumbnail previews (bytes reduction)
thumbnail_path is always null on complete today; variant=preview returns 404. For image attachments:
- Generate a resized preview on
POST /v1/attachments/:id/complete(Edge Function image lib or Supabase transform if enabled) - Store at e.g.
{tenantId}/{conversationId}/{attachmentId}/preview.jpg - Use
variant=previewin inline bubbles; reserveoriginalfor lightbox/download
This reduces egress volume per view, not just refetch count. Defer unless full-size images are common.
What we are not doing (unless you want a larger redesign)
- Making the bucket public (breaks tenant/membership model)
- Embedding signed URLs in message JSON (expiry + security)
- CDN in front of storage (possible later; client cache solves most pain first)
Verification
- Open a conversation with several images; note Network tab: one
download-urlper attachment, one storage GET per attachment. - Switch away and back — no new storage GETs if within TTL (same signed URL in cache).
- Open lightbox — reuses cached URL, no duplicate fetch.
- After ~55 min (or forced expiry), cache refreshes once; single new storage GET.
- Monitor Supabase dashboard egress over a few days of normal usage.
Implementation checklist
- Add shared attachment download-URL cache with expiry + in-flight dedupe in
attachment-api.ts; wireuse-attachment-previewanduse-gallery-image-urlto it - Split
initialLoadingvsrefreshinginuseMessages; keepMessageThreadmounted during background refresh - Increase
DOWNLOAD_URL_TTL_SECONDSand uploadcacheControl; redeployapiedge function - Manual Network-tab QA: conversation switch, lightbox, scroll — confirm single storage GET per attachment per TTL
Files to touch
packages/chat-react/src/attachment-api.ts— shared cache + deduped resolverpackages/chat-react/src/use-attachment-preview.tspackages/chat-react/src/use-gallery-image-url.tspackages/chat-react/src/useMessages.ts—initialLoading/refreshingpackages/chat-react/src/MessageThread.tsx— loading UI splitsupabase/functions/api/attachments.ts— TTL constant- Redeploy
apiedge function after server changes