Caching & Revalidation
Combine Add-KrCacheResponse (Cache-Control directives) with Test-KrCacheRevalidation (ETag / Last-Modified) for efficient conditional requests.
Full source
File: pwsh/tutorial/examples/9.8-Caching.ps1
<#
Sample: Caching & Revalidation
Purpose: Demonstrate cache-control and ETag/Last-Modified revalidation in a Kestrun server.
File: 9.8-Caching.ps1
Notes: Shows public/private cache, ETag, and versioned resource routes.
#>
param(
[int]$Port = 5000,
[IPAddress]$IPAddress = [IPAddress]::Loopback
)
# 1. Logging
New-KrLogger | Add-KrSinkConsole | Register-KrLogger -Name 'console' -SetAsDefault
# 2. Server
New-KrServer -Name 'Responses 9.8'
# 3. Listener
Add-KrEndpoint -IPAddress $IPAddress -Port $Port
# Finalize configuration
Enable-KrConfiguration
# Public cached route
Add-KrMapRoute -Pattern '/cache-public' -Verbs GET -ScriptBlock {
Add-KrCacheResponse -Public -MaxAge 300
Write-KrTextResponse 'cached for 5m'
}
# Private no-store route
Add-KrMapRoute -Pattern '/cache-private' -Verbs GET -ScriptBlock {
Add-KrCacheResponse -Private -NoStore
Write-KrTextResponse 'no-store'
}
# ETag negotiation route
Add-KrMapRoute -Pattern '/etag' -Verbs GET -ScriptBlock {
$payload = 'stable content for ETag demo'
if (-not (Test-KrCacheRevalidation -Payload $payload)) {
Write-KrTextResponse $payload
}
}
# Explicit versioned resource route
Add-KrMapRoute -Pattern '/versioned' -Verbs GET -ScriptBlock {
$payload = 'v1 payload'
if (-not (Test-KrCacheRevalidation -Payload $payload -ETag 'v1' -LastModified (Get-Date '2024-01-01'))) {
Write-KrTextResponse $payload
}
}
# Start the server
Start-KrServer
Step-by-step
- Logging: Register console logger as default.
- Server: Create server named ‘Responses 9.8’.
- Listener: Listen on 127.0.0.1:5000.
/cache-public: adds Cache-Control public max-age=300./cache-private: adds private, no-store./etag: auto ETag from payload, returns 304 on match./versioned: fixed ETag and Last-Modified.- Enable configuration and start server.
Try it
curl -i http://127.0.0.1:5000/cache-public
curl -i http://127.0.0.1:5000/cache-private
# ETag negotiation (first fetch then conditional):
curl -i http://127.0.0.1:5000/etag > etag1.txt
curl -i -H "If-None-Match: $(grep -i etag etag1.txt | cut -d: -f2 | tr -d ' ')" http://127.0.0.1:5000/etag
curl -i http://127.0.0.1:5000/versioned
curl -i -H "If-None-Match: \"v1\"" http://127.0.0.1:5000/versioned
PowerShell equivalents:
# Check cache headers on public route
Invoke-WebRequest -Uri http://127.0.0.1:5000/cache-public | Select-Object -ExpandProperty Headers | Format-List
# Check private no-store headers
Invoke-WebRequest -Uri http://127.0.0.1:5000/cache-private | Select-Object -ExpandProperty Headers | Format-List
# ETag negotiation test
# Note: Headers.ETag returns an array, so we need to convert it to a string
$first = Invoke-WebRequest -Uri http://127.0.0.1:5000/etag
$etag = ($first.Headers.ETag -as [string]) # Convert array to string
Write-Host "First request ETag: $etag"
# Second request with If-None-Match should return 304
$second = Invoke-WebRequest -Uri http://127.0.0.1:5000/etag -Headers @{ 'If-None-Match' = $etag } -SkipHttpErrorCheck
Write-Host "Second request status: $($second.StatusCode)"
# Versioned resource test
$versioned = Invoke-WebRequest -Uri http://127.0.0.1:5000/versioned
$versionedETag = ($versioned.Headers.ETag -as [string])
Write-Host "Versioned ETag: $versionedETag"
# Test 304 on versioned resource
$conditional = Invoke-WebRequest -Uri http://127.0.0.1:5000/versioned -Headers @{ 'If-None-Match' = '"v1"' } -SkipHttpErrorCheck
Write-Host "Conditional request status: $($conditional.StatusCode)"
Cache Control Patterns
Public Caching (CDN/Proxy)
# Static assets - long cache time
Add-KrMapRoute -Pattern '/assets/*' -Verbs GET -ScriptBlock {
Add-KrCacheResponse -Public -MaxAge 31536000 # 1 year
# Serve static file...
}
# API responses - moderate cache time
Add-KrMapRoute -Pattern '/api/products' -Verbs GET -ScriptBlock {
Add-KrCacheResponse -Public -MaxAge 300 -SMaxAge 600 # 5min client, 10min proxy
# Return product data...
}
Private Caching (Browser Only)
# User-specific data
Add-KrMapRoute -Pattern '/api/profile' -Verbs GET -ScriptBlock {
Add-KrCacheResponse -Private -MaxAge 60 # 1 minute browser cache only
# Return user profile...
}
# Sensitive data - no caching
Add-KrMapRoute -Pattern '/api/secrets' -Verbs GET -ScriptBlock {
Add-KrCacheResponse -Private -NoStore -NoCache
# Return sensitive data...
}
ETag Validation
Automatic ETag from Content
Add-KrMapRoute -Pattern '/api/data' -Verbs GET -ScriptBlock {
$data = Get-DatabaseData -Id $Context.Request.Query['id']
$payload = $data | ConvertTo-Json
# Auto-generates ETag from payload hash
if (-not (Test-KrCacheRevalidation -Payload $payload)) {
Add-KrCacheResponse -Public -MaxAge 60
Write-KrJsonResponse $data
}
# Returns 304 Not Modified if ETag matches
}
Manual ETag and Last-Modified
Add-KrMapRoute -Pattern '/api/document/:id' -Verbs GET -ScriptBlock {
$id = $Context.Request.RouteValues['id']
$doc = Get-Document -Id $id
$etag = "doc-$($doc.Version)"
$lastModified = $doc.ModifiedDate
if (-not (Test-KrCacheRevalidation -ETag $etag -LastModified $lastModified)) {
Add-KrCacheResponse -Public -MaxAge 300
Write-KrJsonResponse $doc
}
# Returns 304 if client's If-None-Match or If-Modified-Since matches
}
Conditional Updates
Add-KrMapRoute -Pattern '/api/document/:id' -Verbs PUT -ScriptBlock {
$id = $Context.Request.RouteValues['id']
$doc = Get-Document -Id $id
$currentETag = "doc-$($doc.Version)"
# Check If-Match header for optimistic concurrency
$ifMatch = $Context.Request.Headers['If-Match']
if ($ifMatch -and $ifMatch -ne $currentETag) {
Write-KrErrorResponse -Message 'Document was modified by another user' -StatusCode 412
return
}
# Update document...
$newDoc = Update-Document -Id $id -Data $newData
$newETag = "doc-$($newDoc.Version)"
$Context.Response.Headers.Add('ETag', $newETag)
Write-KrJsonResponse $newDoc
}
Cache Control Directives
| Directive | Purpose | Example Usage |
|---|---|---|
Public | Can be cached by any cache (CDN, proxy, browser) | Static assets, public API responses |
Private | Only cacheable by browser, not shared caches | User-specific data |
NoCache | Must revalidate with server before use | Frequently changing data |
NoStore | Must not be stored anywhere | Sensitive information |
MaxAge | Maximum age in seconds for cache freshness | MaxAge 3600 = 1 hour |
SMaxAge | Maximum age for shared caches (overrides MaxAge) | Different TTL for CDN vs browser |
MustRevalidate | Cache must check with server when stale | Critical data integrity |
Validation Headers
| Header | Client Sends | Server Checks | Response |
|---|---|---|---|
If-None-Match | ETag from previous response | Current resource ETag | 304 if match, 200 with content if different |
If-Modified-Since | Date from Last-Modified | Current resource modification time | 304 if not modified, 200 if newer |
If-Match | ETag for conditional updates | Current resource ETag | 412 if no match, proceeds if match |
If-Unmodified-Since | Date for conditional updates | Current modification time | 412 if modified, proceeds if not |
Best Practices
Cache Strategy by Content Type
# Immutable content (versioned assets)
Add-KrCacheResponse -Public -MaxAge 31536000 -Immutable
# Frequently updated content
Add-KrCacheResponse -Public -MaxAge 60 -MustRevalidate
# User-specific content
Add-KrCacheResponse -Private -MaxAge 300
# Real-time data
Add-KrCacheResponse -NoCache -NoStore
Performance Tips
- Use ETags for dynamic content - Reduces bandwidth even when cache expires
- Set appropriate MaxAge - Balance freshness vs performance
- Leverage CDN caching - Use
PublicandSMaxAgefor static content - Implement conditional updates - Prevent lost updates with
If-Match - Cache headers hierarchy - CDN → Proxy → Browser caching strategy
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Cache not working | Missing Add-KrCacheResponse | Add cache headers before response |
| 304 not returned | ETag/Last-Modified changed | Ensure consistent ETag generation |
| Sensitive data cached | Using Public instead of Private | Use Private or NoStore for sensitive data |
| Stale content served | MaxAge too long | Reduce MaxAge or add revalidation |
| High server load | No caching headers | Implement appropriate caching strategy |
References
- Add-KrCacheResponse
- Test-KrCacheRevalidation
- HTTP Caching Guide - Comprehensive caching strategies and layered approach
Previous / Next
Previous: Errors Next: Content Negotiation