Blog

Luis Majano

September 30, 2026

Spread the word


Share your thoughts

The series:

  1. Route-Scoped Middleware
  2. HTTP Caching Done Right (you are here)
  3. Server-Sent Events You Can Test
  4. AI Routing and Gateways
  5. Capstone: Building a Live Support Desk

In Part 1 we moved security policy into the router. Today we do the same for caching, and we pick up one of the most underused performance wins on the web along the way.

The Cheapest Response Is the One You Don't Send

Every browser and CDN already speaks a protocol for "I have this, has it changed?" It's called conditional GET:

  1. Your server returns a resource with a validator: an ETag (a fingerprint) or a Last-Modified date.
  2. Next time, the client sends it back: If-None-Match: "abc123" or If-Modified-Since: ....
  3. If nothing changed, you answer 304 Not Modified with no body. The client reuses its copy.

The payoff is real: less bandwidth, less rendering work, less serialization, and faster perceived loads. It's especially valuable for APIs polled by mobile clients and for pages behind a CDN.

Almost nobody implemented it in ColdBox apps, because doing it by hand meant quoting ETags correctly, parsing comparison headers, knowing which HTTP methods are safe to short-circuit, and remembering to skip rendering. ColdBox 8.2.0 does all of that for you.

event.etag(): One if

// handlers/Products.bx
class {

    property name="productService" inject;

    function show( event, rc, prc ){
        prc.product = productService.getOrFail( rc.id );

        if ( event.etag( prc.product.getVersionHash() ) ) {
            return; // 304 sent, nothing else to do
        }

        event.setView( "products/show" );
    }

}

That single call:

  • sets the ETag header (you pass the raw value, quoting is handled for you)
  • compares it against If-None-Match
  • on a match, sets status 304, marks the request as noExecution(), and returns true

It never short-circuits unsafe methods. Only GET and HEAD can produce a 304, so a POST, PUT, or DELETE always runs.

What makes a good ETag?

Anything that changes when the representation changes. A few solid options:

// A version column or row counter
event.etag( product.getVersion() );

// A hash of the fields that affect the response
event.etag( hash( product.getUpdatedDate() & product.getPrice() & rc.locale ) );

// A weak validator, when semantically equivalent is good enough (e.g. whitespace can differ)
event.etag( product.getVersion(), weak : true ); // ETag: W/"42"

Pro tip: if the response varies by user, locale, or format, fold that into the ETag. Two users should never share a validator for different content.

event.lastModified(): When You Have a Timestamp

If your data carries an updated date, that's a validator too:

function show( event, rc, prc ){
    prc.article = articleService.getOrFail( rc.slug );

    if ( event.lastModified( prc.article.getUpdatedDate() ) ) {
        return;
    }

    event.setView( "articles/show" );
}

Two details handled for you:

  • HTTP dates have second granularity. If your timestamps carry milliseconds, round down, never up, to avoid reporting a resource as modified when it isn't.
  • Per RFC 7232, when a request carries both If-None-Match and If-Modified-Since, the date is ignored. The ETag is the more precise signal. Setting both validators is safe; ColdBox applies the right precedence.
function show( event, rc, prc ){
    prc.article = articleService.getOrFail( rc.slug );

    if ( event.etag( prc.article.getVersion() ) ) return;
    if ( event.lastModified( prc.article.getUpdatedDate() ) ) return;

    event.setView( "articles/show" );
}

event.cacheControl(): Tell Caches What to Do

Validators answer "has it changed?". Cache-Control answers "how long can you keep it without asking?". Pass a struct: true becomes a bare directive, anything else becomes key=value:

event.cacheControl( {
    "public"                 : true,
    "max-age"                : 60,
    "stale-while-revalidate" : 30
} );
// Cache-Control: public, max-age=60, stale-while-revalidate=30

Common recipes:

// Private, per-user data: browser may cache, shared caches (CDNs) must not
event.cacheControl( { "private" : true, "max-age" : 0, "must-revalidate" : true } );

// Public catalog pages behind a CDN
event.cacheControl( { "public" : true, "max-age" : 300, "s-maxage" : 3600 } );

// Sensitive responses: never store
event.cacheControl( { "no-store" : true } );

Combine max-age with an ETag and you get the best of both: zero requests within the freshness window, and cheap 304s after it.

REST Handlers

Inside a RestHandler, use event.etag() for the conditional check, then build your response as usual. The REST handler respects the noExecution() flag, so a 304 goes out clean, with no JSON envelope:

// handlers/api/v1/Products.bx
class extends="coldbox.system.RestHandler" {

    property name="productService" inject;

    function show( event, rc, prc ){
        var product = productService.getOrFail( rc.id );

        if ( event.etag( product.getVersionHash() ) ) {
            return;
        }

        event.getResponse()
            .withCacheControl( { "private" : true, "max-age" : 60 } )
            .setData( product.getMemento() );
    }

}

Response also has withETag(), which only sets the header. Use it when you want to advertise a validator without doing the comparison yourself (for example, on a response you'll never serve conditionally). For the actual 304 negotiation, reach for event.etag().

Zero-Code Validators With Event Caching

Already caching whole events with cache="true"? You can now have ColdBox compute the ETag and/or Last-Modified once, at cache-write time, and reuse them on every cache hit:

function show( event, rc, prc ) cache="true" cacheTimeout="30" etag="true" lastModified="true" {
    prc.entry = entryService.get( rc.entryID );
    event.setView( "blog/showEntry" );
}

The available annotations:

AnnotationEffect
etag="true"Compute and serve an ETag for the cached entry
etagWeak="true"Serve it as a weak validator
lastModified="true"Serve the cache-write time as Last-Modified
cacheControl="..."Explicit Cache-Control header for this event

With no explicit cacheControl, you get a sensible default of private, max-age=<cacheTimeout in seconds>.

This is fully opt-in. Existing cache="true" actions behave exactly as before until you add one of these. As with all event caching, it requires eventCaching : true in your coldbox settings.

Route-Level Caching With withCache()

Annotations tie caching policy to the handler. Sometimes the policy belongs to the URL instead. withCache() declares it in the router:

// config/Router.bx
route( "/products/:id" )
    .withCache( timeout : 30, etag : true, cacheControl : "public, max-age=60" )
    .toHandler( "products.show" );

withCache() mirrors the handler annotations: timeout, lastAccessTimeout, provider, suffix, include, exclude, filter, plus etag, etagWeak, lastModified, and cacheControl (as a header string). A route that opts in takes full precedence over the handler's own cache annotations for that request. Routes that don't opt in are untouched.

One handler, many policies

This is where route-level caching earns its keep:

// Public catalog: cache hard, share at the CDN
route( "/catalog/:id" )
    .withCache( timeout : 60, etag : true, cacheControl : "public, max-age=300, s-maxage=3600" )
    .toHandler( "products.show" );

// Partner API: short cache, revalidate often
route( "/partners/products/:id" )
    .middleware( "api" )
    .withCache( timeout : 5, etag : true, cacheControl : "private, max-age=0, must-revalidate" )
    .toHandler( "products.show" );

// Admin preview: never cached
route( "/admin/preview/:id" )
    .middleware( "admin" )
    .toHandler( "products.show" );

Same handler, three audiences, three caching strategies, all declared next to the middleware from Part 1. One file tells you who can reach a URL and how it's cached.

Cache keys that vary

Use suffix when the cached output varies by something outside the URL, like locale:

route( "/pages/:slug" )
    .withCache( timeout : 60, etag : true, suffix : ( event ) => event.getValue( "locale", "en" ) )
    .toHandler( "pages.show" );

Closures are evaluated on every read, never frozen at registration, so a request-time value like locale or tenant is always fresh.

Protect Your Own Interceptors

A 304 (and, as we'll see in Part 3, an SSE stream) means the response was already committed. If you have a global postProcess interceptor that renders or appends output unconditionally, guard it:

function postProcess( event, data, buffer, rc, prc ){
    if ( event.isNoExecution() ) {
        return;
    }
    // ... your logic
}

Testing It

Conditional GET is easy to integration test: make one request, capture the ETag, then replay it.

// tests/specs/integration/ProductCachingSpec.bx
class extends="coldbox.system.testing.BaseTestCase" appMapping="/" {

    function run(){
        describe( "Product ETags", () => {

            beforeEach( () => setup() );

            it( "returns 304 when the client already has the latest version", () => {
                var first = this.get( "/products/1" );
                var etag  = first.getResponseHeaders()[ "ETag" ];
                expect( etag ).notToBeEmpty();

                setup(); // fresh request
                var second = this.get( route : "/products/1", headers : { "If-None-Match" : etag } );

                expect( second.getStatusCode() ).toBe( 304 );
                expect( second.isNoExecution() ).toBeTrue();
            } );

            it( "never short-circuits a POST", () => {
                var event = this.post( route : "/products/1/reviews", headers : { "If-None-Match" : "*" } );
                expect( event.getStatusCode() ).notToBe( 304 );
            } );

        } );
    }

}

Choosing the Right Tool

SituationReach for
You know the version/hash of the dataevent.etag()
You only have an updated timestampevent.lastModified()
You want clients/CDNs to skip requests for a whileevent.cacheControl() / withCacheControl()
Whole-event output caching already in placeetag="true" / lastModified="true" annotations
Different policies per URL for the same handlerRouter.withCache()

Why This Matters

For developers, it's a one-line change with measurable results.

For teams running at scale, it's a cost story. Every 304 is a response your servers didn't render, your database didn't query, and your CDN didn't pay egress for. Standards-based caching is also the kind of optimization that ages well: it works with every browser, proxy, and CDN you'll ever put in front of your app, with no vendor lock-in.

Up Next

In Part 3: Server-Sent Events You Can Test, we go from "don't send anything" to "never stop sending": real-time streaming with event.sse(), HTML-over-the-wire with sendView(), and the mock emitter that makes streaming endpoints as testable as any other request.

box update coldbox

Add Your Comment

Recent Entries

Assert Like You Mean It, TestBox 7.1 Part 4 : Grouped Assertions and Collection Modes

Assert Like You Mean It, TestBox 7.1 Part 4 : Grouped Assertions and Collection Modes

A validation spec usually checks several fields at once: name is set, email looks right, total is positive, status is one of an allowed list. Written as separate it() assertions, TestBox stops at the first failure and you fix it, rerun, and find out about the second failure. Five fields, potentially five rounds of that.

Luis Majano
Luis Majano
September 30, 2026
Assert Like You Mean It, TestBox 7.1 Part 3: Data Navigator

Assert Like You Mean It, TestBox 7.1 Part 3: Data Navigator

Almost every application eventually tests a nested piece of data: an API response, a JSON config file, a serialized object, module metadata. The data is a struct that contains an array that contains structs that contain more structs, and the thing you actually care about is one value buried at the bottom.

This post has two halves. First we look at BoxLang's Data Navigator, the language feature that makes nested data pleasant to work with. Then we look at the six TestBox 7.1 expectations built on top of it. If you have never used dataNavigate(), start at the top. If you already know it, skip to The six expectations.

Luis Majano
Luis Majano
September 29, 2026
try.boxlang.io Now Runs Every BoxLang Version, With or Without CFML

try.boxlang.io Now Runs Every BoxLang Version, With or Without CFML

The BoxLang playground at try.boxlang.io just got a major upgrade. You can now choose any released version of BoxLang and toggle CFML Compat mode on or off, right from the editor toolbar. No installs, no Docker images, no servers. Open a browser, write code, hit Run.

And the best part? The whole thing is powered by BoxLang Serverless on AWS Lambda, built and deployed from a single repository. Keep reading, because if you have ever wondered what BoxLang can do in the cloud, this is your answer. ☁️

Luis Majano
Luis Majano
September 28, 2026