Skip to content

Performance Tuning Guide

Version: 1.0 Last Updated: 2026-01-18


Overview

This guide provides comprehensive performance optimization recommendations for Cesivi Server. It covers database optimization, caching strategies, connection pooling, thread configuration, and load testing.

Related Documentation: - PRODUCTION_DEPLOYMENT_CHECKLIST.md - Production deployment guide - SECURITY_HARDENING_GUIDE.md - Security hardening recommendations


Table of Contents

  1. Performance Targets
  2. Database Optimization
  3. Caching Strategies
  4. Connection Pooling
  5. Thread Pool Tuning
  6. Response Compression
  7. Load Testing
  8. Monitoring and Profiling
  9. Performance Checklist

Performance Targets

Baseline Metrics

Response Times (95th percentile): - Simple queries (GET web): < 100ms - List operations (GET lists): < 200ms - Item queries (GET items, no filter): < 300ms - Complex queries (OData filters, sorting): < 500ms - List/item creation: < 200ms - File uploads (< 10MB): < 2s - File downloads (< 10MB): < 1s

Throughput: - Concurrent users: 100+ (typical) - Requests per second: 1000+ (peak) - Database connections: 50-100 (pooled)

Resource Usage: - CPU: < 60% (average) - Memory: < 2GB (typical workload) - Database connections: < 50% of pool - Disk I/O: < 100 MB/s (typical)


Database Optimization

2.1: Indexing Strategy

Critical Indexes:

Lists Table:

-- Primary key (clustered)
CREATE CLUSTERED INDEX PK_Lists ON Lists(Id);

-- Frequently queried columns
CREATE NONCLUSTERED INDEX IX_Lists_WebId ON Lists(WebId) INCLUDE (Title, BaseTemplate);
CREATE NONCLUSTERED INDEX IX_Lists_Title ON Lists(Title) WHERE IsDeleted = 0;

ListItems Table:

-- Primary key (clustered)
CREATE CLUSTERED INDEX PK_ListItems ON ListItems(Id);

-- Frequently queried columns
CREATE NONCLUSTERED INDEX IX_ListItems_ListId ON ListItems(ListId) INCLUDE (Title, Created, Modified);
CREATE NONCLUSTERED INDEX IX_ListItems_ListId_Status ON ListItems(ListId, Status) WHERE IsDeleted = 0;

-- OData filter support
CREATE NONCLUSTERED INDEX IX_ListItems_Created ON ListItems(Created DESC);
CREATE NONCLUSTERED INDEX IX_ListItems_Modified ON ListItems(Modified DESC);

Files Table:

-- Primary key (clustered)
CREATE CLUSTERED INDEX PK_Files ON Files(Id);

-- Frequently queried columns
CREATE NONCLUSTERED INDEX IX_Files_ListId ON Files(ListId) INCLUDE (Name, ServerRelativeUrl);
CREATE NONCLUSTERED INDEX IX_Files_ServerRelativeUrl ON Files(ServerRelativeUrl);

Index Maintenance:

-- Rebuild fragmented indexes (monthly)
ALTER INDEX ALL ON Lists REBUILD;
ALTER INDEX ALL ON ListItems REBUILD;
ALTER INDEX ALL ON Files REBUILD;

-- Update statistics (weekly)
UPDATE STATISTICS Lists WITH FULLSCAN;
UPDATE STATISTICS ListItems WITH FULLSCAN;
UPDATE STATISTICS Files WITH FULLSCAN;

2.2: Query Optimization

Avoid N+1 Queries:

❌ BAD:

// N+1 query problem
var lists = await context.Lists.ToListAsync();
foreach (var list in lists)
{
    var items = await context.ListItems.Where(i => i.ListId == list.Id).ToListAsync();
    // 1 query for lists + N queries for items = N+1 queries
}

✅ GOOD:

// Single query with join
var listsWithItems = await context.Lists
    .Include(l => l.Items)
    .ToListAsync();
// 1 query total

Use Projection (Select):

❌ BAD:

// Loads all columns
var lists = await context.Lists.ToListAsync();

✅ GOOD:

// Loads only required columns
var lists = await context.Lists
    .Select(l => new { l.Id, l.Title, l.ItemCount })
    .ToListAsync();

Use AsNoTracking for Read-Only Queries:

// Faster for read-only queries (no change tracking overhead)
var lists = await context.Lists
    .AsNoTracking()
    .ToListAsync();

2.3: Connection String Optimization

Optimized Connection String:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=CesiviDB;User=cesivi_app;Password=***;Min Pool Size=10;Max Pool Size=100;Connection Timeout=30;Command Timeout=30;Pooling=true;MultipleActiveResultSets=true;"
  }
}

Parameters Explained: - Min Pool Size=10: Keep 10 connections warm (faster first request) - Max Pool Size=100: Maximum 100 connections (adjust based on load) - Connection Timeout=30: 30 seconds to establish connection - Command Timeout=30: 30 seconds for query execution - Pooling=true: Enable connection pooling (critical for performance) - MultipleActiveResultSets=true: Allow multiple result sets per connection


Caching Strategies

3.1: Cache Configuration

In-Memory Cache (Development, Single Server):

{
  "Cesivi": {
    "Cache": {
      "Type": "InMemory",
      "DefaultExpirationMinutes": 60,
      "SlidingExpiration": true,
      "MaxSize": 1000
    }
  }
}

Redis Cache (Production, Multi-Server):

{
  "Cesivi": {
    "Cache": {
      "Type": "Redis",
      "ConnectionString": "localhost:6379,abortConnect=false,connectTimeout=5000,syncTimeout=5000",
      "InstanceName": "Cesivi:",
      "DefaultExpirationMinutes": 60,
      "SlidingExpiration": true
    }
  }
}

Redis Configuration:

# Install Redis (Ubuntu)
sudo apt-get install redis-server

# Configure Redis
sudo nano /etc/redis/redis.conf

# Recommended settings:
maxmemory 2gb
maxmemory-policy allkeys-lru  # Evict least recently used keys
appendonly yes  # Persistence
save 900 1  # Save to disk every 15 minutes if 1+ key changed

# Start Redis
sudo systemctl start redis
sudo systemctl enable redis

3.2: What to Cache

Cache Aggressively: - Web properties (Title, Url, Description) - rarely change - List metadata (Title, BaseTemplate, ItemCount) - infrequent changes - User information (Name, Email, Groups) - changes rarely - Site groups (Owners, Members, Visitors) - static

Cache Moderately: - List items (with short TTL: 5-15 minutes) - changes frequently - File metadata (Name, Size, Modified) - moderate changes

Don't Cache: - Authentication tokens (security risk) - Real-time data (current user session) - Large binary files (use CDN instead)

3.3: Cache Invalidation

Invalidation Strategies:

1. Time-Based (TTL):

// Cache for 60 minutes
_cache.Set("web_" + webId, web, TimeSpan.FromMinutes(60));

2. Event-Based:

// Invalidate on update
public async Task UpdateListAsync(List list)
{
    await _context.SaveChangesAsync();
    _cache.Remove("list_" + list.Id);  // Invalidate cache
}

3. Tag-Based (Redis):

// Cache with tags
await _cache.SetAsync("item_" + itemId, item, new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(60),
    Tags = new[] { "list_" + listId }  // Tag by list
});

// Invalidate all items in list
await _cache.RemoveByTagAsync("list_" + listId);

3.4: Cache Performance Metrics

Monitor: - Cache hit rate (target: > 80%) - Cache miss rate (target: < 20%) - Cache eviction rate (target: < 5%) - Average cache retrieval time (target: < 5ms)

Diagnostics Endpoint:

app.MapGet("/_diagnostics/cache", (IDistributedCache cache) =>
{
    return new
    {
        Type = "Redis",
        HitRate = GetCacheHitRate(),  // From metrics
        MissRate = GetCacheMissRate(),
        EvictionRate = GetCacheEvictionRate(),
        TotalKeys = GetTotalCacheKeys()
    };
});


Connection Pooling

4.1: HTTP Client Connection Pooling

❌ BAD (Creates new connection per request):

public async Task<string> GetDataAsync(string url)
{
    using var client = new HttpClient();  // ❌ Don't do this
    return await client.GetStringAsync(url);
}

✅ GOOD (Reuses connections):

// Register HttpClient in DI
builder.Services.AddHttpClient("CesiviClient")
    .ConfigureHttpClient(client =>
    {
        client.Timeout = TimeSpan.FromSeconds(30);
        client.DefaultRequestHeaders.Add("User-Agent", "Cesivi/2.0");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),  // Recycle connections
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
        MaxConnectionsPerServer = 10
    });

// Use in service
public class MyService
{
    private readonly HttpClient _httpClient;

    public MyService(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("CesiviClient");
    }

    public async Task<string> GetDataAsync(string url)
    {
        return await _httpClient.GetStringAsync(url);
    }
}

4.2: Database Connection Pooling

Already configured via connection string (see section 2.3).

Monitor Connection Pool:

app.MapGet("/_diagnostics/connections", async (CesiviDbContext context) =>
{
    var connectionString = context.Database.GetConnectionString();

    // Query SQL Server DMVs
    var poolStats = await context.Database.SqlQueryRaw<dynamic>(@"
        SELECT
            DB_NAME() AS DatabaseName,
            COUNT(*) AS TotalConnections,
            SUM(CASE WHEN login_time > DATEADD(MINUTE, -5, GETDATE()) THEN 1 ELSE 0 END) AS RecentConnections
        FROM sys.dm_exec_sessions
        WHERE database_id = DB_ID()
    ").ToListAsync();

    return poolStats;
});


Thread Pool Tuning

5.1: Kestrel Configuration

Optimized Kestrel Settings:

builder.WebHost.ConfigureKestrel(options =>
{
    // Limits
    options.Limits.MaxConcurrentConnections = 1000;
    options.Limits.MaxConcurrentUpgradedConnections = 1000;
    options.Limits.MaxRequestBodySize = 52428800;  // 50 MB
    options.Limits.MinRequestBodyDataRate = new MinDataRate(100, TimeSpan.FromSeconds(10));
    options.Limits.MinResponseDataRate = new MinDataRate(100, TimeSpan.FromSeconds(10));

    // Timeouts
    options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
    options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);

    // HTTP/2 settings
    options.Limits.Http2.MaxStreamsPerConnection = 100;
    options.Limits.Http2.HeaderTableSize = 4096;
    options.Limits.Http2.MaxFrameSize = 16384;
    options.Limits.Http2.MaxRequestHeaderFieldSize = 8192;

    // Disable server header
    options.AddServerHeader = false;
});

5.2: Thread Pool Configuration

Configure Thread Pool (Program.cs):

// Increase thread pool size for high concurrency
ThreadPool.SetMinThreads(50, 50);  // Min worker threads, Min I/O threads
ThreadPool.SetMaxThreads(1000, 1000);  // Max worker threads, Max I/O threads

Monitor Thread Pool:

app.MapGet("/_diagnostics/threadpool", () =>
{
    ThreadPool.GetMinThreads(out int minWorker, out int minIo);
    ThreadPool.GetMaxThreads(out int maxWorker, out int maxIo);
    ThreadPool.GetAvailableThreads(out int availWorker, out int availIo);

    return new
    {
        MinWorkerThreads = minWorker,
        MinIoThreads = minIo,
        MaxWorkerThreads = maxWorker,
        MaxIoThreads = maxIo,
        AvailableWorkerThreads = availWorker,
        AvailableIoThreads = availIo,
        ActiveThreads = maxWorker - availWorker
    };
});


Response Compression

6.1: Enable Compression

Configuration:

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;  // Enable for HTTPS
    options.Providers.Add<GzipCompressionProvider>();
    options.Providers.Add<BrotliCompressionProvider>();

    // Compress these MIME types
    options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
    {
        "application/json",
        "application/xml",
        "text/plain",
        "text/css",
        "text/html",
        "application/javascript",
        "image/svg+xml"
    });
});

builder.Services.Configure<GzipCompressionProviderOptions>(options =>
{
    options.Level = CompressionLevel.Fastest;  // Balance speed vs. compression
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
    options.Level = CompressionLevel.Fastest;
});

app.UseResponseCompression();  // Add early in pipeline

Compression Benchmarks: - JSON response (100 KB): 85% reduction (100 KB → 15 KB) - XML response (200 KB): 90% reduction (200 KB → 20 KB) - HTML response (50 KB): 80% reduction (50 KB → 10 KB)

6.2: Selective Compression

Don't compress: - Already compressed files (JPEG, PNG, ZIP, PDF) - Very small responses (< 1 KB) - overhead not worth it - Streaming responses


Load Testing

7.1: Load Testing Tools

Recommended Tools: - Apache JMeter - GUI-based, comprehensive - k6 - Modern, scriptable, Grafana integration - wrk - Simple, command-line - NBomber - .NET-based load testing

7.2: JMeter Test Plan

Scenario: Simulate 100 concurrent users:

  1. Create Thread Group:
  2. Number of Threads: 100
  3. Ramp-Up Period: 60 seconds
  4. Loop Count: 10

  5. Add HTTP Request Samplers:

  6. GET /_api/web (baseline)
  7. GET /_api/web/lists (list metadata)
  8. GET /_api/web/lists/getbytitle('Tasks')/items (query items)
  9. POST /_api/web/lists/getbytitle('Tasks')/items (create item)

  10. Add Assertions:

  11. Response code: 200 or 201
  12. Response time: < 500ms (95th percentile)

  13. Add Listeners:

  14. Summary Report
  15. Aggregate Report
  16. Response Time Graph

7.3: k6 Load Test Script

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
  stages: [
    { duration: '1m', target: 50 },   // Ramp up to 50 users
    { duration: '3m', target: 100 },  // Stay at 100 users
    { duration: '1m', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% of requests < 500ms
    http_req_failed: ['rate<0.01'],    // Error rate < 1%
  },
};

const BASE_URL = 'http://localhost:5000';
const AUTH = 'Basic ' + encoding.b64encode('admin:password');

export default function () {
  // Test 1: Get web
  let res = http.get(`${BASE_URL}/_api/web`, {
    headers: { 'Authorization': AUTH, 'Accept': 'application/json' },
  });
  check(res, { 'GET web status 200': (r) => r.status === 200 });

  sleep(1);

  // Test 2: Get lists
  res = http.get(`${BASE_URL}/_api/web/lists`, {
    headers: { 'Authorization': AUTH, 'Accept': 'application/json' },
  });
  check(res, { 'GET lists status 200': (r) => r.status === 200 });

  sleep(1);

  // Test 3: Query items
  res = http.get(`${BASE_URL}/_api/web/lists/getbytitle('Tasks')/items`, {
    headers: { 'Authorization': AUTH, 'Accept': 'application/json' },
  });
  check(res, { 'GET items status 200': (r) => r.status === 200 });

  sleep(2);
}

Run Test:

k6 run load-test.js

7.4: Interpreting Results

Key Metrics: - Response Time (p95): 95% of requests complete within this time - Throughput: Requests per second (RPS) - Error Rate: Percentage of failed requests - Concurrency: Number of simultaneous users

Good Results: - p95 response time: < 500ms - Throughput: > 1000 RPS - Error rate: < 1% - CPU usage: < 70% - Memory usage: Stable (no leaks)

Bad Results (Investigate): - p95 response time: > 2s (slow queries, no caching) - Error rate: > 5% (application errors, database timeouts) - CPU usage: > 90% (insufficient resources, inefficient code) - Memory usage: Growing continuously (memory leak)


Monitoring and Profiling

8.1: Application Metrics

Metrics to Monitor: - Request rate (requests/second) - Response time (average, p50, p95, p99) - Error rate (errors/second, percentage) - Active connections - Cache hit rate - Database query time

Prometheus Metrics:

// Add to Program.cs
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics.AddPrometheusExporter();
        metrics.AddMeter("Cesivi");
    });

// Create custom metrics
var meter = new Meter("Cesivi");
var requestCounter = meter.CreateCounter<long>("cesivi_requests_total");
var responseTimeHistogram = meter.CreateHistogram<double>("cesivi_response_time_seconds");

app.Use(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    await next();
    sw.Stop();

    requestCounter.Add(1, new KeyValuePair<string, object>("endpoint", context.Request.Path));
    responseTimeHistogram.Record(sw.Elapsed.TotalSeconds, new KeyValuePair<string, object>("endpoint", context.Request.Path));
});

app.MapPrometheusScrapingEndpoint();  // Expose at /metrics

8.2: Database Profiling

SQL Server Query Performance:

-- Find slow queries
SELECT TOP 10
    qs.execution_count,
    qs.total_elapsed_time / 1000000.0 AS total_elapsed_time_seconds,
    qs.total_elapsed_time / qs.execution_count / 1000000.0 AS avg_elapsed_time_seconds,
    SUBSTRING(qt.text, (qs.statement_start_offset/2)+1,
        ((CASE qs.statement_end_offset
            WHEN -1 THEN DATALENGTH(qt.text)
            ELSE qs.statement_end_offset
        END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
WHERE qt.text LIKE '%ListItems%'  -- Filter by table
ORDER BY qs.total_elapsed_time DESC;

-- Find missing indexes
SELECT
    migs.avg_total_user_cost * (migs.avg_user_impact / 100.0) * (migs.user_seeks + migs.user_scans) AS improvement_measure,
    'CREATE INDEX IX_' + OBJECT_NAME(mid.object_id) + '_' + REPLACE(REPLACE(REPLACE(ISNULL(mid.equality_columns,''),', ','_'),'[',''),']','') +
        CASE WHEN mid.inequality_columns IS NOT NULL THEN '_' + REPLACE(REPLACE(REPLACE(mid.inequality_columns,', ','_'),'[',''),']','') ELSE '' END + ' ON ' +
        mid.statement + ' (' + ISNULL(mid.equality_columns,'') +
        CASE WHEN mid.equality_columns IS NOT NULL AND mid.inequality_columns IS NOT NULL THEN ',' ELSE '' END +
        ISNULL(mid.inequality_columns, '') + ')' +
        ISNULL(' INCLUDE (' + mid.included_columns + ')', '') AS create_index_statement
FROM sys.dm_db_missing_index_groups mig
INNER JOIN sys.dm_db_missing_index_group_stats migs ON migs.group_handle = mig.index_group_handle
INNER JOIN sys.dm_db_missing_index_details mid ON mig.index_handle = mid.index_handle
WHERE migs.avg_total_user_cost * (migs.avg_user_impact / 100.0) * (migs.user_seeks + migs.user_scans) > 10
ORDER BY improvement_measure DESC;

8.3: Memory Profiling

dotnet-trace (CPU and Memory Profiling):

# Install tool
dotnet tool install --global dotnet-trace

# Collect trace
dotnet trace collect --process-id <PID> --duration 00:00:30

# Analyze trace in Visual Studio or PerfView

dotMemory (JetBrains): - Attach to running Cesivi process - Capture snapshot - Analyze object retention graph - Find memory leaks


Performance Checklist

Database

  • [ ] Indexes created on frequently queried columns
  • [ ] Index maintenance scheduled (monthly rebuild)
  • [ ] Statistics updated regularly (weekly)
  • [ ] N+1 queries eliminated (use Include/joins)
  • [ ] AsNoTracking used for read-only queries
  • [ ] Connection pooling enabled (Min=10, Max=100)
  • [ ] Query timeout configured (30 seconds)

Caching

  • [ ] Cache configured (Redis for multi-server, InMemory for single server)
  • [ ] Web metadata cached (60 minutes TTL)
  • [ ] List metadata cached (60 minutes TTL)
  • [ ] Cache hit rate monitored (target > 80%)
  • [ ] Cache invalidation on updates

Connection Pooling

  • [ ] HttpClient registered in DI (don't create per request)
  • [ ] Connection lifetime configured (5 minutes)
  • [ ] Max connections per server set (10)
  • [ ] Database connection pooling enabled

Threading

  • [ ] Kestrel MaxConcurrentConnections configured (1000)
  • [ ] Thread pool min threads set (50)
  • [ ] Thread pool max threads set (1000)
  • [ ] Async/await used throughout (no blocking calls)

Compression

  • [ ] Response compression enabled (Gzip + Brotli)
  • [ ] HTTPS compression enabled
  • [ ] MIME types configured (JSON, XML, HTML)
  • [ ] Compression level set (Fastest)

Load Testing

  • [ ] Load testing performed (JMeter/k6)
  • [ ] Baseline metrics established
  • [ ] 95th percentile response time < 500ms
  • [ ] Error rate < 1%
  • [ ] Throughput > 1000 RPS

Monitoring

  • [ ] Application metrics exposed (Prometheus /metrics)
  • [ ] Database query performance monitored
  • [ ] Memory profiling performed (no leaks)
  • [ ] Alerting configured (high error rate, slow responses)

Last Updated: 2026-01-18 Version: 1.0 For Support: See README.md