Caching SheetsAPI Responses for Better Performance
Strategies for caching Google Sheet data at the edge, in Next.js ISR, and with HTTP cache headers — so your app stays fast even as your sheet grows.
Why caching matters here
Every SheetsAPI request ultimately reads from Google Sheets. Google Sheets isn't a database — it's a spreadsheet that happens to have an API. Under load, or with large sheets, response times can vary. More importantly, most applications don't need real-time data: a pricing table updated once a day, a content feed refreshed hourly, or a dashboard rebuilt every few minutes can all tolerate stale data within a defined window.
Caching moves the latency question from "how fast is Google Sheets today" to "how old is this cached response allowed to be." That's a tradeoff you control.
HTTP cache headers
SheetsAPI responses include standard HTTP cache headers. If you're calling from a server environment, your HTTP client or infrastructure can respect these automatically.
When you fetch with Cache-Control: max-age=N in mind, you can layer caching at multiple levels:
- The CDN or reverse proxy in front of your application (Cloudflare, Fastly, Vercel Edge Network)
- A server-side in-memory cache in your application
- The browser cache for public data fetched directly from the client
For Cloudflare in front of your origin:
Cache-Control: public, max-age=300, stale-while-revalidate=60
This tells Cloudflare to serve the cached response for 5 minutes, and then serve stale while revalidating for an additional 60 seconds. The first request after expiry triggers a background refresh — subsequent requests during that 60-second window still get a fast cached response.
Next.js Server Components with revalidate
Next.js 13+ App Router fetch requests accept a next.revalidate option that plugs into the built-in
data cache. Fetches with the same URL are deduplicated and cached between server renders.
// app/products/page.tsx
async function getProducts() {
const res = await fetch(
"https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/products" +
"?sort=name&limit=200",
{
next: { revalidate: 3600 }, // re-fetch at most once per hour
}
);
if (!res.ok) throw new Error("Failed to fetch products");
const payload = await res.json();
return payload.data;
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p, i) => (
<li key={i}>{p.name} — {p.price}</li>
))}
</ul>
);
}With revalidate: 3600, Next.js serves the cached response on every request and triggers a background
re-fetch after the TTL expires. The page regenerates without a deploy. This is ISR (Incremental Static
Regeneration) applied to individual data fetches, not just pages.
Route segment config for full-page ISR
If the entire page is driven by sheet data, you can set revalidate at the segment level instead of
per-fetch:
// app/schedule/page.tsx
export const revalidate = 900; // 15 minutes
async function getSchedule() {
const res = await fetch(
"https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/schedule" +
"?sort=date"
);
const payload = await res.json();
return payload.data;
}
export default async function SchedulePage() {
const events = await getSchedule();
// ...
}All fetches on this page are cached with the same TTL. When the TTL expires, the next request triggers a background regeneration of the entire page.
On-demand revalidation
For content you update manually (a blog post, a team directory, a changelog), polling-based revalidation wastes cycles. Next.js supports on-demand revalidation via Route Handlers:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
revalidatePath("/schedule");
return NextResponse.json({ revalidated: true });
}Trigger it after editing the sheet:
curl -X POST "https://yoursite.com/api/revalidate?secret=YOUR_SECRET"You can call this from a Google Apps Script onEdit trigger, a GitHub Action, or a webhook from any
automation tool.
SWR for client-side caching
SWR (stale-while-revalidate) is a React data-fetching library that implements the HTTP caching strategy in the browser. It serves cached data immediately, revalidates in the background, and updates the UI when fresh data arrives.
import useSWR from "swr";
const BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY";
const fetcher = (url: string) =>
fetch(url).then(res => res.json()).then(p => p.data);
function Leaderboard() {
const { data, error, isValidating } = useSWR(
`${BASE}/scores?sort=-score&limit=10`,
fetcher,
{
refreshInterval: 60000, // poll every 60 seconds
revalidateOnFocus: true, // refresh when the tab regains focus
dedupingInterval: 10000, // don't re-fetch if last fetch was < 10s ago
}
);
return (
<div>
{isValidating && <span>Updating…</span>}
<ol>
{(data ?? []).map((row, i) => (
<li key={i}>{row.name}: {row.score}</li>
))}
</ol>
</div>
);
}SWR's cache is in-memory per browser tab. It does not persist across page reloads by default. For
dashboard data that needs to feel live, refreshInterval is usually the right knob. For slower-moving
content, revalidateOnFocus alone is often enough.
Server-side in-memory caching (Node.js)
For non-Next.js Node servers — Express, Fastify, a background job — a simple in-memory cache with a TTL avoids redundant API calls:
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class SimpleCache<T> {
private store = new Map<string, CacheEntry<T>>();
get(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.data;
}
set(key: string, data: T, ttlMs: number) {
this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
}
}
const cache = new SimpleCache<object[]>();
async function getSheetData(sheetName: string): Promise<object[]> {
const cached = cache.get(sheetName);
if (cached) return cached;
const res = await fetch(
`https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/${sheetName}`
);
const payload = await res.json();
cache.set(sheetName, payload.data, 5 * 60 * 1000); // 5 minutes
return payload.data;
}This is intentionally simple — no LRU eviction, no persistence. For production workloads with many
distinct cache keys, consider node-cache or Redis.
Choosing a revalidation interval
The right TTL depends on how the sheet is used:
| Content type | Suggested TTL | Reasoning |
|---|---|---|
| Product catalog, pricing | 1–4 hours | Changes infrequently; freshness is not critical |
| Event schedule, team directory | 15–30 minutes | Updated occasionally; users expect near-current data |
| Blog posts, documentation | 1–24 hours | Deploy-triggered revalidation is usually better than polling |
| Form responses, submissions | No caching | Append-only; always read fresh |
| Live scoreboard, dashboard | 30–60 seconds | Near-real-time without hammering the API |
One pattern that works well: use a longer TTL (1 hour) with on-demand revalidation. The page stays cached for up to an hour, but you can bust the cache immediately after making a change. This gives you both performance and control.
What caching doesn't fix
Caching reduces how often you call the API, but it doesn't fix a slow initial load if the cache is cold.
For server-rendered pages, pre-fetching at build time (generateStaticParams, getStaticProps) or
warming the cache at deploy time eliminates cold-cache latency entirely.
For client-side fetches, showing a skeleton UI while loading matters more than the raw fetch time — a 200ms fetch that renders instantly feels faster than a 50ms fetch that causes layout shift.