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.