Skip to content

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:

  1. Signed URLs are not stable — each createSignedUrl call produces a different query token. Browsers cache by full URL, so a reminted URL is a cache miss and re-downloads the full file.
  2. No shared client cachepackages/chat-react/src/use-attachment-preview.ts keeps downloadUrl in per-component useState. Every AttachmentImageBody / SharedContentThumbnail instance fetches independently.
  3. Gallery cache is ephemeraluseGalleryUrlCache is a useRef inside AttachmentLightbox; it is discarded when the lightbox closes and does not share with inline previews.
  4. Thread unmount on loadMessageThread.tsx returns a loading placeholder when loading is true (lines 845–847). useMessages.refresh sets loading=true on conversation switch, jumpToMessage, and initial load — unmounting every attachment component and re-triggering fetches when the thread returns.
  5. Short object cache headers — uploads set cacheControl: "3600" in attachment-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 again

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 expiresAt is 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

Refactor consumers to use one helper (e.g. resolveAttachmentDownloadUrl in attachment-api.ts):

FileChange
use-attachment-preview.tsReplace local useState fetch with shared cache
use-gallery-image-url.tsRemove per-lightbox Map; use same cache
ConversationInfoSidebar.tsxBenefit 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 when messages.length === 0 and first fetch is in flight
  • refreshing — true during refresh() / jumpToMessage() when messages already exist

Update MessageThread.tsx:

  • Gate the full-page “Loading messages…” UI on initialLoading only
  • 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:

SettingCurrentProposedRationale
DOWNLOAD_URL_TTL_SECONDS360086400 (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=preview in inline bubbles; reserve original for 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

  1. Open a conversation with several images; note Network tab: one download-url per attachment, one storage GET per attachment.
  2. Switch away and back — no new storage GETs if within TTL (same signed URL in cache).
  3. Open lightbox — reuses cached URL, no duplicate fetch.
  4. After ~55 min (or forced expiry), cache refreshes once; single new storage GET.
  5. 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; wire use-attachment-preview and use-gallery-image-url to it
  • Split initialLoading vs refreshing in useMessages; keep MessageThread mounted during background refresh
  • Increase DOWNLOAD_URL_TTL_SECONDS and upload cacheControl; redeploy api edge function
  • Manual Network-tab QA: conversation switch, lightbox, scroll — confirm single storage GET per attachment per TTL

Files to touch