From 3efb292c3b8665d16264c0231cf9de16c7dd1472 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:14:07 +0100 Subject: [PATCH] feat: implement negative caching for favicon requests to improve performance --- app/api/favicon/route.ts | 53 +++++++++++++++++++++++++++++++++++++--- components/ui/avatar.tsx | 25 ++++++++++++++----- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 095fc793..2e2f2b5a 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -10,7 +10,14 @@ interface CacheEntry { fetchedAt: number; } +interface NegativeCacheEntry { + fetchedAt: number; +} + const cache = new Map(); +const negativeCache = new Map(); +const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day +const NEGATIVE_CACHE_MAX_SIZE = 2000; // Strict domain validation to prevent SSRF const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; @@ -49,11 +56,23 @@ export async function GET(request: NextRequest) { const domain = request.nextUrl.searchParams.get('domain'); if (!domain || !isValidDomain(domain)) { - return new NextResponse(null, { status: 400 }); + return new NextResponse(null, { + status: 400, + headers: { 'Cache-Control': 'public, max-age=86400' }, + }); } const normalizedDomain = domain.toLowerCase(); + // Check negative cache (domains known to have no favicon) + const neg = negativeCache.get(normalizedDomain); + if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) { + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day + }); + } + // Check cache const cached = cache.get(normalizedDomain); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { @@ -72,7 +91,12 @@ export async function GET(request: NextRequest) { ); if (!upstream.ok) { - return new NextResponse(null, { status: 404 }); + evictNegativeOldest(); + negativeCache.set(normalizedDomain, { fetchedAt: Date.now() }); + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=86400' }, + }); } const contentType = upstream.headers.get('content-type') || 'image/x-icon'; @@ -80,7 +104,12 @@ export async function GET(request: NextRequest) { // Don't cache empty/tiny responses (likely no real favicon) if (data.byteLength < 10) { - return new NextResponse(null, { status: 404 }); + evictNegativeOldest(); + negativeCache.set(normalizedDomain, { fetchedAt: Date.now() }); + return new NextResponse(null, { + status: 404, + headers: { 'Cache-Control': 'public, max-age=86400' }, + }); } // Cache the result @@ -94,6 +123,22 @@ export async function GET(request: NextRequest) { }, }); } catch { - return new NextResponse(null, { status: 502 }); + return new NextResponse(null, { + status: 502, + headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min + }); } } + +function evictNegativeOldest() { + if (negativeCache.size < NEGATIVE_CACHE_MAX_SIZE) return; + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, entry] of negativeCache) { + if (entry.fetchedAt < oldestTime) { + oldestTime = entry.fetchedAt; + oldestKey = key; + } + } + if (oldestKey) negativeCache.delete(oldestKey); +} diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 3d1f42cb..1371e9a9 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,11 +1,15 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; const IS_DEV = process.env.NODE_ENV !== "production"; +// Module-level cache of domains whose favicons failed to load. +// Shared across all Avatar instances to avoid re-requesting known-bad domains. +const failedFaviconDomains = new Set(); + // Personal email domains where the favicon is the mail provider logo, not the sender const PERSONAL_DOMAINS = new Set([ "gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", @@ -86,6 +90,8 @@ interface AvatarProps { export function Avatar({ name, email, size = "md", className }: AvatarProps) { const [imgError, setImgError] = useState(false); const senderFavicons = useSettingsStore((s) => s.senderFavicons); + const domain = email?.split("@")[1]?.toLowerCase(); + const domainFailed = domain ? failedFaviconDomains.has(domain) : false; const getInitials = () => { if (name) { @@ -117,16 +123,23 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) { lg: "w-12 h-12 text-base", }; - const domain = email?.split("@")[1]?.toLowerCase(); const profilePic = email && domain ? getProfilePictureUrl(email, domain, name) : null; const showFavicon = - senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !imgError; + senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !imgError && !domainFailed; // Priority: custom avatar > profile picture > company favicon > initials const customAvatar = email ? CUSTOM_AVATARS[email.toLowerCase()] : null; - const imgSrc = !imgError + const imgSrc = !imgError && !domainFailed ? customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(domain!)}` : null) - : null; + : (customAvatar || profilePic || null); + + const handleImgError = useCallback(() => { + setImgError(true); + // If this was a favicon URL (not a custom avatar or profile pic), remember the domain + if (domain && !customAvatar && !profilePic) { + failedFaviconDomains.add(domain); + } + }, [domain, customAvatar, profilePic]); return (
setImgError(true)} + onError={handleImgError} /> ) : ( getInitials()