There's an old joke in software development that says that there are only two hard problems: cache invalidation and naming.
Recently, when building an app on Google Apps Script, I walked straight into it.
I didn't write the code. I directed it with Claude Code. I'm not a software developer, I'm more of a software director. Instead of guiding the film crew, I guide AI agents.
The setup
I built a web app for a local kayak club that members can use to sign up to weekly events.
The app runs on Google Apps Script with a Google Sheet as the database, which consists of several sheets.
Only one of them is the actual sign-up list. The other sheets have config data:
- the boat fleet (a list of all available and broken kayaks),
- the season dates (summer vs. winter schedule),
- special events (outside of the regular schedule),
- canceled events.
Every time a member opens the app, the server needs all of this data to work out what's going on this week.
Otherwise, the weekly schedule wouldn't show the accurate events.
The problem: cache and cache invalidation
I'm not a developer (some people would call me a vibe coder). So when I built the first version of the web app, I noticed that it was very accurate and... very slow.
Reading several sheets through Apps Script's built-in SpreadsheetApp service on every single page load wasn't an example of great implementation. Every getDataRange().getValues() is a round-trip to Google's backend. So with a few sheets holding data, these round-trips quickly added up.
So I decided to cache them using the Apps Script built-in CacheService and storing each sheet as JSON for six hours (the maximum time-to-live allowed):
function cacheWrapRead(key, fetcher) {
var cache = CacheService.getScriptCache();
var stored = cache.get(CACHE_PREFIX + key);
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
// fall through and re-fetch
}
}
var fresh = fetcher();
try {
cache.put(CACHE_PREFIX + key, JSON.stringify(fresh), CACHE_TTL_SECONDS);
} catch (e) {
// Cache write can fail if the payload is too large — ignore, read uncached
}
return fresh;
}
Page loads got noticeably faster.
Caching seemed like the right answer to the page load problem. But this simple web app has real users, and this meant: things didn't always go as planned.
Sometimes a committee member needed to change a headcount cap, cancel an event, mark a boat as broken... In these cases, caching produced stale copies of data.
That's where I learned first-hand about the importance of cache invalidation. I might never have run into this question if I'd just vibe coded an app for personal use.
The fix
Apps Script has a simple trigger called onEdit.
It fires whenever someone edits a cell in Google Sheets, which can have different tabs.
Each tab maps to one cache key:
var SHEET_TO_CACHE_KEY = {
'Boats': 'boats',
'Schedule': 'schedule',
'Config': 'config',
'Special Events': 'specialEvents',
'Cancelled Dates': 'cancelledDates',
};
So when a committee member edits a cell in a tab, the trigger drops the cache entry for that specific tab:
function onEdit(e) {
if (!e || !e.range) return;
try {
var sheetName = e.range.getSheet().getName();
var cacheKey = SHEET_TO_CACHE_KEY[sheetName];
if (cacheKey) {
CacheService.getScriptCache().remove(CACHE_PREFIX + cacheKey);
}
// (edits to a cap column also trigger a re-shuffle of sign-ups — omitted here)
} catch (err) {
// Never let a trigger failure block the edit from completing
}
}
Every other unedited sheet with config data still loads a cached version.
The invalidation tactic
Here's the detail that makes this cache invalidation smooth: simple triggers don't fire for writes made by scripts.
That matters because the app writes to the database constantly. Every sign-up and cancellation appends or updates a row. If onEdit fired on those, it would be clearing caches hundreds of times a week, slowing down the app.
Curious how this whole app came about? Read how I built a mobile-friendly web app in 20 hours.