The series:
- Route-Scoped Middleware
- HTTP Caching Done Right (you are here)
- Server-Sent Events You Can Test
- AI Routing and Gateways
- 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:
- Your server returns a resource with a validator: an
ETag(a fingerprint) or aLast-Modifieddate. - Next time, the client sends it back:
If-None-Match: "abc123"orIf-Modified-Since: .... - If nothing changed, you answer
304 Not Modifiedwith 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
ETagheader (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 asnoExecution(), and returnstrue
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-MatchandIf-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:
| Annotation | Effect |
|---|---|
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
| Situation | Reach for |
|---|---|
| You know the version/hash of the data | event.etag() |
| You only have an updated timestamp | event.lastModified() |
| You want clients/CDNs to skip requests for a while | event.cacheControl() / withCacheControl() |
| Whole-event output caching already in place | etag="true" / lastModified="true" annotations |
| Different policies per URL for the same handler | Router.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