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.
- Add OutputCache and use named policies.
- Apply
.AsNoTracking()everywhere on read paths. - Add compression (Brotli + Gzip) and let OutputCache amortize CPU.
- Add the minimum indexes that support your list and detail queries.
- 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
likedByMestays 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):
- Hit the same endpoint twice.
- Compare TTFB (cold vs warm) and confirm response is cacheable.
- Log EF Core query counts in dev and watch for regressions.
- 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
- Adding caching after shipping slow pages, then trying to unwind complexity.
- Accidentally removing
.AsNoTracking()on read-only queries. - Returning full entities instead of projections.
- Caching personalized responses without a vary rule.
- 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
- ASP.NET Core Output Caching Middleware
- ASP.NET Core Response Compression
- EF Core Efficient Querying
- EF Core Tracking vs. No-Tracking
- SQL Server Index Design Guide (INCLUDE and Covering Indexes)
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.