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

  1. Logging: Register console logger as default.
  2. Server: Create server named ‘Responses 9.8’.
  3. Listener: Listen on 127.0.0.1:5000.
  4. /cache-public: adds Cache-Control public max-age=300.
  5. /cache-private: adds private, no-store.
  6. /etag: auto ETag from payload, returns 304 on match.
  7. /versioned: fixed ETag and Last-Modified.
  8. 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

  1. Use ETags for dynamic content - Reduces bandwidth even when cache expires
  2. Set appropriate MaxAge - Balance freshness vs performance
  3. Leverage CDN caching - Use Public and SMaxAge for static content
  4. Implement conditional updates - Prevent lost updates with If-Match
  5. 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


Previous / Next

Previous: Errors Next: Content Negotiation