Best Practice di Sviluppo 13 min di lettura 20 novembre 2025

Tecniche di ottimizzazione delle performance per applicazioni web su larga scala

Una guida sistematica all'ottimizzazione delle performance per applicazioni web complesse: Core Web Vitals, lazy loading, code splitting, SSR/SSG, eliminazione delle query N+1, database indexes, connection pooling e pattern di caching distribuito con Redis.

LD

Luigi De Rosa

Senior Backend Engineer

Le performance non sono un'ottimizzazione da fare «quando si ha tempo». Sono una funzionalità di prodotto con impatto diretto sulle metriche di business: Google penalizza nei ranking le pagine lente, Amazon ha calcolato che ogni 100ms di latenza aggiunta costa l'1% di conversioni, e gli utenti mobile abbandonano le pagine che impiegano più di 3 secondi a caricare. Eppure la maggior parte delle applicazioni web che vediamo in produzione accumula debito di performance sistematicamente, un feature alla volta, fino a quando i problemi diventano troppo costosi da ignorare.

Questo articolo affronta l'ottimizzazione delle performance in modo sistematico, separando il frontend — dove le metriche sono definite dai Core Web Vitals di Google — dal backend, dove i colli di bottiglia classici sono le query N+1, l'assenza di indici e la gestione inefficiente delle connessioni al database. Entrambi gli strati richiedono strategie di caching intelligenti per scalare.

Core Web Vitals: misurare prima di ottimizzare

Google ha sostituito First Input Delay (FID) con Interaction to Next Paint (INP) nel marzo 2024, rendendo il set di metriche ancora più rappresentativo dell'esperienza utente reale. I tre Core Web Vitals correnti sono: Largest Contentful Paint (LCP) — il tempo al quale il contenuto principale della pagina diventa visibile, target sotto 2,5 secondi; Interaction to Next Paint (INP) — la latenza di risposta alle interazioni utente, target sotto 200 millisecondi; Cumulative Layout Shift (CLS) — la quantità di spostamento visivo inatteso del layout, target sotto 0.1. Solo il 47% dei siti web supera oggi queste soglie, con perdite stimate di 8-35% in conversioni per chi non le rispetta.

Core Web Vitals — Soglie di Performance 2025

LCP (Largest Contentful Paint)Buono < 2.5s · Da migliorare 2.5–4s · Scarso > 4s
INP (Interaction to Next Paint)Buono < 200ms · Da migliorare 200–500ms · Scarso > 500ms
CLS (Cumulative Layout Shift)Buono < 0.1 · Da migliorare 0.1–0.25 · Scarso > 0.25
TTFB (Time to First Byte)Raccomandato < 800ms — impatta direttamente LCP
FCP (First Contentful Paint)Buono < 1.8s — indicatore della reattività percepita iniziale

Il punto critico è misurare sul campo, non solo in laboratorio. Lighthouse e PageSpeed Insights mostrano dati su hardware standardizzato; i dati reali vengono dal Chrome User Experience Report (CrUX) e da soluzioni di Real User Monitoring (RUM). Un'applicazione che performa brillantemente su un MacBook Pro con fibra ottica può avere CWV catastrofici sul P75 dei dispositivi Android su rete 4G.

Ottimizzazione LCP: far arrivare velocemente il contenuto principale

L'LCP è quasi sempre dominato da un'immagine hero o da un blocco di testo grande. La prima ottimizzazione è identificare quale elemento è l'LCP element con i DevTools di Chrome, e poi eliminare tutto ciò che ne ritarda il caricamento. Le tecniche più impattanti sono: preload delle risorse critiche con link rel='preload', utilizzo dell'attributo fetchpriority='high' sull'immagine LCP, conversione delle immagini in WebP o AVIF — fino al 50% più leggere di JPEG — eliminazione del render-blocking CSS e JavaScript, e utilizzo di una CDN per ridurre la latenza geografica.

next-image-optimization.tsxtypescript
import Image from "next/image";
import dynamic from "next/dynamic";

// Next.js Image component handles automatically:
// - Format conversion (WebP/AVIF based on browser support)
// - Responsive sizing with srcset
// - CLS prevention via explicit width/height dimensions
// - Lazy loading (disabled for LCP images via the priority flag)

export function HeroSection({ product }: { product: Product }) {
  return (
    <section>
      {/* priority=true injects <link rel="preload"> and disables lazy loading */}
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={1200}
        height={630}
        priority // Critical: this IS the LCP element — never lazy load it
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 1200px"
        quality={85}
      />
    </section>
  );
}

// Code splitting: load heavy components only when needed
const ProductReviews = dynamic(() => import("./ProductReviews"), {
  loading: () => <ReviewsSkeleton />,
});

const RelatedProducts = dynamic(() => import("./RelatedProducts"));

// ISR: pre-render at build time, revalidate every hour in background
export async function generateStaticParams() {
  const products = await getTopProducts();
  return products.map((p) => ({ slug: p.slug }));
}

export const revalidate = 3600;

Strategie di rendering: SSG, SSR e ISR

La scelta della strategia di rendering ha un impatto enorme su LCP e TTFB. Static Site Generation (SSG) è la scelta ottimale per contenuti che cambiano raramente: la pagina viene pre-renderizzata a build time, servita direttamente dalla CDN senza alcun calcolo server-side, con TTFB inferiori a 50ms. Server-Side Rendering (SSR) è appropriato per contenuti personalizzati o che cambiano continuamente. Incremental Static Regeneration (ISR) di Next.js combina i due approcci: le pagine vengono servite dalla CDN e rigenerate in background quando i dati cambiano, bilanciando freschezza e performance.

  • SSG: blog, landing page, documentazione, cataloghi prodotto con aggiornamenti rari — TTFB sotto 50ms dalla CDN
  • ISR: e-commerce con prezzi e disponibilità che cambiano ogni ora — TTFB sotto 50ms con dati freschi via revalidation
  • SSR: dashboard personalizzate, feed utente, risultati di ricerca in tempo reale — TTFB dipende dalla velocità del server
  • CSR: single-page app con dati altamente dinamici e nessun requisito SEO — LCP penalizzato ma INP ottimale post-hydration

Il problema N+1: il veleno silenzioso del backend

Il problema N+1 è probabilmente il collo di bottiglia di performance più comune nelle applicazioni backend basate su ORM. Si verifica quando si recupera una lista di N entità e poi si esegue una query separata per ciascuna per recuperare le relazioni — risultando in 1 + N query invece di 1 o 2 ottimizzate. In produzione, N può essere centinaia o migliaia, e quello che sembra un endpoint veloce in sviluppo diventa un'operazione che impiega secondi. Il problema è insidioso perché rimane invisibile finché la tabella non cresce abbastanza da rendere le query singole percettibilmente lente.

n-plus-one-fix.tstypescript
// PROBLEM: N+1 queries with Prisma ORM
// Fetches posts then executes a separate query for each author
async function getPostsWithAuthorSlow() {
  const posts = await prisma.post.findMany({ take: 50 }); // 1 query
  return Promise.all(
    posts.map(async (post) => ({
      ...post,
      author: await prisma.user.findUnique({ where: { id: post.authorId } }),
    }))
  );
  // Total: 51 queries — catastrophic at scale
}

// SOLUTION 1: Eager loading with include — generates optimized JOIN or IN (...)
async function getPostsWithAuthorFast() {
  return prisma.post.findMany({
    take: 50,
    include: { author: { select: { id: true, name: true, email: true } } },
  });
  // Total: 1–2 queries regardless of post count
}

// SOLUTION 2: DataLoader — batches all loads within the same tick (ideal for GraphQL)
import DataLoader from "dataloader";

const userLoader = new DataLoader<string, User>(async (userIds) => {
  const users = await prisma.user.findMany({
    where: { id: { in: userIds as string[] } },
    select: { id: true, name: true, email: true },
  });
  // DataLoader requires results ordered identically to the input keys
  return userIds.map(
    (id) => users.find((u) => u.id === id) ?? new Error(`User ${id} not found`)
  );
});

// Each GraphQL resolver independently calls userLoader.load(post.authorId)
// DataLoader automatically coalesces all calls into one batched query per tick

Indici del database: la leva più potente

Un indice database ben posizionato può ridurre il tempo di una query da secondi a millisecondi senza cambiare una riga di codice applicativo. Eppure gli indici sono sistematicamente sottoutilizzati. La regola pratica è: ogni colonna usata in clausole WHERE, JOIN o ORDER BY su tabelle di dimensioni significative è un candidato per un indice. Gli indici composti sulle colonne usate insieme nelle query frequenti sono spesso più efficaci di più indici singoli. I covering index — che includono tutte le colonne necessarie a una query — possono eliminare completamente gli accessi al disco per le query più critiche.

index-optimization.sqlsql
-- Identify slow queries with EXPLAIN ANALYZE in PostgreSQL
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.created_at, u.email, p.name AS product_name
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'pending'
  AND o.created_at > NOW() - INTERVAL '7 days'
ORDER BY o.created_at DESC;

-- Compound index on status + created_at (most selective first)
-- INCLUDE adds user_id to leaf pages — avoids heap fetch (covering index)
CREATE INDEX CONCURRENTLY idx_orders_status_created
  ON orders (status, created_at DESC)
  INCLUDE (user_id);

-- Partial index: only indexes pending orders — much smaller, faster writes
CREATE INDEX CONCURRENTLY idx_orders_pending_recent
  ON orders (created_at DESC)
  WHERE status = 'pending';

-- Foreign key indexes — often forgotten, causes sequential scans on large joins
CREATE INDEX CONCURRENTLY idx_order_items_order_id   ON order_items (order_id);
CREATE INDEX CONCURRENTLY idx_order_items_product_id ON order_items (product_id);

-- Find unused indexes: they cost writes without benefiting reads
SELECT schemaname, tablename, indexname, idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;

Connection Pooling: non sprecare connessioni

Aprire una nuova connessione al database per ogni richiesta HTTP è costoso: il TCP handshake, l'autenticazione e la negoziazione del protocollo possono richiedere 20-100ms. In un'applicazione che gestisce 1000 richieste al secondo, questo significherebbe 1000 connessioni simultanee al database — ben oltre la capacità di PostgreSQL di gestirle efficientemente. Il connection pooling risolve questo problema mantenendo un pool di connessioni pre-aperte e riutilizzandole tra le richieste. Ricerche mostrano che un corretto connection pooling può ridurre il tempo di transazione del 72% in applicazioni ad alto carico.

Per applicazioni Node.js, PgBouncer è lo standard de facto come proxy di pooling esterno con modalità transaction pooling. Per applicazioni serverless e edge function, dove il numero di istanze è imprevedibile, strumenti come Prisma Accelerate o Supabase Pooler gestiscono il pooling a livello di infrastruttura — essenziale dato che ogni istanza Lambda tenderebbe ad aprire la propria connessione, saturando il database in pochi minuti sotto carico.

Pattern di caching distribuito con Redis

Il caching è il moltiplicatore di performance più potente, ma anche la fonte principale di bug difficili da diagnosticare in produzione. Redis è lo standard de facto per il caching distribuito nelle applicazioni moderne: supporta strutture dati ricche, TTL per entry, pub/sub per la invalidazione e clustering per la scalabilità. I tre pattern fondamentali da padroneggiare sono Cache-Aside (lazy loading), invalidazione su scrittura e la gestione del cache stampede.

redis-cache-patterns.tstypescript
import { Redis } from "ioredis";

const redis = new Redis({ host: process.env.REDIS_HOST, enableAutoPipelining: true });

// PATTERN 1: Cache-Aside — populate cache only on demand
async function getProductCacheAside(productId: string): Promise<Product> {
  const cacheKey = `product:v1:${productId}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached) as Product;

  const product = await db.product.findUniqueOrThrow({ where: { id: productId } });
  await redis.setex(cacheKey, 300, JSON.stringify(product)); // 5-minute TTL
  return product;
}

// PATTERN 2: Invalidate on write — keep cache consistent
async function updateProduct(productId: string, data: Partial<Product>): Promise<Product> {
  const updated = await db.product.update({ where: { id: productId }, data });

  const pipeline = redis.pipeline();
  pipeline.del(`product:v1:${productId}`);
  pipeline.del(`product:v1:${productId}:related`);
  pipeline.del(`category:${updated.categoryId}:products`); // invalidate listing cache
  await pipeline.exec();

  return updated;
}

// PATTERN 3: Mutex to prevent cache stampede
// Without this, many concurrent requests on a cold key all hit the DB simultaneously
async function getProductWithLock(productId: string): Promise<Product> {
  const cacheKey = `product:v1:${productId}`;
  const lockKey = `lock:${cacheKey}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // NX = only set if not exists | EX = expire in 5 seconds
  const acquired = await redis.set(lockKey, "1", "NX", "EX", 5);
  if (!acquired) {
    // Another request is rebuilding the cache — back off and retry
    await new Promise((resolve) => setTimeout(resolve, 50));
    return getProductWithLock(productId);
  }

  try {
    const product = await db.product.findUniqueOrThrow({ where: { id: productId } });
    await redis.setex(cacheKey, 300, JSON.stringify(product));
    return product;
  } finally {
    await redis.del(lockKey); // Always release, even on error
  }
}

Priorità di intervento: dove iniziare

Con tante aree di ottimizzazione possibili, la domanda pratica è: da dove si inizia? La risposta è sempre la stessa: dalla misurazione. Strumenti come Datadog APM, New Relic o il semplice slow query log di PostgreSQL rivelano dove il sistema trascorre effettivamente il tempo. In quasi tutti i sistemi che abbiamo analizzato, il 90% della latenza è concentrata nel 10% delle operazioni. Identificare e risolvere questi colli di bottiglia specifici produce risultati drammaticamente migliori rispetto all'ottimizzazione distribuita su tutta la codebase.

  1. 1Misura con dati reali: attiva il slow query log del database con soglia 100ms, usa RUM per i CWV reali e APM per le trace applicative
  2. 2Risolvi il problema N+1: è quasi sempre il collo di bottiglia più impattante nelle applicazioni ORM-based
  3. 3Aggiungi indici sulle query lente: usa EXPLAIN ANALYZE e crea indici mirati, preferendo gli indici parziali e covering per le tabelle grandi
  4. 4Implementa connection pooling: PgBouncer per PostgreSQL in produzione, pooling gestito per ambienti serverless
  5. 5Aggiungi caching a livello applicativo: Cache-Aside con Redis per dati letti frequentemente e modificati raramente
  6. 6Ottimizza le immagini e usa priority hints per l'elemento LCP della pagina principale
  7. 7Misura di nuovo: verifica che le ottimizzazioni abbiano prodotto i miglioramenti attesi sui CWV reali degli utenti

Conclusioni

L'ottimizzazione delle performance non è un progetto da fare una volta sola — è una disciplina continua. I sistemi che performano bene nel lungo periodo sono quelli con metriche di performance integrate nel processo di sviluppo: benchmark nelle pipeline CI/CD, alerting sui percentili di latenza p95 e p99, budget di performance per il bundle JavaScript e revisione regolare dei piani di esecuzione delle query più frequenti. La performance non degrada improvvisamente: degrada una feature alla volta, un deploy alla volta. Trattarla come un requisito non funzionale di prima classe, con tanto di test di regressione, è l'unico modo per non ritrovarsi a fare re-architecture emergenziale.

Tag

PerformanceCore Web VitalsCachingDatabaseFrontendSSRRedisN+1 Queries

Prossimo passo

Hai bisogno di implementare questa architettura?

Il nostro team di ingegneria costruisce e scala i sistemi descritti in questo articolo. Dalla discovery alla produzione — con risultati misurabili.