Caching with Harper
Every production application hits the same wall eventually: an external API you depend on is slow, rate-limited, or expensive to call. Harper's caching system lets you wrap any external data source — a REST API, a microservice, a database — and serve responses from a fast local cache while transparently fetching fresh data only when needed.
In this guide you will build a Harper application that caches responses from a public API, observe caching behavior using ETags and HTTP status codes, and learn how to invalidate entries on demand.
What You Will Learn
- How to define a cache table using the
@table(expiration:)schema directive - How to wrap an external data source with a custom Resource class
- How to connect a data source to a cache table using
sourcedFrom - How to observe caching behavior through ETag and
304 Not Modifiedresponses - How to manually invalidate a cached entry
Prerequisites
- Completed Install and Connect Harper
- Completed Create Your First Application
- Working Harper installation (local or Fabric)
- A command-line HTTP client (
curlrecommended) or familiarity withfetch
Setting Up the Application
Clone the example repository and open it in your editor. If you are using a container install, clone into the mounted dev/ directory.
git clone https://github.com/HarperFast/caching-guide-example.git harper-caching
The repository has the following structure:
harper-caching/
├── config.yaml
├── schema.graphql
└── resources.js
Start Harper in dev mode from inside the directory:
harper dev .
Defining a Cache Table
Open schema.graphql. The cache table is defined with a single addition to the familiar @table directive: an expiration argument.
type JokeCache @table(expiration: 60) @export {
id: ID @primaryKey
setup: String
punchline: String
}
The expiration: 60 argument tells Harper that any record in this table is considered stale after 60 seconds. When a stale record is requested, Harper fetches a fresh copy from the source resource and stores it before returning the response.
A table's expiration is measured in seconds. Harper also supports separate eviction and scanInterval arguments if you need fine-grained control over when records are physically removed from the table. See the Schema reference for details.
Wrapping an External Data Source
The source for the cache is a simple object in resources.js. The public jokeAPI returns a joke by ID as a JSON object — a perfect stand-in for any real external API.
// resources.js
const jokeAPI = {
async get(id) {
const response = await fetch(`https://official-joke-api.appspot.com/jokes/${id}`);
return response.json();
},
};
tables.JokeCache.sourcedFrom(jokeAPI);
sourcedFrom registers jokeAPI as the upstream source for JokeCache. Harper's caching behavior now works as follows:
- A request arrives for
/JokeCache/1. - Harper checks if the record with id
1exists inJokeCacheand is not stale. - If it is fresh, Harper returns it immediately.
- If it is missing or stale, Harper calls
jokeAPI.get()to fetch the data, stores it inJokeCache, and returns the result.
Multiple simultaneous requests for the same missing or stale record will all wait on a single upstream call — Harper prevents cache stampedes automatically.
Configuring the Application
With the schema and resource in place, open config.yaml and enable the two plugins this application needs:
graphqlSchema:
files: 'schema.graphql'
rest: true
jsResource:
files: 'resources.js'
graphqlSchemaloadsschema.graphqland creates theJokeCachetable.restenables Harper's REST API on port9926, exposing any@export-ed tables and resources as HTTP endpoints.jsResourceloadsresources.js, registering thejokeAPIsource and thesourcedFromconnection — as well as any exported Resource classes as endpoints.
Restart Harper (or let harper dev pick up the change automatically), then continue.
If you need to check your work, checkout the 01-cached-api branch.
Making Your First Cached Request
With Harper running, fetch a joke:
- curl
- fetch
curl -i 'http://localhost:9926/JokeCache/1'
const response = await fetch('http://localhost:9926/JokeCache/1');
console.log(response.status); // 200
console.log(response.headers.get('etag'));
const joke = await response.json();
console.log(joke);
You should see a 200 response:
- curl
- fetch
HTTP/1.1 200 OK
content-type: application/json
etag: "abCDefGHij"
...
{
"id": 1,
"type": "general",
"setup": "What did the ocean say to the beach?",
"punchline": "Nothing, it just waved."
}
200
"abCDefGHij"
{ id: 1, type: 'general', setup: 'What did the ocean say to the beach?', punchline: 'Nothing, it just waved.' }
Note the double quotes on the ETag — they are part of the value itself, not just string delimiters. You will need to include them when passing the ETag back in a request header.
Harper automatically computes an ETag from the record's last-modified timestamp. This is the key to downstream caching.
Observing Caching Behavior with ETags
Make the same request again, this time passing the ETag back in the If-None-Match header:
- curl
- fetch
# Use the etag value from the previous response, double quotes included
curl -i 'http://localhost:9926/JokeCache/1' \
-H 'If-None-Match: "abCDefGHij"'
// Store the etag from the first request
const first = await fetch('http://localhost:9926/JokeCache/1');
const etag = first.headers.get('etag'); // e.g. "abCDefGHij"
// Second request using the etag
const second = await fetch('http://localhost:9926/JokeCache/1', {
headers: { 'If-None-Match': etag },
});
console.log(second.status); // 304
- curl
- fetch
HTTP/1.1 304 Not Modified
etag: "abCDefGHij"
304
The response status will be 304 Not Modified with an empty body. Harper compared the record's current ETag to the one you sent and found them identical — the data hasn't changed, so there's nothing to transfer.
This is standard HTTP conditional request behavior. Any HTTP cache layer between your client and Harper — a CDN, a service worker, or a browser cache — can use this same mechanism to avoid redundant data transfers.
The ETag / If-None-Match pattern is documented in detail in the REST Headers reference.
Watching Cache Expiration
The JokeCache table has a 60-second expiration. After 60 seconds, the cached record becomes stale and the next request will fetch a fresh copy from jokeAPI.
You can force this behavior immediately by passing the no-cache directive in the Cache-Control request header, which tells Harper to bypass the local cache and always go to the source:
- curl
- fetch
curl -i 'http://localhost:9926/JokeCache/1' \
-H 'Cache-Control: no-cache'
const response = await fetch('http://localhost:9926/JokeCache/1', {
headers: { 'Cache-Control': 'no-cache' },
});
You will see a 200 response, and if you check the Harper logs you will see an outbound request to jokeAPI.
Invalidating a Cache Entry
Sometimes you know the source data has changed and you do not want to wait for the TTL to expire. Harper's Resource API exposes an invalidate method that marks a cached record as stale immediately, so it will be reloaded from the source on the next access.
First, remove the @export directive from the JokeCache schema:
type JokeCache @table(expiration: 60) {
id: ID @primaryKey
setup: String
punchline: String
}
Then, create an exported class of the same name in resources.js with a custom POST handler:
export class JokeCache extends tables.JokeCache {
static async post(target, data) {
const body = await data;
if (body?.action === 'invalidate') {
this.invalidate(target);
return { status: 200, data: { message: 'invalidated' } };
}
}
}
By exporting this class, Harper registers it as the endpoint for /JokeCache. The @export directive in the schema is no longer required separately because the export is provided by this class.
Now you can trigger invalidation with a POST request:
- curl
- fetch
curl -X POST 'http://localhost:9926/JokeCache/1' \
-H 'Content-Type: application/json' \
-d '{"action": "invalidate"}'
await fetch('http://localhost:9926/JokeCache/1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'invalidate' }),
});
The next GET /JokeCache/1 will trigger a fresh fetch from jokeAPI regardless of whether the TTL has expired.
If you need to check your work, checkout the 02-invalidate-example branch.
Putting It All Together
Here is the complete resources.js for this guide:
// resources.js
const jokeAPI = {
async get() {
const id = this.getId();
const response = await fetch(`https://official-joke-api.appspot.com/jokes/${id}`);
return response.json();
},
};
tables.JokeCache.sourcedFrom(jokeAPI);
export class JokeCache extends tables.JokeCache {
static async post(target, data) {
const body = await data;
if (body?.action === 'invalidate') {
this.invalidate(target);
return { status: 200, data: { message: 'invalidated' } };
}
}
}
And the complete schema.graphql:
type JokeCache @table(expiration: 60) {
id: ID @primaryKey
setup: String
punchline: String
}
What Comes Next
This guide covered the passive caching pattern: Harper fetches from the source on demand and serves the cached copy until the TTL expires. The next guide, Caching AI Generations with Harper, applies these same techniques to a real-world problem — caching expensive AI-generated content so that you don't pay for the same generation twice.
Additional Resources
- Database Schema —
@tabledirective andexpirationargument - Resource API —
sourcedFrom,invalidate, static and instance methods - REST Headers — ETag and
If-None-Matchconditional requests - REST Overview — HTTP methods and URL structure
- react-ssr-example — A full example using
sourcedFromto cache server-rendered HTML pages