Photo by Harrison Broadbent on Unsplash
API Gateway cache cost is an hourly charge for every REST API stage with a provisioned cache cluster, from $0.02 an hour for 0.5 GB to $3.80 an hour for 237 GB in us-east-1, whether requests hit the cache or not. Find those stages with GetStages (cacheClusterEnabled, cacheClusterSize), check CacheHitCount and CacheMissCount in CloudWatch, and turn off caches that don’t earn their keep.
A stage cache is easy to switch on for a launch or a load test and easy to forget. It keeps billing every hour, and it’s worth the money only when a good share of requests are served from it. This example is for engineers who own REST APIs in API Gateway and want to know which stages pay for a cache, what each one costs per month and whether it’s actually used.
The script reports by default. It changes a stage only when you name it with --disable and add --apply.
What does the API Gateway cache cost?
Caching is charged by the hour for the capacity you choose and isn’t covered by the AWS Free Tier. As of September 2026, the AWS Price List for API Gateway (published 21 September 2026) gives these us-east-1 rates; other Regions differ, which is why the script looks prices up for each Region:
cacheClusterSize (GB) |
Per hour | Per 730-hour month |
|---|---|---|
| 0.5 | $0.020 | $14.60 |
| 1.6 | $0.038 | $27.74 |
| 6.1 | $0.200 | $146.00 |
| 13.5 | $0.250 | $182.50 |
| 28.4 | $0.500 | $365.00 |
| 58.2 | $1.000 | $730.00 |
| 118 | $1.900 | $1,387.00 |
| 237 | $3.800 | $2,774.00 |
Worked example: the 6.1 GB cache on a production stage costs $0.20 × 730 = $146.00 a month. If only 17% of requests hit it, the backend still handles 83% of the traffic, so the cache saves little while the bill stays the same. A forgotten 28.4 GB cache on an unused stage costs $0.50 × 730 = $365.00 a month for nothing. The price list names two sizes slightly differently from the API (1.55 GB and 6.05 GB); they match the 1.6 and 6.1 values that GetStages returns.
How do you tell whether a stage cache is worth it?
Three signals decide it, and the script reports all of them:
- Is a cache provisioned?
cacheClusterEnabledandcacheClusterStatus(AVAILABLE,CREATE_IN_PROGRESS,FLUSH_IN_PROGRESS, and so on) on the stage. This is what you pay for. - Does any method use it? Caching is switched on per method in
methodSettings: the*/*key is the stage default, which applies toGETmethods, and keys such as~1orders~1{id}/GETare overrides. A provisioned cache with no method settingcachingEnabled: trueis pure cost. - What’s the hit ratio?
CacheHitCountandCacheMissCountin theAWS/ApiGatewaynamespace, with theApiNameandStagedimensions, summed over--days(default 14). The script flags ratios below--min-hit(default 20%).
The AWS guide to cache settings for REST APIs in API Gateway describes each setting. A low hit ratio can also mean the cache keys are too specific or the TTL too short; the default TTL is 300 seconds and the maximum 3,600.
What does the script do?
- Lists REST APIs
paginateGetRestApisin each Region you pass. - Finds cached stages
GetStagesper API, keeping stages with a cache cluster enabled or still being created or deleted. - Reads cache metricsOne
GetMetricDatacall per 250 stages, with daily sums of hits and misses. - Prices each cache
GetProductson the Price List API with the Region code andcacheMemorySizeGb. - Disables only on requestWith
--disable apiId/stageit previews; with--applyit sendsUpdateStagewith areplaceoperation on/cacheClusterEnabled.
Prerequisites
- Node.js 18 or later,
tsx,@aws-sdk/client-api-gateway,@aws-sdk/client-cloudwatchand@aws-sdk/client-pricing. The Price List API is always called in us-east-1. - A read-only profile for the report; connecting to AWS with profiles, SSO and roles shows how to keep one separate from the profile you use for
--apply. - REST APIs only. The script calls
GetRestApis, and the dedicated stage cache described here is a REST API feature.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadRestApisAndStages",
"Effect": "Allow",
"Action": "apigateway:GET",
"Resource": [
"arn:aws:apigateway:*::/restapis",
"arn:aws:apigateway:*::/restapis/*"
]
},
{
"Sid": "ReadMetricsAndPrices",
"Effect": "Allow",
"Action": [
"cloudwatch:GetMetricData",
"pricing:GetProducts"
],
"Resource": "*"
},
{
"Sid": "DisableCacheOnlyWithApply",
"Effect": "Allow",
"Action": "apigateway:PATCH",
"Resource": "arn:aws:apigateway:*::/restapis/*/stages/*"
}
]
}
API Gateway authorizes its management API with HTTP verbs on resource paths, so reading stages is apigateway:GET and changing one is apigateway:PATCH. Remove the last statement for a report-only role. The IAM policy generator for TypeScript SDK code lists the actions again if you extend the script.
The script to find API Gateway stages with caching turned on
// find-api-gateway-stages-with-cache.ts
// Lists REST API stages with a provisioned cache cluster, the methods that actually use it, the
// CacheHitCount / CacheMissCount totals for the last --days days, and the hourly price of each cache size
// from the AWS Price List API. Report only by default.
// --apply --disable apiId/stage,apiId/stage turns off the cache cluster on the named stages only.
// Usage: npx tsx find-api-gateway-stages-with-cache.ts [--regions us-east-1,eu-west-1] [--days 14]
// [--min-hit 20] [--disable a1b2c3d4e5/prod [--apply]]
import {
APIGatewayClient,
GetStagesCommand,
UpdateStageCommand,
paginateGetRestApis,
type RestApi,
type Stage,
} from "@aws-sdk/client-api-gateway";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
import { PricingClient, GetProductsCommand } from "@aws-sdk/client-pricing";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const list = (v: string | undefined): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const regions = list(flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1");
const days = Number(flag("--days") ?? 14);
const minHit = Number(flag("--min-hit") ?? 20);
const toDisable = new Set(list(flag("--disable")));
const apply = args.includes("--apply");
const HOURS_PER_MONTH = 730;
interface Row {
Region: string;
Stage: string; // apiId/stageName
Api: string;
SizeGB: string;
Status: string;
CachedMethods: string;
Hits: number;
Misses: number;
HitRatio: string;
PerMonth: number | null;
Verdict: string;
}
const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// ---- Price List API: hourly price of a dedicated cache of a given size ----
const pricing = new PricingClient({ region: "us-east-1" });
const priceCache = new Map<string, number | null>();
async function cacheHourly(region: string, sizeGb: string): Promise<number | null> {
const key = `${region}|${sizeGb}`;
if (priceCache.has(key)) return priceCache.get(key) ?? null;
let rate: number | null = null;
try {
const out = await pricing.send(new GetProductsCommand({
ServiceCode: "AmazonApiGateway",
Filters: [
{ Type: "TERM_MATCH", Field: "regionCode", Value: region },
{ Type: "TERM_MATCH", Field: "cacheMemorySizeGb", Value: sizeGb },
],
MaxResults: 10,
}));
for (const item of out.PriceList ?? []) {
const product = JSON.parse(String(item)) as {
terms?: { OnDemand?: Record<string, { priceDimensions: Record<string, { pricePerUnit: { USD?: string } }> }> };
};
for (const offer of Object.values(product.terms?.OnDemand ?? {})) {
for (const dim of Object.values(offer.priceDimensions)) {
const usd = Number(dim.pricePerUnit.USD);
if (usd > 0) rate = usd;
}
}
}
} catch (err) {
console.error(`price lookup for ${sizeGb} GB in ${region}: ${errorText(err)}`);
}
priceCache.set(key, rate);
return rate;
}
// Method setting keys with caching turned on; the stage-wide key "*/*" is the default for GET methods.
function cachedMethods(stage: Stage): string[] {
return Object.entries(stage.methodSettings ?? {})
.filter(([, s]) => s.cachingEnabled === true)
.map(([k]) => (k === "*/*" ? "all GET (default)" : k));
}
// ---- CloudWatch: CacheHitCount and CacheMissCount sums per ApiName + Stage ----
async function cacheCounts(region: string, targets: { api: string; stage: string }[]): Promise<Map<string, { hits: number; misses: number }>> {
const cw = new CloudWatchClient({ region });
const result = new Map<string, { hits: number; misses: number }>();
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
for (let i = 0; i < targets.length; i += 250) { // 2 queries per stage, 500 queries per call
const ids = new Map<string, { key: string; hit: boolean }>(); // query Id -> stage and metric
const queries: MetricDataQuery[] = [];
targets.slice(i, i + 250).forEach((t, n) => {
for (const metric of ["CacheHitCount", "CacheMissCount"] as const) {
const Id = `${metric === "CacheHitCount" ? "h" : "m"}${i + n}`;
ids.set(Id, { key: `${t.api}/${t.stage}`, hit: metric === "CacheHitCount" });
queries.push({
Id,
MetricStat: {
Metric: {
Namespace: "AWS/ApiGateway",
MetricName: metric,
Dimensions: [{ Name: "ApiName", Value: t.api }, { Name: "Stage", Value: t.stage }],
},
Period: 86_400,
Stat: "Sum",
},
});
}
});
let NextToken: string | undefined;
do {
const out = await cw.send(new GetMetricDataCommand({ MetricDataQueries: queries, StartTime: start, EndTime: end, NextToken }));
for (const r of out.MetricDataResults ?? []) {
const q = ids.get(r.Id ?? "");
if (!q) continue;
const entry = result.get(q.key) ?? { hits: 0, misses: 0 };
const sum = (r.Values ?? []).reduce((s, v) => s + v, 0);
if (q.hit) entry.hits += sum;
else entry.misses += sum;
result.set(q.key, entry);
}
NextToken = out.NextToken;
} while (NextToken);
}
return result;
}
async function scanRegion(region: string): Promise<Row[]> {
const apigw = new APIGatewayClient({ region });
const apis: RestApi[] = [];
for await (const page of paginateGetRestApis({ client: apigw }, { limit: 500 })) apis.push(...(page.items ?? []));
const cached: { api: RestApi; stage: Stage }[] = [];
for (const api of apis) {
const out = await apigw.send(new GetStagesCommand({ restApiId: api.id }));
for (const stage of out.item ?? []) {
if (stage.cacheClusterEnabled || (stage.cacheClusterStatus && stage.cacheClusterStatus !== "NOT_AVAILABLE")) {
cached.push({ api, stage });
}
}
}
// API Gateway strips non-ASCII characters from the ApiName dimension, and uses the API ID if nothing is left.
const dimName = (api: RestApi): string => (api.name ?? "").replace(/[^\x00-\x7F]/g, "") || (api.id ?? "");
const counts = await cacheCounts(region, cached.map(({ api, stage }) => ({ api: dimName(api), stage: stage.stageName ?? "" })));
const rows: Row[] = [];
for (const { api, stage } of cached) {
const size = stage.cacheClusterSize ?? "";
const c = counts.get(`${dimName(api)}/${stage.stageName}`) ?? { hits: 0, misses: 0 };
const total = c.hits + c.misses;
const ratio = total > 0 ? (100 * c.hits) / total : null;
const methods = cachedMethods(stage);
const hourly = size ? await cacheHourly(region, size) : null;
let verdict = "keep";
if (methods.length === 0) verdict = "no cached methods";
else if (total === 0) verdict = "no cache traffic";
else if (ratio !== null && ratio < minHit) verdict = "low hit ratio";
rows.push({
Region: region,
Stage: `${api.id}/${stage.stageName}`,
Api: api.name ?? "",
SizeGB: size,
Status: stage.cacheClusterStatus ?? "",
CachedMethods: methods.length ? methods.join(" ") : "none",
Hits: Math.round(c.hits),
Misses: Math.round(c.misses),
HitRatio: ratio === null ? "n/a" : `${ratio.toFixed(1)}%`,
PerMonth: hourly === null ? null : Math.round(hourly * HOURS_PER_MONTH * 100) / 100,
Verdict: verdict,
});
}
return rows;
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${errorText(err)}`);
}
}
if (rows.length === 0) {
console.log(`No REST API stages with a cache cluster in ${regions.join(", ")}.`);
return;
}
rows.sort((a, b) => (b.PerMonth ?? 0) - (a.PerMonth ?? 0));
console.table(rows.map(({ PerMonth, ...r }) => ({ ...r, "$/month": PerMonth ?? "unknown" })));
const flagged = rows.filter((r) => r.Verdict !== "keep");
const waste = flagged.reduce((s, r) => s + (r.PerMonth ?? 0), 0);
console.log(`${rows.length} stages pay for a cache; ${flagged.length} look unjustified over ${days} days ` +
`(about $${waste.toFixed(2)} a month at list price).`);
for (const id of toDisable) {
const row = rows.find((r) => r.Stage === id);
if (!row) {
console.log(`skip ${id}: no cache cluster on that stage in ${regions.join(", ")}`);
continue;
}
const [restApiId, stageName] = id.split("/");
if (!apply) {
console.log(`would disable the ${row.SizeGB} GB cache on ${id} in ${row.Region} (re-run with --apply)`);
continue;
}
try {
await new APIGatewayClient({ region: row.Region }).send(new UpdateStageCommand({
restApiId,
stageName,
patchOperations: [{ op: "replace", path: "/cacheClusterEnabled", value: "false" }],
}));
console.log(`disabled the cache cluster on ${id}; deletion takes a few minutes`);
} catch (err) {
console.error(`disable ${id}: ${errorText(err)}`);
}
}
}
main().catch((err) => {
console.error(errorText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-api-gateway @aws-sdk/client-cloudwatch @aws-sdk/client-pricing
npm install --save-dev tsx typescript @types/node
# Report over the last 30 days, flagging hit ratios under 30%
AWS_PROFILE=readonly npx tsx find-api-gateway-stages-with-cache.ts --regions us-east-1,eu-west-1 --days 30 --min-hit 30
# Preview, then turn off one stage cache
AWS_PROFILE=readonly npx tsx find-api-gateway-stages-with-cache.ts --disable k1l2m3n4o5/v1
AWS_PROFILE=ops-admin npx tsx find-api-gateway-stages-with-cache.ts --disable k1l2m3n4o5/v1 --apply
Sample output
┌─────────┬─────────────┬──────────────────────┬───────────────┬────────┬─────────────┬──────────────────────┬─────────┬────────┬──────────┬─────────────────────┬─────────┐
│ (index) │ Region │ Stage │ Api │ SizeGB │ Status │ CachedMethods │ Hits │ Misses │ HitRatio │ Verdict │ $/month │
├─────────┼─────────────┼──────────────────────┼───────────────┼────────┼─────────────┼──────────────────────┼─────────┼────────┼──────────┼─────────────────────┼─────────┤
│ 0 │ 'us-east-1' │ 'k1l2m3n4o5/v1' │ 'partner-api' │ '28.4' │ 'AVAILABLE' │ 'all GET (default)' │ 0 │ 0 │ 'n/a' │ 'no cache traffic' │ 365 │
│ 1 │ 'us-east-1' │ 'a1b2c3d4e5/prod' │ 'orders-api' │ '6.1' │ 'AVAILABLE' │ '~1orders~1{id}/GET' │ 41200 │ 198400 │ '17.2%' │ 'low hit ratio' │ 146 │
│ 2 │ 'us-east-1' │ 'a1b2c3d4e5/staging' │ 'orders-api' │ '1.6' │ 'AVAILABLE' │ 'none' │ 0 │ 0 │ 'n/a' │ 'no cached methods' │ 27.74 │
│ 3 │ 'us-east-1' │ 'f6g7h8i9j0/prod' │ 'catalog-api' │ '0.5' │ 'AVAILABLE' │ 'all GET (default)' │ 1830000 │ 262000 │ '87.5%' │ 'keep' │ 14.6 │
└─────────┴─────────────┴──────────────────────┴───────────────┴────────┴─────────────┴──────────────────────┴─────────┴────────┴──────────┴─────────────────────┴─────────┘
4 stages pay for a cache; 3 look unjustified over 14 days (about $538.74 a month at list price).
would disable the 28.4 GB cache on k1l2m3n4o5/v1 in us-east-1 (re-run with --apply)
This run used mocked API Gateway and CloudWatch responses with the real us-east-1 prices from the Price List file. partner-api/v1 pays $365 a month for a 28.4 GB cache that served nothing in 14 days. orders-api/staging has a cache with no cached methods, which is common when someone provisions it in the console and never turns on method caching. orders-api/prod caches one method at a 17.2% hit ratio: look at its cache keys and TTL before deciding. catalog-api/prod serves 87.5% of requests from a $14.60 cache and should stay.
What should you check before turning a cache off?
- Backend headroom. Every former cache hit becomes a backend call. For Lambda integrations, check concurrency and errors first; investigating Lambda errors with CloudWatch shows which metrics to watch.
- Resize instead of removing. A cache that’s used but oversized can move to a smaller
cacheClusterSize. API Gateway resizes by deleting the cache instance and creating a new one, so cached data is lost and creation takes up to about 4 minutes. - Per-method caching. Turning off method caching doesn’t stop the hourly charge; only removing the cache cluster does.
- Cache invalidation rules. If clients send
Cache-Control: max-age=0, make surerequireAuthorizationForCacheControlis on; otherwise any client can bypass the cache and your hit ratio suffers. - Other stage settings. While you’re in the stage, confirm it logs requests and that no method is open by accident; the scripts to find API Gateway stages without access logging and find API Gateway methods without authorization cover both.
To see how much API Gateway contributes to the bill before and after, get last month’s AWS bill broken down by service. Idle caches aren’t unique to API Gateway either; the script to find idle ElastiCache clusters applies the same idea to Redis and Memcached.
Troubleshooting
HitsandMissesare 0 on a busy stage. The metrics use the API name as theApiNamedimension, with non-ASCII characters removed. If two REST APIs share a name, their counts are combined; rename one or check in the console.$/monthshowsunknown. The Price List API had no match for that Region and size. Look the rate up on the API Gateway pricing page.TooManyRequestsExceptionfrom API Gateway. API Gateway throttles management calls when you send them too fast. The SDK retries throttled calls; for hundreds of APIs, see configuring AWS SDK v3 retries and timeouts to raisemaxAttempts.- The stage still shows a cache after
--apply.cacheClusterStatusmoves toDELETE_IN_PROGRESSfor a few minutes before the cache is gone. - Access denied. Check the resource paths in the policy, then follow troubleshooting AWS IAM access denied errors.
Ask ChatWithCloud instead
For a quick answer, ask ChatWithCloud “Which API Gateway REST API stages have a cache cluster enabled, and what size?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result, as described in how ChatWithCloud answers AWS questions from your terminal. It uses one profile and Region per session, can be wrong, and runs changes without confirmation, so use a read-only profile and keep changes in the script. Browse more cost reports in the AWS practical examples collection.
Frequently asked questions
Is the API Gateway cache charged when nobody uses it?
Yes. The cache is billed by the hour for its size from the moment it’s provisioned until it’s deleted, regardless of traffic or hit ratio, and it isn’t part of the free tier.
How do I reduce API Gateway cache cost without losing caching?
Choose the smallest cacheClusterSize that keeps latency and hit ratio where you need them, cache only the methods that benefit, and use cache keys and a TTL that let requests share entries.
Which methods does API Gateway cache by default?
When you turn on caching for a stage, only GET methods have caching enabled by default. Other methods need a method override.
Does flushing the cache lower the bill?
No. Flushing empties the cache but keeps the instance, so the hourly charge continues. Only disabling the cache cluster stops it.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud