Caching

January 20, 2026 ยท View on GitHub

Edge caching reduces load on origin servers and improves response times. Zigly provides both simple and transactional caching APIs.

Simple Cache Operations

Insert

Store data in the cache:

const zigly = @import("zigly");
const cache = zigly.cache;

fn cacheData(key: []const u8, data: []const u8) !void {
    // Insert with 1 hour TTL
    var body = try cache.insert(key, .{
        .max_age_ns = cache.secondsToNs(3600),
    });
    try body.writeAll(data);
    try body.close();
}

Lookup

Retrieve cached data:

fn getCached(allocator: Allocator, key: []const u8) !?[]u8 {
    var entry = cache.lookup(key, .{}) catch |err| {
        if (err == FastlyError.FastlyNone) return null;
        return err;
    };
    defer entry.close() catch {};

    const state = try entry.getState();
    if (!state.isFound() or !state.isUsable()) {
        return null;
    }

    var body = try entry.getBody(null);
    defer body.close() catch {};

    return try body.readAll(allocator, 0);
}

Check State

The cache entry state tells you about the entry:

const state = try entry.getState();

if (state.isFound()) {
    // Entry exists in cache
}

if (state.isUsable()) {
    // Entry can be served (not expired)
}

if (state.isStale()) {
    // Entry is past TTL but within stale-while-revalidate window
}

if (state.mustInsertOrUpdate()) {
    // For transactions: you need to populate this entry
}

Write Options

Control cache behavior with WriteOptions:

const options = cache.WriteOptions{
    // Required: How long the entry is fresh
    .max_age_ns = cache.secondsToNs(300),  // 5 minutes

    // Serve stale content while revalidating
    .stale_while_revalidate_ns = cache.secondsToNs(60),  // 1 minute

    // Tag for purging
    .surrogate_keys = "product-123 category-electronics",

    // Known content length (enables streaming)
    .length = 1024,

    // Custom metadata (not served to clients)
    .user_metadata = "version=2",

    // Don't cache in edge memory (PCI compliance)
    .sensitive_data = true,
};

Time Helpers

Convert between time units:

const cache = zigly.cache;

// Seconds to nanoseconds
const one_hour = cache.secondsToNs(3600);

// Milliseconds to nanoseconds
const half_second = cache.msToNs(500);

Transactional Caching

Transactional caching prevents cache stampedes. When multiple requests arrive for the same uncached key, only one fetches from the origin:

fn fetchWithTransaction(allocator: Allocator, key: []const u8) ![]u8 {
    var tx = try cache.transactionLookup(key, .{});
    defer tx.close() catch {};

    const state = try tx.getState();

    if (state.isUsable()) {
        // Cache hit, return cached data
        var body = try tx.getBody(null);
        defer body.close() catch {};
        return try body.readAll(allocator, 0);
    }

    if (state.mustInsertOrUpdate()) {
        // We won the race, fetch and cache
        const data = try fetchFromOrigin(key);

        var result = try tx.insert(.{
            .max_age_ns = cache.secondsToNs(300),
        });
        try result.body.writeAll(data);
        try result.body.close();

        return data;
    }

    // Another request is populating, wait or fetch
    return try fetchFromOrigin(key);
}

Insert and Stream Back

Populate the cache while simultaneously returning data to the caller:

fn insertAndStream(allocator: Allocator, key: []const u8) ![]u8 {
    var tx = try cache.transactionLookup(key, .{});
    const state = try tx.getState();

    if (state.mustInsertOrUpdate()) {
        const data = try fetchFromOrigin(key);

        var result = try tx.insertAndStreamBack(.{
            .max_age_ns = cache.secondsToNs(300),
        });

        // Write to cache
        try result.body.writeAll(data);
        try result.body.close();

        // Read back from cache entry
        var cached_body = try result.entry.getBody(null);
        defer cached_body.close() catch {};
        return try cached_body.readAll(allocator, 0);
    }

    // Hit path...
}

Update Metadata

Update cache metadata without changing the body:

fn refreshTtl(key: []const u8) !void {
    var tx = try cache.transactionLookup(key, .{});
    defer tx.close() catch {};

    const state = try tx.getState();
    if (state.isFound()) {
        try tx.update(.{
            .max_age_ns = cache.secondsToNs(600),  // Extend TTL
        });
    }
}

Replace Operation

Atomically replace a cache entry:

fn replaceEntry(key: []const u8, new_data: []const u8) !void {
    var handle = try cache.replace(key, .{});

    // Check if old entry exists
    const state = try handle.getState();
    if (state.isFound()) {
        // Can read old data if needed
        var old_body = try handle.getBody(null);
        const old_data = try old_body.readAll(allocator, 0);
        // Use old_data...
    }

    // Insert new data
    var body = try handle.insert(.{
        .max_age_ns = cache.secondsToNs(300),
    });
    try body.writeAll(new_data);
    try body.close();
}

Cache Metadata

Read metadata from cache entries:

fn inspectEntry(key: []const u8) !void {
    var entry = try cache.lookup(key, .{});
    defer entry.close() catch {};

    const length = try entry.getLength();
    const age_ns = try entry.getAgeNs();
    const max_age_ns = try entry.getMaxAgeNs();
    const hits = try entry.getHits();

    std.debug.print("Size: {}, Age: {}ms, TTL: {}ms, Hits: {}\n", .{
        length,
        age_ns / cache.ms_per_ns,
        max_age_ns / cache.ms_per_ns,
        hits,
    });
}

Range Requests

Read partial content from cache:

fn getPartialContent(key: []const u8, start: u64, end: u64) ![]u8 {
    var entry = try cache.lookup(key, .{});
    defer entry.close() catch {};

    var body = try entry.getBody(.{
        .from = start,
        .to = end,
    });
    defer body.close() catch {};

    return try body.readAll(allocator, end - start);
}

Surrogate Keys and Purging

Tag entries for later purging:

// When caching
var body = try cache.insert("product-123-page", .{
    .max_age_ns = cache.secondsToNs(3600),
    .surrogate_keys = "product-123 all-products",
});

// Later, purge all entries with the key
const purge = zigly.purge;
try purge.purge("product-123");  // Hard purge

// Or soft purge (mark stale, serve while revalidating)
try purge.softPurge("all-products");

Request-Level Cache Control

Override caching at the request level:

fn proxyWithCache() !void {
    var downstream = try zigly.downstream();

    // Force specific TTL for this request
    try downstream.request.setCachingPolicy(.{
        .ttl = 600,          // Cache for 10 minutes
        .serve_stale = 3600, // Serve stale for 1 hour if origin fails
        .surrogate_key = "api-responses",
    });

    try downstream.proxy("api", null);
}

Bypass cache entirely:

try downstream.request.setCachingPolicy(.{ .no_cache = true });

Async Operations

For non-blocking cache lookups:

fn asyncLookup(key: []const u8) !void {
    var busy_handle = try cache.transactionLookupAsync(key, .{});

    // Do other work...

    // Wait for result
    var tx = try busy_handle.wait();
    defer tx.close() catch {};

    // Use transaction...
}

Patterns

Cache-Aside

Check cache first, fetch on miss:

fn getData(allocator: Allocator, key: []const u8) ![]u8 {
    // Check cache
    if (try getCached(allocator, key)) |data| {
        return data;
    }

    // Fetch from origin
    const data = try fetchFromOrigin(key);

    // Store in cache (fire and forget)
    cacheData(key, data) catch {};

    return data;
}

Stale-While-Revalidate

Serve stale content while refreshing in the background:

var body = try cache.insert(key, .{
    .max_age_ns = cache.secondsToNs(60),     // Fresh for 1 minute
    .stale_while_revalidate_ns = cache.secondsToNs(300),  // Serve stale for 5 more minutes
});

When a request arrives during the stale period, Fastly serves the stale content immediately and triggers a background revalidation.

Next Steps