Approfondimento

Ottimizzazione delle performance per la Generative UI

Come rendere veloci le interfacce AI: strategie di streaming, ottimizzazione del bundle e pattern di rendering. Con numeri reali dalla produzione.

A
Alex13 min di lettura

Il paradosso delle performance

Il paradosso è semplice: 300ms possono sembrare un'eternità, mentre 1,2 secondi possono sembrare un istante. Nella Generative UI non è un'affermazione teorica. In produzione ho vissuto un caso in cui passare da una cache in memoria allo streaming di skeleton ha ridotto di tre volte il tempo di caricamento percepito — pur avendo il tempo totale fino al componente finale aumentato di 80ms.

L'inferenza LLM richiede 200–800ms per una risposta semplice e diversi secondi per quelle con più strumenti. CDN, SSG e caching edge non possono eliminare questa latenza: la fase decisionale dell'LLM si trova nel percorso critico di ogni richiesta. Ma questo non significa che l'interfaccia debba sembrare lenta.

Questo articolo non è una lista di "10 consigli sulle performance". È un tentativo di distinguere quando vale la pena ottimizzare da quando si tratta di autoinganni e gold plating ingegneristico, e quale strategia risolve quale problema specifico. Con numeri reali dalla mia produzione, non da benchmark di post sul blog.

Quando NON ottimizzare

Prima di leggere le sei strategie qui sotto, rispondi a tre domande:

  1. Hai misurato le performance attuali? Se no, chiudi questa scheda e imposta il tracciamento di TTFC/TTIC. Metà dei clienti che arrivavano da me con "tutto è lento" scoprivano un p50 di 600ms e utenti impazziti per il layout instabile (CLS), non per la latenza.
  2. Il tuo p95 è già sotto 1,5 secondi? Lo streaming di skeleton e la UI ottimistica ti daranno circa il 20% di miglioramento percepito — ma costeranno una settimana di lavoro. Meglio spendere quella settimana sulle funzionalità.
  3. Hai meno di 100 utenti attivi al giorno? Una cache Redis su due richieste al minuto è cargo cult infrastrutturale. Una Map in memoria regge tranquillamente quel carico per un altro anno e mezzo.

L'ottimizzazione non è "sempre positiva". Ogni strategia qui sotto aggiunge complessità, punti di guasto e carico cognitivo. Se sei uno sviluppatore solista e il prodotto sta ancora cercando il PMF — usa lo streaming di skeleton (Strategia 1) e per ora non fare altro. Tutto il resto è prematuro.

Tabella dei compromessi

Sei strategie, il loro costo e quando si ripagano:

StrategiaComplessitàGuadagno TTFCGuadagno TTICQuando applicare
1. Streaming skeletonBassa (ore)−400…600ms0Sempre, se usi streamUI
2. Tool call in paralleloBassa (ore)0−30…50%Con ≥2 fetch indipendenti dentro generate
3. Caching delle risposteMedia (giorni)0−500…800ms su cache hitQuery ripetute ≥10×/giorno/utente
4. Selezione del modelloBassa (ore)0−200…500msSelezione semplice degli strumenti senza ragionamento
5. Ottimizzazione del bundleMedia (giorni)−100…300ms (cold load)0Bundle > 200KB o audience prevalentemente mobile
6. UI ottimisticaMedia (giorni)−150…250ms0Query prevedibili per parole chiave

Se dovessi classificare per rapporto "beneficio ÷ complessità" su un prodotto con traffico reale, l'ordine sarebbe: 1 → 4 → 2 → 6 → 3 → 5. Le strategie 3 e 5 si ripagano più tardi di quanto sembri e sono state le mie "settimana sprecata" più volte.

Le metriche che contano

Prima di ottimizzare, definisci cosa stai misurando.

Time to First Component (TTFC): Quanto tempo passa prima che l'utente veda qualsiasi elemento generato dall'AI, anche uno stato di caricamento. Obiettivo: sotto i 200ms. È raggiungibile con lo streaming immediato dello skeleton mentre l'inferenza è in corso.

Time to Interactive Component (TTIC): Quanto tempo passa prima che appaia il primo componente reale con i dati. Obiettivo: sotto gli 800ms. Questo corrisponde al completamento dell'inferenza dell'LLM per la prima tool call.

Streaming Completion Time: Quanto tempo prima che tutti i componenti generati siano caricati. Dipende dal numero di tool call. Con lo streaming, questa metrica è meno importante di TTFC e TTIC.

Layout Shift Score (CLS): I componenti generati non devono spostare il layout della pagina durante il caricamento. Gli skeleton devono corrispondere alle dimensioni del componente finale.

Strategia 1: streaming immediato degli skeleton

La singola ottimizzazione con il maggiore impatto è trasmettere in streaming uno skeleton di caricamento prima che l'LLM risolva il primo parametro. Il pattern a generatore del Vercel AI SDK lo abilita direttamente:

tools: {
  revenueChart: {
    description: 'Display a revenue chart',
    parameters: z.object({
      period: z.string(),
      data: z.array(z.object({ date: z.string(), value: z.number() })),
    }),
    generate: async function* (params) {
      // Questo yield avviene IMMEDIATAMENTE — prima che i parametri siano risolti
      // Lo skeleton appare al tempo zero
      yield <ChartSkeleton />;

      // Opzionalmente recupera i dati reali mentre l'AI risolve i parametri
      // Il componente appare quando entrambi sono pronti
      return <RevenueChart {...params} />;
    },
  },
}

L'istruzione yield viene eseguita in modo sincrono. L'utente vede lo skeleton nello stesso round trip della richiesta iniziale. L'inferenza dell'LLM avviene in parallelo. Ecco perché il TTFC può essere sotto i 200ms anche quando il TTIC è a 800ms.

Dettaglio critico: Lo skeleton deve corrispondere alle dimensioni del componente finale. Se lo skeleton è alto 100px e il componente caricato è alto 300px, si genera un layout shift che peggiora il CLS e risulta fastidioso.

// Male: skeleton generico che non corrisponde alle dimensioni del componente
yield <div className="h-8 animate-pulse bg-muted rounded" />;

// Bene: skeleton che replica la struttura del componente
yield (
  <div className="rounded-lg border p-6 h-64">
    <div className="h-4 w-32 animate-pulse bg-muted rounded mb-4" />
    <div className="h-48 w-full animate-pulse bg-muted rounded" />
  </div>
);

Strategia 2: tool call in parallelo

Quando l'AI deve chiamare più strumenti, devono essere eseguiti in parallelo. Il Vercel AI SDK lo gestisce automaticamente — più tool call in una singola risposta eseguono le loro funzioni generate in modo concorrente.

Ma il data fetching del tuo componente non deve bloccare:

// Lento: data fetching sequenziale all'interno di generate
generate: async function* ({ userId, period }) {
  yield <DashboardSkeleton />;
  const revenue = await fetchRevenue(userId, period);      // 200ms
  const users = await fetchUsers(userId, period);          // 150ms
  const conversions = await fetchConversions(userId);      // 100ms
  // Totale: ~450ms
  return <Dashboard revenue={revenue} users={users} conversions={conversions} />;
},

// Veloce: data fetching in parallelo
generate: async function* ({ userId, period }) {
  yield <DashboardSkeleton />;
  const [revenue, users, conversions] = await Promise.all([
    fetchRevenue(userId, period),
    fetchUsers(userId, period),
    fetchConversions(userId),
  ]);
  // Totale: ~200ms (dipende dal fetch più lento)
  return <Dashboard revenue={revenue} users={users} conversions={conversions} />;
},

Per sorgenti dati indipendenti, Promise.all è sempre più veloce degli await sequenziali.

Strategia 3: caching delle risposte

Molte query Generative UI vengono ripetute. "Mostrami la dashboard dei ricavi di questo mese" viene eseguita decine di volte al giorno per lo stesso utente con gli stessi dati sottostanti.

Metti in cache a livello di risposta LLM, usando come chiave un hash del prompt e del contesto rilevante:

import { createHash } from 'crypto';

interface CacheEntry {
  value: React.ReactNode;
  cachedAt: number;
  ttlMs: number;
}

const responseCache = new Map<string, CacheEntry>();

function getCacheKey(prompt: string, context: object): string {
  return createHash('md5')
    .update(prompt + JSON.stringify(context))
    .digest('hex');
}

export async function generateUIWithCache(
  prompt: string,
  context: object = {},
  ttlMs: number = 5 * 60 * 1000  // 5 minutes default
) {
  const key = getCacheKey(prompt, context);
  const cached = responseCache.get(key);

  if (cached && Date.now() - cached.cachedAt < cached.ttlMs) {
    return cached.value;
  }

  const result = await streamUI({ /* ... */ });
  responseCache.set(key, { value: result.value, cachedAt: Date.now(), ttlMs });
  return result.value;
}

Per la produzione, usa Redis invece di una Map in memoria. Valuta Vercel KV o Upstash Redis per il caching su edge.

Importante: L'invalidazione della cache deve corrispondere alla frequenza di aggiornamento dei dati. Una dashboard dei ricavi con 5 minuti di cache va bene. Un ticker azionario in tempo reale con 5 minuti di cache è un errore.

Strategia 4: selezione del modello

Non ogni query richiede GPT-4o. La selezione del modello è l'ottimizzazione con il miglior rapporto costo/latenza disponibile.

ModelloLatenzaCostoQualità
GPT-4o400–800msAltoMigliore
GPT-4o-mini200–400ms10x più economicoBuona
Claude Haiku150–300ms5x più economicoBuona
Gemini Flash100–200ms5x più economicoBuona

Per la maggior parte dei compiti di selezione degli strumenti nella Generative UI, GPT-4o-mini o Claude Haiku producono risultati indistinguibili da GPT-4o. Riserva i modelli frontier per ragionamenti complessi a più passi.

// Route to appropriate model based on query complexity
function selectModel(toolCount: number, contextLength: number) {
  if (toolCount <= 5 && contextLength < 500) {
    return openai('gpt-4o-mini');
  }
  return openai('gpt-4o');
}

Strategia 5: ottimizzazione del bundle

Le librerie di componenti Generative UI possono crescere molto. Ogni componente nel registro degli strumenti viene distribuito al browser. Gestisci questo aspetto attivamente.

Carica in lazy i componenti non critici:

// Importa i componenti grafici pesanti solo quando necessario
const HeavyChartComponent = dynamic(
  () => import('@/components/heavy-chart'),
  { loading: () => <ChartSkeleton /> }
);

Separa il bundle dei componenti dal registro degli strumenti:

// Tool registry: leggero, distribuito presto
export const toolDefinitions = {
  revenueChart: {
    description: '...',
    parameters: z.object({ ... }),
  },
};

// Implementazioni dei componenti: caricate in lazy quando necessario
export const toolComponents = {
  revenueChart: dynamic(() => import('@/components/revenue-chart')),
};

Misura il tuo bundle. Esegui npx @next/bundle-analyzer e cerca componenti sproporzionatamente grandi. Una singola libreria di grafici può aggiungere 50KB+ al bundle.

Strategia 6: UI ottimistica

Per le query che il sistema può prevedere, mostra una UI ottimistica prima che l'AI risponda:

export function useGenerativeUI() {
  const [ui, setUI] = useState<React.ReactNode>(null);
  const [optimisticUI, setOptimisticUI] = useState<React.ReactNode>(null);

  async function generate(prompt: string) {
    // Mostra immediatamente uno skeleton plausibile in base al tipo di query
    if (prompt.toLowerCase().includes('weather')) {
      setOptimisticUI(<WeatherCardSkeleton />);
    } else if (prompt.toLowerCase().includes('stock') || prompt.toLowerCase().includes('price')) {
      setOptimisticUI(<StockTickerSkeleton />);
    } else {
      setOptimisticUI(<GenericSkeleton />);
    }

    const result = await generateUI(prompt);
    setOptimisticUI(null);
    setUI(result);
  }

  return { ui: optimisticUI ?? ui, generate };
}

Il semplice keyword matching sul client ha latenza zero. Mostrare uno skeleton meteo nel momento in cui l'utente invia una query meteo risulta percettivamente molto più veloce che aspettare il round trip al server.

Impatto sui Core Web Vitals

La Generative UI influenza i Core Web Vitals. Ecco cosa monitorare:

Largest Contentful Paint (LCP): Se il contenuto principale è generato dall'AI, l'LCP rifletterà l'intero tempo di generazione. Mitigalo generando prima il contenuto above-the-fold e usando lo streaming per renderizzare la pagina progressivamente.

Cumulative Layout Shift (CLS): Il rischio maggiore. Se gli skeleton non corrispondono alle dimensioni dei componenti, ogni caricamento causa layout shift. Usa min-height sui container degli skeleton per riservare lo spazio.

Interaction to Next Paint (INP): Assicurati che la generazione AI sia attivata da azioni dell'utente (click su pulsanti, invio di form), non da eventi passivi al caricamento della pagina. La generazione passiva può bloccare la gestione delle interazioni.

First Input Delay / INP: Non eseguire streamUI direttamente in un event handler React. È un'operazione asincrona di lunga durata. Mantieni l'event handler veloce:

// Potenzialmente lento: streamUI blocca l'handler
async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  const result = await streamUI({ ... }); // blocca
  setUI(result.value);
}

// Meglio: avvia l'operazione asincrona, aggiorna lo stato quando pronto
function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setLoading(true);
  generateUI(prompt).then(ui => {
    setUI(ui);
    setLoading(false);
  });
}

Misurare ciò che si ottimizza

Senza misurazione, l'ottimizzazione è approssimativa. Aggiungi il tracciamento delle performance fin dall'inizio:

export async function generateUIWithMetrics(prompt: string) {
  const startTime = performance.now();

  const result = await streamUI({
    /* ... */
    onFinish: ({ toolCalls }) => {
      const totalTime = performance.now() - startTime;

      // Invia alla tua piattaforma di analytics / osservabilità
      track('genui.generation_complete', {
        prompt_length: prompt.length,
        tool_calls_count: toolCalls.length,
        total_ms: Math.round(totalTime),
        tools_used: toolCalls.map(c => c.toolName),
      });
    },
  });

  return result.value;
}

Traccia TTFC e TTIC separatamente: cronometra lo yield dello skeleton e il return del componente finale. Dopo una settimana di dati avrai un quadro chiaro di dove va realmente il tempo.

Anti-pattern in cui sono già incappato

Sei casi in cui "ottimizzare" peggiora invece di migliorare — tutti errori che ho commesso in produzione:

1. Mettere in cache una risposta LLM non deterministica con un hash del prompt. GPT-4o con temperature=0.7 restituirà UI diverse per lo stesso prompt. La cache "funzionerà", ma l'utente vedrà un'interfaccia diversa da quella della chiamata precedente — peggio di una risposta lenta ma coerente. Soluzione: metti in cache solo con temperature=0, o includi prompt + temperature + seed nella chiave.

2. Skeleton che differisce molto dal componente finale. Ho visto in produzione: skeleton di una tabella da 5 righe, tabella finale da 50. Il CLS è schizzato, l'utente riesce a cliccare nel posto sbagliato e si arrabbia. Soluzione: min-height sul container basato sulla dimensione media + rendering lazy delle righe con virtual list.

3. Fare streaming di uno skeleton che l'utente vede per meno di 50ms. Su una rete veloce con p50 = 250ms di TTFC, lo skeleton lampeggia e sparisce — è più fastidioso di un caricamento pulito. Soluzione: aggiungi un ritardo di 100ms prima di mostrare lo skeleton (setTimeout), oppure non mostrarlo affatto su reti veloci (navigator.connection.effectiveType).

4. UI ottimistica che non converge con la risposta reale. Hai mostrato lo skeleton meteo, ma l'AI ha deciso che la query era in realtà sulle notizie — l'utente vede un salto. Soluzione: UI ottimistica solo per i trigger più ovvi (corrispondenza esatta di parole, non sottostringa), con graceful fallback su uno skeleton generico in caso di mancata corrispondenza.

5. Cache Redis con TTL di 5 minuti su dati personalizzati. Chiave senza userId — e l'utente A vede la dashboard dell'utente B. È una data leak, non un bug di performance. Soluzione: userId è sempre parte della chiave, namespace separati per dati pubblici/privati, audit log sui cache hit.

6. GPT-4o-mini per la classificazione degli intenti con 50+ strumenti. I modelli mini si perdono nei tool registry lunghi — iniziano a invocare strumenti inappropriati. Il risparmio in latenza si traduce in un aumento degli errori. Soluzione: per tool registry con più di 20 strumenti, usa GPT-4o oppure suddividi il registry per dominio con un router.

Configurazione Redis per la produzione

Se sei arrivato alla Strategia 3 e ne hai davvero bisogno, ecco la configurazione che funziona nel mio caso:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: 2,
  enableReadyCheck: true,
  // Non bloccare la richiesta più di 50ms per il cache lookup — meglio ricalcolare
  commandTimeout: 50,
});

// Il TTL si calibra sulla frequenza di aggiornamento dei dati, non su "5 minuti per tutto"
const TTL_BY_DATA_TYPE = {
  staticReference: 24 * 60 * 60,    // 24h: riferimenti, documentazione
  userDashboard:   5 * 60,          // 5min: dati personali
  marketData:      30,              // 30s: quotazioni, notizie
  realtime:        0,                // 0: non mettere in cache, dati in streaming
};

// Eviction policy in redis.conf: allkeys-lru
// maxmemory 512mb (per un MVP tipico)
// maxmemory-policy allkeys-lru

La policy di eviction allkeys-lru è più importante di quanto sembri: senza di essa Redis, quando si riempie, inizierà a rifiutare la scrittura di nuove chiavi — ovvero un fail lento invece di un graceful degradation. Gestisci l'invalidazione della cache tramite pattern (redis.del('user:123:*') via SCAN), non tramite point-delete — è molto più affidabile sulle chiavi calde.

Numeri reali dalla mia produzione

Dati da uno dei miei prodotti su Generative UI (~2000 DAU, dashboard con 8 strumenti, regione US East, gennaio 2026):

MetricaPrima dell'ottimizzazioneDopo le strategie 1+2+4Dopo tutte e 6
TTFC p50580ms145ms90ms
TTFC p951100ms320ms240ms
TTIC p501400ms720ms380ms (cache hit)
TTIC p952800ms1500ms1300ms (cache miss)
CLS0,180,040,03
Costo per richiesta$0,012$0,002$0,0015
Complessità del codicebaseline+~150 LOC+~600 LOC + Redis

Osservazione principale: le strategie 1+2+4 hanno prodotto l'80% del guadagno con il 20% della complessità. Le strategie 3, 5 e 6 hanno portato il restante 20% di miglioramento con l'80% della complessità aggiuntiva. Se non hai un team che gestisce un cluster Redis e un SLA sull'invalidazione della cache, le strategie 1+2+4 sono il punto di arrivo, non intermedio.

Il cambiamento architetturale di cui non si parla

Se stai passando da "single render" (una pagina — una risposta) a "progressive delivery" (streaming + skeleton), non si tratta di un'ottimizzazione — è un cambio di architettura. Cosa cambia:

  • Il codice server si scrive come generatori asincroni, non come funzioni normali — un modello mentale diverso.
  • Gli error boundary in React si comportano diversamente per i contenuti in streaming — servono componenti di fallback a ogni livello.
  • SEO e SSR richiedono una strategia separata: il contenuto AI in streaming non viene indicizzato per default.
  • I test si complicano: snapshot test sugli stati intermedi degli skeleton più sul render finale.

Ho sottovalutato il costo di questo cambiamento nel primo progetto — avevo previsto 2 giorni, ne ho impiegati 2 settimane. Nel secondo progetto ho previsto 2 settimane dall'inizio e ho finito in tempo. Se il tuo prodotto non fa streaming adesso e stai pianificando la Strategia 1, calcola settimane, non ore.


Hai sfide di performance con la GenUI? Parliamone — l'ottimizzazione sull'intero stack è una nostra specializzazione.

CondividiTwitterLinkedInEmail
performanceoptimizationstreaminggenerative-ui
A

Alex

Ingegnere e Consulente Generative UI

Ingegnere senior specializzato in interfacce AI e sistemi Generative UI. Aiuta i team di prodotto a rilasciare più velocemente con il giusto stack GenUI.

Resta aggiornato su Generative UI

Articoli settimanali, aggiornamenti sui framework e guide pratiche di implementazione — direttamente nella tua casella di posta.

Rispettiamo la tua privacy. Disiscrizione in qualsiasi momento.

Hai bisogno di aiuto per implementare quello che hai appena letto?

Prenota una consulenza gratuita