Performance Defaults That Beat Clever Optimizations

TL;DR The boring baseline that makes content sites fast: OutputCache policies, EF Core query shaping, compression, and stable indexing.

Performance regressions are usually accidental, not mysterious. AI-assisted development makes it easy to ship "clever" changes that add queries, allocations, and complexity. The fix is to start with defaults that make pages fast without heroics.

You're reading Part 4 of 5 in the AI-assisted development series. Previous: Part 3: Security Boundaries for AI-Assisted Development in ASP.NET Core Next: Part 5: From Markdown to Live: A Publishing Pipeline Without a CMS

This series moves from workflow -> safety -> performance -> publishing, using DAP iQ as the working system.

Common questions this answers

  • What caching defaults prevent AI-generated performance regressions?
  • How do you keep OutputCache vary keys bounded?
  • What EF Core patterns keep query count predictable?

Definition (what this means in practice)

OutputCache defaults for content sites are: cache read-heavy pages with named policies, vary only on bounded inputs, and keep database work predictable with projection-first queries and correct indexes.

In practice, this means wiring OutputCache policies before you ship, using .AsNoTracking() on every read path, and measuring TTFB on cached endpoints.

Terms used

  • OutputCache policy: a named caching rule (TTL + vary behavior) applied to endpoints.
  • Vary key: the small set of inputs that change the response (query, cookie, header).
  • Projection: selecting only the fields you need into a DTO/view model.
  • Tracking: EF Core change tracking; useful for writes, wasteful on reads.
  • Cache key sanity: avoiding unbounded vary inputs that explode cache entries.
  • Query-to-index mapping: explicitly matching your most common queries to indexes.

Reader contract

This article is for:

  • Engineers shipping ASP.NET Core content sites.
  • Anyone trying to keep AI-generated performance regressions out of production.

You will leave with:

  • A set of caching defaults that make read traffic cheap.
  • EF Core query shaping patterns that avoid accidental N+1s.
  • Index guidance and a table mapping queries to indexes.

This is not for:

  • premature micro-optimizations.
  • "we will cache later" roadmaps.

Why this exists

I want DAP iQ pages to be fast by default. That means caching, sane queries, predictable work per request, and stable indexing.

Default rule

Caching defaults beat clever optimizations 90 percent of the time. This is especially true for content.

Quick start (10 minutes)

If you are running an ASP.NET Core content site, do this first:

Verified on: ASP.NET Core (.NET 10), EF Core 10.

  1. Add OutputCache and use named policies.
  2. Apply .AsNoTracking() everywhere on read paths.
  3. Add compression (Brotli + Gzip) and let OutputCache amortize CPU.
  4. Add the minimum indexes that support your list and detail queries.
  5. Measure query count and TTFB, then iterate.

Verify with a quick cache check:

# Hit twice: warm request should be faster
curl -s -o /dev/null -w "ttfb=%{time_starttransfer}\n" http://localhost:5000/insights
curl -s -o /dev/null -w "ttfb=%{time_starttransfer}\n" http://localhost:5000/insights

Output caching: policy names are the interface

A caching strategy should be readable in code review. Named policies make intent visible.

DAP iQ uses a small set of policies that map to page types.

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Default12Hours", b => b.Expire(TimeSpan.FromHours(12)));
    options.AddPolicy("VaryByPage12Hours", b => b.Expire(TimeSpan.FromHours(12)).SetVaryByQuery("page"));
    options.AddPolicy("Detail6Hours", b => b.Expire(TimeSpan.FromHours(6)));
    options.AddPolicy("Feed12Hours", b => b.Expire(TimeSpan.FromHours(12)));
    options.AddPolicy("Sitemap12Hours", b => b.Expire(TimeSpan.FromHours(12)));

    options.AddPolicy("LikesRead10Seconds", b => b
        .Expire(TimeSpan.FromSeconds(10))
        // Only vary by Origin if the endpoint enforces an allowlist (reject unknown origins).
        .SetVaryByHeader("Origin")
        .VaryByValue(static httpContext =>
        {
            if (!httpContext.Request.Cookies.TryGetValue("dapiq_client", out var raw)
                || !Guid.TryParse(raw, out var parsed))
            {
                return new KeyValuePair<string, string>(key: "dapiq_client", value: string.Empty);
            }

            return new KeyValuePair<string, string>(key: "dapiq_client", value: parsed.ToString("D"));
        }));
});

OutputCache wiring (end-to-end)

Policies are only useful if they are wired correctly. The minimum is: register services, wire middleware in the correct order, and apply a named policy.

Reference ordering (end-to-end):

builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Detail6Hours", b => b.Expire(TimeSpan.FromHours(6)));
});

var app = builder.Build();

app.UseForwardedHeaders(); // if you run behind a trusted reverse proxy
app.UseHttpsRedirection();

app.UseResponseCompression();

app.UseRouting();

// If your app uses auth, include authentication before authorization.
// app.UseAuthentication();
app.UseAuthorization();

app.UseRateLimiter();
app.UseOutputCache();

app.MapStaticAssets();
app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}")
    .RequireRateLimiting("per-ip")
    .WithStaticAssets();

app.Run();

Apply policy (example):

[OutputCache(PolicyName = "Detail6Hours")]
public async Task<IActionResult> Read(string slug)
{
    // ...
}

Copy/paste artifact: vary allowlist (bounded inputs)

This is the mental model to keep cache keys sane.

Vary input Allow? Bound it how Notes
?page= yes parse int, clamp range paging lists
?q= usually no max length + normalization search pages can explode cache
Cookie: dapiq_client yes (small) validate GUID, else empty personalization toggles
Header: Origin sometimes allowlist origins CORS and like state
Header: User-Agent no do not vary cache fragmentation
Header: Accept-Language no (for mono-lang) do not vary cache fragmentation

OutputCache patterns (what to do and what to avoid)

These are the patterns that stop AI-assisted performance regressions:

Pattern Use when Avoid when
Long TTL on content detail Read-heavy content with stable pages Personalized pages
Vary by query for paging Lists with ?page= Queries with unbounded params
Cookie/header vary for personalization Small personalization like "liked" state Complex per-user content
NoStore for write endpoints Forms, POSTs, state changes Never cache writes

Two DAP iQ examples:

  • Article detail uses Detail6Hours. That makes read traffic cheap.
  • Likes reads use a 10 second cache with a cookie vary so likedByMe stays correct.

EF Core query shaping: read paths are not a place for tracking

Tracking adds cost when you do not need it. For content lists, tracking is almost never needed.

DAP iQ list endpoints use .AsNoTracking() and limit the shape.

const int PageSize = 20;
var requestedPage = 1; // e.g., from query string, then clamp
var page = Math.Max(1, requestedPage);

var articles = await db.Articles
    .AsNoTracking()
    .Where(a => a.IsPublished)
    .OrderByDescending(a => a.PublishedAt)
    .Skip((page - 1) * PageSize)
    .Take(PageSize)
    .Select(a => new
    {
        a.Id,
        a.Title,
        a.Slug,
        a.Tldr,
        a.ReadingTimeMinutes,
        a.PublishedAt,
        a.CoverImageUrl,

        // Prefer a minimal projection over returning entities.
        // If your EF provider cannot translate this collection projection,
        // load tags in a second query keyed by Article IDs.
        TagNames = a.Tags
            .OrderBy(t => t.Name)
            .Select(t => t.Name)
            .ToList()
    })
    .ToListAsync();

Pattern: list query + tag stitch (2 queries)

Copy/paste-safe fallback when your EF provider cannot translate the tag collection projection.

const int PageSize = 20;
var requestedPage = 1; // e.g., from query string, then clamp
var page = Math.Max(1, requestedPage);

var articles = await db.Articles
    .AsNoTracking()
    .Where(a => a.IsPublished)
    .OrderByDescending(a => a.PublishedAt)
    .Skip((page - 1) * PageSize)
    .Take(PageSize)
    .Select(a => new
    {
        a.Id,
        a.Title,
        a.Slug,
        a.Tldr,
        a.ReadingTimeMinutes,
        a.PublishedAt,
        a.CoverImageUrl
    })
    .ToListAsync();

var ids = articles.Select(a => a.Id).ToArray();

var tags = await db.ArticleTags
    .AsNoTracking()
    .Where(at => ids.Contains(at.ArticleId))
    .Select(at => new { at.ArticleId, TagName = at.Tag.Name })
    .ToListAsync();

var tagMap = tags
    .GroupBy(t => t.ArticleId)
    .ToDictionary(g => g.Key, g => g.Select(x => x.TagName).OrderBy(x => x).ToArray());

var viewModels = articles.Select(a => new
{
    a.Title,
    a.Slug,
    a.Tldr,
    a.ReadingTimeMinutes,
    a.PublishedAt,
    a.CoverImageUrl,
    TagNames = tagMap.TryGetValue(a.Id, out var names) ? names : Array.Empty<string>()
}).ToList();

The boundary is simple: shape the data you need, then stop. If AI proposes more Include chains, ask: "What is the view model contract?"

Cache key sanity: vary rules must be bounded

OutputCache is not "cache everything". It is "cache the stable response for a bounded key".

Practical rules:

  • vary by a small allowlist of query params (for lists: page, not arbitrary filters)
  • never vary on unbounded user input (search queries, free-form strings)
  • for cookies, vary only on validated, normalized values (or do not vary)

If the vary key is unbounded, your cache becomes a memory leak.

Compression: cheap wins when combined with caching

Compression is a good default, but it has CPU cost. The cost is easier to justify when output caching absorbs repeated requests.

Reference configuration:

builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});

builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
    options.Level = CompressionLevel.SmallestSize);

builder.Services.Configure<GzipCompressionProviderOptions>(options =>
    options.Level = CompressionLevel.SmallestSize);

Indexing: performance is also a database contract

If your list pages sort by PublishedAt, you need an index that supports it. If your detail pages fetch by Slug, you need an index that supports it.

You can think of indexes as "cache keys" for the database. Without them, you are doing extra work on every request.

Query-to-index mapping

Use a table like this to keep indexing intentional.

Query Typical predicate/order Index
Article detail Slug = @slug IX_Articles_Slug (unique)
Article list IsPublished = 1 ORDER BY PublishedAt DESC IX_Articles_IsPublished_PublishedAt
Series navigation SeriesId = @id ORDER BY SeriesOrder IX_Articles_SeriesId_SeriesOrder
Tag browsing join via ArticleTags indexes on join table FK columns

Reference SQL (for understanding)

If you need a mental model, this is what you are asking the DB to do:

CREATE UNIQUE INDEX IX_Articles_Slug ON dbo.Articles (Slug);
CREATE INDEX IX_Articles_IsPublished_PublishedAt ON dbo.Articles (IsPublished, PublishedAt DESC);
CREATE INDEX IX_Articles_SeriesId_SeriesOrder ON dbo.Articles (SeriesId, SeriesOrder);

SQL Server goldmine: covering indexes with INCLUDE. If your list query needs a handful of columns, INCLUDE lets SQL Server satisfy the query from the index without extra lookups.

Example:

CREATE INDEX IX_Articles_IsPublished_PublishedAt
ON dbo.Articles (IsPublished, PublishedAt DESC)
INCLUDE (Title, Slug, Tldr, CoverImageUrl, ReadingTimeMinutes);

Do not add every index you can imagine. Add the ones that match real query shapes.

Measurement targets and how to measure

For a content site, you do not need a lab. You need a few numbers and a regression alarm.

Targets:

  • TTFB p50 under 100ms for cached pages (local dev will differ)
  • DB query count stable (no surprise +3 queries for a "small" change)
  • cache hit rate high on lists and details

How to measure (baseline recipe):

  1. Hit the same endpoint twice.
  2. Compare TTFB (cold vs warm) and confirm response is cacheable.
  3. Log EF Core query counts in dev and watch for regressions.
  4. Sample feed/sitemap endpoints (they should stay cheap).

Cache verification snippet (simple, practical):

# Hit twice: warm request should be faster.
curl -s -o /dev/null -w "ttfb=%{time_starttransfer}\n" http://localhost:5000/insights
curl -s -o /dev/null -w "ttfb=%{time_starttransfer}\n" http://localhost:5000/insights

# Inspect headers: confirm cacheability signals.
curl -I http://localhost:5000/insights

Common failure modes

  1. Adding caching after shipping slow pages, then trying to unwind complexity.
  2. Accidentally removing .AsNoTracking() on read-only queries.
  3. Returning full entities instead of projections.
  4. Caching personalized responses without a vary rule.
  5. Adding indexes based on guesses instead of query shapes.

Checklist

  • Pick named OutputCache policies per endpoint.
  • Use .AsNoTracking() for read-only EF Core queries.
  • Project to view models and avoid returning full entities.
  • Treat personalization as a cache-vary problem, not a reason to skip caching.
  • Add only the indexes that match real queries.

FAQ

Should I cache everything?

No. Cache the expensive, read-heavy endpoints first. Do not cache writes. Do not cache personalized content unless you have a vary story.

Why is .AsNoTracking() a big deal?

It stops EF Core from building a change tracker graph you do not need. On lists, that overhead adds up.

Should I always use projection instead of Include?

Not always. But projection makes contracts explicit. If you can project, do it.

What is the smallest set of indexes for a content site?

Slug lookup and published list ordering. Add series/tag indexes as you add those features.

Should OutputCache cache authenticated pages?

Usually no. The default OutputCache rules do not cache authenticated requests. If you override that, treat it as a high-risk change and validate carefully.

Where should ResponseCompression sit relative to OutputCache?

There are multiple valid orderings. If you want to cache compressed responses, compression must run before caching. If you want to avoid caching multiple encodings, you may choose a different ordering. Pick deliberately and validate with headers and repeated requests.

When should I avoid caching a page?

When the response varies on unbounded user input (search queries) or contains per-user data. In those cases, start with query shaping, indexes, and rate limits.

OutputCache vs ResponseCaching: when should I use each?

OutputCache is server-side and gives you control over cache keys, vary rules, and invalidation. ResponseCaching honors HTTP cache headers and is client/CDN-facing. For content sites where you control the origin, OutputCache is simpler and more predictable.

How do I confirm OutputCache is working in production?

Hit the same endpoint twice and compare TTFB. If the warm request is significantly faster and the response headers show cacheability, it is working. For finer-grained confirmation, log cache hit/miss or inspect server metrics.

What is the safest vary-by strategy for list pages?

Vary only on bounded, validated inputs. For paging, vary on ?page= with an int parse and range clamp. Avoid varying on free-form query strings, user agents, or unbounded cookies.

What to do next

Read Part 5: From Markdown to Live: A Publishing Pipeline Without a CMS. Browse the AI-assisted development series for the full sequence. If you are using AI-assisted development on a content system, make caching and query shaping the default starting point. If you want to review performance defaults for your content system, start at Insights or reach out via Contact.

References

Author notes

Decisions:

  • Named OutputCache policies per scenario. Rationale: makes caching intent reviewable.
  • Use .AsNoTracking() for all read paths. Rationale: reduces overhead and avoids accidental state.
  • Keep caching TTLs long for content, short for interactive reads. Rationale: matches the cost profile.

Observations:

  • Before: page performance depends on DB round trips and repeated work.
  • After: OutputCache absorbs repeated reads and stabilizes latency.
  • Observed: query shaping made list endpoints predictable under load.