Titip is a high-throughput, low-allocation HTTP caching middleware for Go applications and API gateways.
Designed for high-concurrency services, Titip reduces backend load by serving cached responses with minimal memory allocation, atomic multi-variant negotiation, RFC-compliant freshness calculations, and fail-open resilience.
- Low-Allocation Design: Reuses internal memory buffers and decompression streams to minimize heap allocations under high concurrency.
- Storage Compression: Built-in LZ4 (default) and Zstandard (
zstd) compression for stored variant bodies to reduce memory footprint and storage I/O, with pluggable custom codec support. - Fail-Open Resilience: Storage outages, decompression errors, or upstream panics safely bypass to the origin handler (
fwd=bypass) or serve stale cache without terminating the process. - Strict Privacy & Explicit Opt-In: Titip never caches heuristically. Responses must explicitly declare a freshness lifetime (
max-age,s-maxage, orExpires). Cold URL misses execute independently without singleflight coalescing, and responses withSet-Cookieorprivatedirectives bypass caching unconditionally. - RFC-7234, RFC-9111 & RFC-9213 Compliant: Implements the official Age & Freshness calculation standard (apparent age, corrected initial age, resident time, clock-skew correction, and multi-variant
Varyheader negotiation). - Tiered Cache-Control (RFC 9213): Supports targeted header resolution (
Titip-Cache-Control→CDN-Cache-Control→Cache-Control), allowing backends to configure edge caching independently from browser caching. - RFC-9211
Cache-StatusObservability: Structured diagnostics (Cache-Status: titip; hit; ttl=295,fwd=stale,fwd=bypass) with multi-tier cache chaining. - Edge Side Includes (ESI): Concurrent fragment assembly, in-process routing, recursive loop protection, and SSRF prevention.
- Granular Cache Purge API: Invalidation via programmatic Go API (exact URL, wildcard prefixes, surrogate
Cache-Tag, soft-purge, or full cache purge). - Pluggable Architecture: Standard
net/httpmiddleware with modular framework adapters and decoupled storage engines.
Titip separates metadata from variant payloads to enable atomic multi-variant negotiation (Vary) and serve conditional responses (304 Not Modified) without reading the response body from storage:
[ Incoming Request ] ──► (Primary Key: Scheme + Host + Path + Filtered Query)
│
▼
┌──────────────────────────────────────────────┐
│ Stage 1: GetMeta(primaryKey) │
│ Evaluates: Vary headers, freshness, tags │
└──────────────────────┬───────────────────────┘
│
┌──────────────────────┴───────────────────────┐
│ Match Vary Variant & Evaluate Freshness │
└──────┬───────────────────────────────┬───────┘
│ │
(Downstream 304 / HEAD) (Cache Hit)
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ Serve 304 / Headers │ │ Stage 2: GetVariant(pk, vk) │
│ (No Body Read Needed)│ │ Returns: Headers & Body │
└──────────────────────┘ └──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│Decompress & Stream (LZ4/Zstd)│
└──────────────────────────────┘
Titip is organized as a multi-module workspace. Each module is versioned independently:
| Module | Description | Documentation |
|---|---|---|
github.com/indragunawan/titip |
Core caching middleware, state machine, and programmatic Purge API | Core Quickstart |
github.com/indragunawan/titip/adapter/caddy |
Native Caddy HTTP middleware directive (titip) & Admin Purge API |
Caddy Adapter Guide |
github.com/indragunawan/titip/storage/redis |
High-performance Redis (7.4+), Valkey (9.0+), DragonflyDB (1.38+) driver (rueidis) |
Redis Storage Guide |
github.com/indragunawan/titip/storage/redis/caddy |
Guest storage module for Caddy (titip.storage.redis) |
Caddy Redis Guide |
Install the core package and Redis storage driver:
go get github.com/indragunawan/titip
go get github.com/indragunawan/titip/storage/redisWrap any standard net/http handler:
package main
import (
"context"
"net/http"
"time"
"github.com/redis/rueidis"
"github.com/indragunawan/titip"
storageRedis "github.com/indragunawan/titip/storage/redis"
)
func main() {
// Initialize Redis client
client, err := rueidis.NewClient(rueidis.ClientOption{
InitAddress: []string{"127.0.0.1:6379"},
})
if err != nil {
panic(err)
}
defer client.Close()
// Create Titip Redis storage
store, err := storageRedis.New(client, storageRedis.WithKeyPrefix("titip:"))
if err != nil {
panic(err)
}
// Configure Titip engine
cache, err := titip.New(
store,
titip.WithCacheStatus(titip.CacheStatusRFC9211),
)
if err != nil {
panic(err)
}
defer cache.Close(context.Background())
// Wrap standard HTTP handler
origin := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Cache publicly for 60 seconds, allow serving stale for 5 minutes during revalidation
w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=300")
w.Header().Set("Cache-Tag", "catalog items")
w.Write([]byte(`{"message": "hello from origin", "timestamp": "` + time.Now().Format(time.RFC3339) + `"}`))
})
http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cache.ServeHTTP(w, r, origin)
}))
}Initialize Titip with the mandatory storage engine and any functional options via titip.New(store storage.Storage, opts ...Option):
| Option | Type | Default | Description |
|---|---|---|---|
WithCacheStatus(mode) |
CacheStatusMode |
CacheStatusSimple |
Emitted status format (CacheStatusRFC9211, CacheStatusSimple, or CacheStatusNone). |
WithCacheKey(cfg) |
CacheKey |
{} (standard) |
Primary cache key generation rules and query parameter filtering. |
WithTagHeader(name) |
string |
"Cache-Tag" |
Response header inspected for surrogate cache tags. |
WithBackgroundFetchTimeout(d) |
time.Duration |
125s |
Maximum timeout budget for background revalidations (stale-while-revalidate). |
WithStorageTimeout(d) |
time.Duration |
5s |
Maximum time budget for storage reads/writes before fail-open bypass. |
WithRespectClientCacheControl() |
- | (disabled) | When enabled, honors client request Cache-Control: no-cache / no-store. |
WithoutConvertHeadToGet() |
- | (enabled) | Disables converting origin HEAD cache misses to GET to prime the cache with body bytes. |
WithAutoInvalidateMutatingMethods() |
- | (disabled) | RFC 9111 §4.4: Auto-purges URI cache when mutating requests (POST/PUT/DELETE) succeed. |
WithLogger(l) |
*slog.Logger |
slog.Default() |
Structured logger instance for diagnostic events. |
WithMetrics(reg) |
prometheus.Registerer |
nil |
Prometheus registry for cache and ESI telemetry. |
WithESI(opts...) |
...esi.Option |
disabled |
Edge Side Includes processing configuration and options. |
WithServerTiming() |
- | (disabled) | Enables Server-Timing header diagnostics for TTFB tracing in browser DevTools. |
WithServerTimingCookie(name, val) |
string, string |
"" |
Restricts Server-Timing header generation to requests matching an exact cookie name and value. |
WithStorageCompression(name) |
string |
"lz4" |
Selected compression codec for stored variant bodies ("lz4", "zstd", or "none"). |
WithStorageCompressor(comp) |
Compressor |
- | Registers a custom compression engine implementing Compressor for storage reads/writes. |
Titip constructs normalized cache keys directly without expensive hashing. Use CacheKey to filter query parameters and strip tracking tags to prevent cache fragmentation:
cache, err := titip.New(
store,
titip.WithCacheKey(titip.CacheKey{
// Strip marketing query parameters (utm_*, fbclid, gclid, mc_eid, etc.)
ExcludeMarketingQueryParams: true,
// Allowlist specific query parameters to include (or use ExcludedQueryParams for a denylist)
IncludedQueryParams: []string{"page", "sort", "filter"},
}),
)| Field | Type | Default | Description |
|---|---|---|---|
IncludeProtocol |
bool |
false |
Includes scheme (http:// vs https://) in the cache key. |
ExcludeHost |
bool |
false |
Excludes hostname from the cache key so multiple domains share cache entries. |
ExcludeQuery |
bool |
false |
Ignores all query parameters entirely. |
PreserveQueryOrder |
bool |
false |
Preserves URL query parameter order instead of sorting alphabetically. |
IncludedQueryParams |
[]string |
(all) | Allowlist of query parameters to keep in the cache key. |
ExcludedQueryParams |
[]string |
(none) | Denylist of query parameters to exclude from the cache key. |
ExcludeMarketingQueryParams |
bool |
false |
Strips tracking tags (utm_*, fbclid, gclid, ttclid, etc.). |
IncludedQueryParamValues |
map[string][]string |
(none) | Allowlists specific accepted values per query parameter. |
IncludedHeaderNames |
[]string |
(none) | Appends specific request header values to the primary key (e.g. X-Region). |
IncludedCookieNames |
[]string |
(none) | Appends specific cookie values to the primary key (e.g. currency, theme). |
CaseInsensitivePath |
bool |
false |
Lowercases the URL path for case-insensitive matching. |
Titip supports three Cache-Status modes configured via WithCacheStatus:
Emits a concise status header:
| Status | Description |
|---|---|
HIT |
Served fresh directly from cache or matched downstream conditional 304 Not Modified. |
MISS |
Cache miss: fetched from origin and stored in cache. |
EXPIRED |
Expired cache entry was synchronously revalidated with origin and refreshed (200 OK). |
REVALIDATED |
Expired cache entry was revalidated with origin via conditional headers (304 Not Modified). |
UPDATING |
Stale cache entry served immediately while revalidating asynchronously in the background (stale-while-revalidate). |
STALE |
Stale cache entry served as failover fallback due to origin error (stale-if-error). |
BYPASS |
Caching explicitly bypassed (mutating method, client no-store, Range request, WebSocket). |
DYNAMIC |
Evaluated for caching, but origin response is uncacheable (missing explicit max-age/s-maxage/Expires, or contains Set-Cookie, private, no-store). |
Emits structured diagnostics compliant with RFC 9211, supporting multi-tier cache chaining:
Cache-Status: titip; hit; ttl=240Disables the Cache-Status response header completely.
Titip supports the standard Server-Timing header to inspect cache lookup, decompression, and origin latency in browser DevTools (Network Timing):
# Cache Hit (with LZ4 decompression)
Server-Timing: titip-status;desc="HIT", titip-meta;dur=1.49, titip-body;dur=3.39;desc="lz4", titip;dur=4.95
# Cache Miss (storing compressed variant)
Server-Timing: titip-status;desc="MISS", titip-origin;dur=42.10, titip-store;dur=1.85;desc="lz4 (12.4KB -> 3.8KB, -69%)", titip;dur=44.15| Metric | Description |
|---|---|
titip-status;desc="..." |
Cache status (HIT, MISS, EXPIRED, REVALIDATED, STALE, DYNAMIC, BYPASS). |
titip-meta;dur=X |
Stage 1 metadata lookup duration in milliseconds. |
titip-body;dur=X[;desc="<codec>"] |
Stage 2 body retrieval and decompression duration. Appends ;desc="<codec>" (e.g. desc="lz4") when decompression is performed. |
titip-origin;dur=X |
Upstream backend origin fetch duration (on misses or revalidations). |
titip-store;dur=X[;desc="..."] |
Cache storage duration (compression + storage write). Reports codec, pre/post size, and reduction percentage: desc="<codec> (<rawSize> -> <compSize>, <savedPct>%)" (e.g. desc="lz4 (12.4KB -> 3.8KB, -69%)"), or desc="none (<rawSize>)". |
titip-esi;dur=X;desc="N fragments" |
Edge Side Includes processing duration and fragment count. |
titip;dur=X |
Total Titip processing duration from request arrival. |
Tip
Titip emits Server-Timing via Header.Add, preserving any existing application-level Server-Timing headers sent by upstream backends (e.g. database query timings). Browsers combine them into a unified list.
To prevent exposing internal infrastructure metrics to the general public, gate header generation with a cookie:
cache, err := titip.New(
store,
titip.WithServerTimingCookie("debug_timing", "secret_value"),
)Titip supports configurable internal storage compression to optimize storage memory consumption (e.g. Redis RAM) and network transfer:
"lz4"(default): Ultra-fast compression and decompression with minimal CPU overhead."zstd": Higher compression ratio with fast decompression and bounded memory footprint (512KB window)."none": Uncompressed byte storage with zero CPU overhead.- Pluggable Codecs: Custom algorithms implementing the
Compressorinterface registered viaWithStorageCompressor(comp).
cache, err := titip.New(
store,
titip.WithStorageCompression("zstd"),
)Regardless of the selected compression algorithm, Titip automatically bypasses compression (storing raw bytes with "none") when:
- Payload size is < 256 bytes (avoiding compression frame overhead and size expansion).
- Origin response has a
Content-Encodingheader (e.g.gzip,br,zstd). - Response is an inherently compressed media format (
image/*except SVG/ICO,video/*,audio/*,application/zip,application/pdf).
Titip provides a programmatic Go API for Hierarchical Path Purging, Prefix Purging, Surrogate Tag Purging, and Namespace Invalidation. All purge methods return (int64, error), reporting the total number of logical cache entries invalidated.
// Path Purge (purges /api/products and all its query string variants)
count, err := cache.Purge(ctx, "/api/products")
// Exact Query Invalidation (purges only ?id=10, leaves other queries intact)
count, err := cache.Purge(ctx, "http://example.com/api/products?id=10", titip.WithSoftPurge())
// Prefix Purge (purges all cached paths under /assets/, e.g. /assets/app.js)
count, err := cache.PurgePrefix(ctx, "/assets/")
// Surrogate Tag Invalidation (invalidates all cached entries matching the tag)
count, err := cache.PurgeTag(ctx, "catalog")
// Invalidates all cached entries
count, err := cache.PurgeAll(ctx)Titip supports RFC 9213 Targeted Cache-Control, allowing backend origins to define separate caching rules for the edge/proxy layer versus end-user browsers.
HTTP/1.1 200 OK
Titip-Cache-Control: public, max-age=86400, stale-while-revalidate=3600
Cache-Control: private, no-store- Titip (Intermediary): Caches the response in storage for 24 hours (
max-age=86400), shielding the origin from load. - Client (Browser): Receives
Cache-Control: private, no-store, preventing sensitive data from persisting in local browser history.
Titip never caches heuristically. An origin response is only stored in cache and served fresh if it explicitly specifies a freshness lifetime:
Cache-Control: max-age=<seconds>ors-maxage=<seconds>- Or a future
Expiresdate header
Directives like Cache-Control: public without a max-age grant permission for shared caching, but have a freshness lifetime of 0 seconds and cannot serve fresh cache hits. Responses lacking an explicit freshness duration, or containing Set-Cookie, Cache-Control: private, or no-store, are treated as uncacheable (DYNAMIC) and served directly from origin without being saved to storage.
Titip includes an Edge Side Includes (ESI) engine with parallel fragment fetching, circular loop protection, and SSRF prevention.
When ESI is active, Titip advertises capability to upstream origins by sending Surrogate-Capability: titip="ESI/1.0" per Edge Side Includes (ESI) specifications. Origins can respond with Surrogate-Control: content="ESI/1.0" to direct ESI processing.
For standalone package documentation and options reference, see ESI Package Guide.
cache, err := titip.New(
store,
titip.WithESI(
esi.WithInternalFetcher(esi.HandlerFetcher(router)),
esi.WithMaxDepth(3),
esi.WithMaxTimeout(5 * time.Second),
),
)| Syntax | Description |
|---|---|
<esi:include src="/fragment" /> |
Self-closing fragment include. Fetched concurrently. |
<esi:include src="/fragment" alt="/fallback" onerror="continue" /> |
Include with fallback URL on failure or silent omission (onerror="continue"). |
<esi:include src="...">Fallback HTML</esi:include> |
Paired include with inline fallback block. |
<!--esi <div>Visible only when ESI active</div> --> |
Unescapes enclosed HTML comments when ESI is enabled. |
<esi:remove><p>Placeholder</p></esi:remove> |
Strips placeholder content intended for non-ESI clients. |
<!--esi-comment text="..." --> |
Strips internal comments without emitting bytes. |
| Option Builder | Default | Description |
|---|---|---|
esi.WithHeaderRequired() |
(disabled) | Process ESI only when origin sets Surrogate-Control: content="ESI/1.0". |
esi.WithInternalFetcher(fn) |
nil |
Custom hook for in-memory virtual subrequests (e.g. esi.HandlerFetcher(r)). |
esi.WithMaxDepth(uint32) |
3 |
Maximum nesting depth for recursive ESI includes. |
esi.WithMaxTimeout(duration) |
30s |
Maximum time budget per fragment include fetch. |
esi.WithMaxConcurrentRequests(int) |
8 |
Maximum concurrent fetch goroutines per document. |
esi.WithAllowPrivateIPs() |
(disabled) | SSRF guard: allows requests to private, loopback, and link-local IP addresses. |
esi.WithAllowedHosts(...string) |
[] |
List of allowed external hosts for domain includes (empty allows all public hosts). |
esi.WithAllowPrivateIPsForAllowedHosts() |
(disabled) | Permits private IPs specifically for explicitly allowed hosts. |
esi.WithMaxResponseSize(int64) |
10MB |
Maximum allowed fragment body size in bytes. |
esi.WithoutForwardCookies() |
(forwarding enabled) | Disables forwarding Set-Cookie headers from fragment responses to the client. |
esi.WithPreserveETag() |
(disabled) | Weakens origin ETag (W/"...") and preserves Last-Modified for downstream 304. By default, strips ETag/Last-Modified downstream to guarantee fresh fragment execution. |
esi.WithIncludeErrorMarker(string) |
"" |
HTML placeholder rendered on unhandled fetch errors. |
Titip exports comprehensive Prometheus metrics for request traffic, cache latencies, purge invalidations, and ESI fragment processing:
// Register with a Prometheus registry:
cache, err := titip.New(
store,
titip.WithMetrics(prometheus.DefaultRegisterer),
)| Metric Name | Type | Labels | Description |
|---|---|---|---|
titip_requests_total |
Counter | status (hit, miss, stale_hit, revalidated, bypass, error) |
Total HTTP requests processed by Titip caching middleware. |
titip_request_duration_seconds |
Histogram | status |
Request latency distribution in seconds across cache statuses. |
titip_purges_total |
Counter | type (url, tag, all), mode (hard, soft), status (success, error) |
Total purge operations executed by type and mode. |
titip_purged_entries_total |
Counter | type (url, tag, all), mode (hard, soft) |
Total logical cache entries invalidated by purge operations. |
titip_esi_fragments_total |
Counter | status (success, fallback, error) |
Total ESI fragment includes processed (enabled when ESI is active). |
titip_esi_duration_seconds |
Histogram | mode (in_process, outbound) |
Latency distribution of ESI fragment fetching and document splicing. |
# Example Prometheus Scrape Output:
titip_requests_total{status="hit"} 4125
titip_requests_total{status="miss"} 102
titip_requests_total{status="stale_hit"} 18
titip_requests_total{status="revalidated"} 12
titip_requests_total{status="bypass"} 5
titip_requests_total{status="error"} 0
titip_purges_total{mode="soft",status="success",type="url"} 45
titip_purged_entries_total{mode="soft",type="url"} 45
titip_esi_fragments_total{status="success"} 230
titip_esi_duration_seconds_bucket{mode="in_process",le="0.005"} 225
Call cache.Close(ctx) during application teardown to cleanly drain background tasks:
// Wait for all in-flight background SWR revalidations to complete
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := cache.Close(ctx); err != nil {
log.Printf("cache close error: %v", err)
}Close blocks until all active background stale-while-revalidate (SWR) goroutines have finished or the context expires, and closes the underlying storage driver if it implements storage.Closer or io.Closer.
Titip is tested under continuous race detection and high-concurrency workloads:
# Run all unit and concurrency stress tests with race detector
go test -race -count=50 -v ./...We welcome contributions for new framework adapters and storage drivers.
Please read our Contributing Guide for architectural guidelines, interface contracts, and testing standards.
This project is licensed under the Apache 2.0 License.