.NET + React : 7 optimisations simples qui font vraiment la différence

Sur des apps .NET + React, 80% du « ressenti de vitesse » vient d’un petit nombre de décisions techniques. Voici les 7 optimisations que je mets systématiquement en place chez mes clients, avec exemples concrets.


1) API rapide avant tout

Commencez par la base : endpoints clairs, requêtes minimales, index en base, et compression HTTP.

Compression & Minimal APIs (ASP.NET Core)

// Program.cs
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(o => 
    o.Level = System.IO.Compression.CompressionLevel.Optimal);
builder.Services.Configure<GzipCompressionProviderOptions>(o => 
    o.Level = System.IO.Compression.CompressionLevel.Optimal);

var app = builder.Build();
app.UseResponseCompression();

// Minimal endpoint clair et rapide
app.MapGet("/api/products/{id:int}", async (int id, AppDb db, CancellationToken ct) =>
{
    var dto = await db.Products
        .Where(p => p.Id == id)
        .Select(p => new { p.Id, p.Name, p.Price })
        .FirstOrDefaultAsync(ct);

    return dto is not null ? Results.Ok(dto) : Results.NotFound();
});

app.Run();

Index en base (EF Core)

// In DbContext.OnModelCreating:
modelBuilder.Entity<Product>()
    .HasIndex(p => p.Sku)
    .HasDatabaseName("IX_Product_Sku")
    .IsUnique();

SQL : éviter la logique excessive

  • Préférez des SELECT ciblés (colonnes nécessaires uniquement).
  • Pas de transformation lourde côté BDD : faites-la en code si possible.
  • Paginer dès que la table dépasse quelques milliers de lignes.

2) Mise en cache intelligente

Utilisez le bon cache pour le bon besoin : mémoire (rapide), distribué (Redis), et HTTP (ETag / Last-Modified).

Cache en mémoire (résultats peu volatils)

builder.Services.AddMemoryCache();

public class CatalogService(IMemoryCache cache, AppDb db)
{
    public async Task<IReadOnlyList<Category>> GetCategoriesAsync(CancellationToken ct)
    {
        return await cache.GetOrCreateAsync("categories:v1", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
            return await db.Categories.OrderBy(c => c.Name).ToListAsync(ct);
        }) ?? [];
    }
}

ETag / Last-Modified

app.MapGet("/api/categories", async (HttpContext ctx, CatalogService svc) =>
{
    var data = await svc.GetCategoriesAsync(ctx.RequestAborted);
    var json = System.Text.Json.JsonSerializer.Serialize(data);

    // ETag simple (hash des 8 premiers octets)
    var hash = System.Security.Cryptography.SHA256.HashData(
        System.Text.Encoding.UTF8.GetBytes(json));
    var etag = $"W/\"{Convert.ToHexString(hash.AsSpan(0, 8))}\"";

    if (ctx.Request.Headers.IfNoneMatch == etag)
        return Results.StatusCode(304);

    ctx.Response.Headers.ETag = etag;
    ctx.Response.Headers.CacheControl = "public, max-age=900";
    return Results.Text(json, "application/json");
});

CDN : servez vos assets (images, JS, CSS) via CloudFront/Cloudflare ; mettez Cache-Control long (1 semaine+) et versionnez les fichiers.


3) Découper le bundle React

Chargez uniquement ce qui est nécessaire, au moment nécessaire.

Code splitting par route

// router.tsx
import { lazy, Suspense } from "react";
const Admin = lazy(() => import("./pages/Admin"));
const Home  = lazy(() => import("./pages/Home"));

<Routes>
  <Route path="/" element={
    <Suspense fallback={<div className="skeleton"/>}><Home /></Suspense>}
  />
  <Route path="/admin" element={
    <Suspense fallback={<div className="skeleton"/>}><Admin /></Suspense>}
  />
</Routes>

Découpage par composant lourd

const Chart = React.lazy(() => import("./components/Chart"));

<Suspense fallback={<div className="skeleton-chart"/>}>
  <Chart data={data} />
</Suspense>

Astuce : préchargez à l’hover le prochain écran (link prefetch) ; utilisez vite ou webpack pour analyser les chunks et réduire les « commons » trop lourds.


4) Images optimisées

  • Convertir en WebP/AVIF et fournir des tailles multiples (srcset).
  • Ne jamais afficher une image 3000px dans une carte 300px.
  • loading="lazy" + dimensions figées pour éviter le CLS.
<img
  src="/img/hero-960.webp"
  srcset="/img/hero-640.webp 640w, /img/hero-960.webp 960w, /img/hero-1440.webp 1440w"
  sizes="(max-width: 900px) 90vw, 640px"
  alt="Dashboard .NET + React"
  width="960" height="540"
  loading="lazy" decoding="async"
/>

5) Ne pas sur-solliciter l’API

Moins d’appels, mieux groupés, annulables et mis en cache côté client.

Debounce d’une recherche (React)

import { useEffect, useState } from "react";

export function SearchBox() {
  const [q, setQ] = useState("");
  const [debounced, setDebounced] = useState(q);

  useEffect(() => {
    const t = setTimeout(() => setDebounced(q), 350);
    return () => clearTimeout(t);
  }, [q]);

  useEffect(() => {
    if (!debounced) return;
    // fetch('/api/search?q=' + encodeURIComponent(debounced))
  }, [debounced]);

  return <input value={q} onChange={e => setQ(e.target.value)} placeholder="Rechercher..." />;
}

Annulation de requête et cache (react-query)

import { useQuery } from "@tanstack/react-query";

function useProduct(id: number) {
  return useQuery({
    queryKey: ["product", id],
    queryFn: ({ signal }) => fetch(`/api/products/${id}`, { signal }).then(r => r.json()),
    staleTime: 60_000, // 1 min
  });
}

Côté API : pagination et endpoints « batch »

app.MapGet("/api/products", async (int page = 1, int pageSize = 20, AppDb db, CancellationToken ct) =>
{
    page     = Math.Max(1, page);
    pageSize = Math.Clamp(pageSize, 10, 100);

    var total = await db.Products.CountAsync(ct);
    var items = await db.Products
        .OrderBy(p => p.Id)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new { p.Id, p.Name, p.Price })
        .ToListAsync(ct);

    return Results.Ok(new { total, page, pageSize, items });
});

WebSocket/SignalR seulement si le temps réel est utile (chat, trading, monitoring). Sinon : polling raisonnable + ETag.


6) Surveillance & métriques

Mesurez pour savoir quoi améliorer.

Lighthouse (CI/CD)

  • Ajoutez un job CI qui lance Lighthouse sur quelques URLs et échoue sous un seuil (Perf > 85).

OpenTelemetry + Application Insights (ASP.NET Core)

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSqlClientInstrumentation()
        .AddSource("MyApp"))
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation());

builder.Services.AddApplicationInsightsTelemetry(); // instrumentation key via config

Web Vitals (front)

import { onCLS, onLCP, onTTFB } from "web-vitals";
onTTFB(console.log);
onLCP(console.log);
onCLS(console.log);

7) Focaliser sur l’expérience

  • Placeholders/skeletons pour masquer la latence perçue.
  • Optimistic UI pour les petites actions (« like », « star », etc.).
  • Stabiliser la mise en page (largeur/hauteur fixées, font-display: swap).
  • Précharger ce qui arrive vraiment (hover, near-viewport).
// Optimistic toggle (react-query)
function useToggleFavorite(id: number) {
  const q = useQueryClient();
  return useMutation({
    mutationFn: () => fetch(`/api/fav/${id}`, { method: "POST" }),
    onMutate: async () => {
      await q.cancelQueries({ queryKey: ["product", id] });
      const prev = q.getQueryData(["product", id]);
      q.setQueryData(["product", id], (d: any) => ({ ...d, favorite: !d.favorite }));
      return { prev };
    },
    onError: (_e, _v, ctx) => ctx?.prev && q.setQueryData(["product", id], ctx.prev),
    onSettled: () => q.invalidateQueries({ queryKey: ["product", id] })
  });
}

Bonus — .NET 8 Minimal API : assistant IA « sûr »

// Exemple simplifié (OpenAI Chat Completions)
app.MapPost("/api/assist", async (AssistRequest req, IOptions<OpenAIOptions> cfg, HttpClient http) =>
{
    // 1) Récupérer le contexte (RAG) — pseudo
    var context = await LoadRelevantChunksAsync(req.Query);

    // 2) Construire le prompt sûr
    var system = "Tu es un assistant métier.\n" +
                 "- Réponds factuellement en français.\n" +
                 "- Cite les sources (titres + liens) si tu t'appuies sur des docs internes.\n" +
                 "- Si l'info manque, dis-le et propose une démarche.";

    var user = $"Question: {req.Query}\nContexte (extraits): {context}";

    // 3) Appel modèle
    var payload = new
    {
        model    = "gpt-4o-mini",
        messages = new object[]
        {
            new { role = "system", content = system },
            new { role = "user",   content = user   }
        },
        temperature = 0.2
    };

    using var msg = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions")
    {
        Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(payload),
                                    System.Text.Encoding.UTF8, "application/json")
    };
    msg.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", cfg.Value.ApiKey);

    var res  = await http.SendAsync(msg, req.HttpContext.RequestAborted);
    var json = await res.Content.ReadAsStringAsync();
    return Results.Text(json, "application/json");
});

Checklist rapide

  • Compression Brotli/Gzip activée, endpoints minimalistes, index BDD OK.
  • Cache mémoire/Redis + ETag/Last-Modified + CDN assets.
  • React lazy/Suspense, route-chunks propres, skeletons.
  • Images WebP/AVIF, srcset, loading="lazy", dimensions fixes.
  • Debounce, annulation requêtes, pagination serveur, endpoints batch.
  • Lighthouse en CI, OpenTelemetry + App Insights, Web Vitals côté front.
  • Optimistic UI, prefetch à l’hover, layout stable (anti-CLS).

Ce sont les 7 points que je check en priorité sur chaque projet client. En appliquant ces principes, on obtient très vite une app plus rapide, plus stable et beaucoup plus agréable à utiliser.

← Retour au blog