Photo by Vishnu Mohanan on Unsplash
To find idle ElastiCache clusters, list node-based clusters with DescribeCacheClusters and serverless caches with DescribeServerlessCaches, then sum the CloudWatch metrics GetTypeCmds and SetTypeCmds (CmdGet and CmdSet for Memcached) over 14 days. A cache with zero reads and writes, and only ElastiCache’s own monitoring connections, is idle.
Caches outlive the features they were built for. A Redis replication group from a retired service keeps billing every node-hour, and nobody notices because it never errors. This example is for engineers and FinOps-minded leads who want to find idle ElastiCache clusters across Regions, with evidence strong enough to take to the owning team.
You get a read-only TypeScript script for the AWS SDK for JavaScript v3 that covers Valkey, Redis OSS and Memcached node-based clusters plus serverless caches. It follows the same pattern as the other AWS SDK v3 cost-cleanup examples, and in particular the script to find idle RDS instances with no connections.
What counts as an idle ElastiCache cluster?
ElastiCache publishes engine metrics for every node every 60 seconds. Three of them answer the question:
| Engine | Reads | Writes | Connections |
|---|---|---|---|
| Valkey / Redis OSS (node-based) | GetTypeCmds |
SetTypeCmds |
CurrConnections, which includes 4 to 6 connections ElastiCache uses for monitoring |
| Memcached (node-based) | CmdGet |
CmdSet |
CurrConnections, which includes monitoring connections and internal connections that depend on the node type |
| Serverless | GetTypeCmds (Memcached: CmdGet) |
SetTypeCmds (Memcached: CmdSet) |
CurrConnections, client connections only |
Node-based metrics use the dimensions CacheClusterId and CacheNodeId; serverless caches use clusterId, set to the cache name. GetTypeCmds and SetTypeCmds come from the engine’s command statistics, the same counters the Valkey INFO command reports under commandstats, grouped into read-only and write commands.
The script’s rule is strict on purpose: zero reads and zero writes over the whole window is “idle”. Connections are the tiebreaker. A Valkey or Redis OSS cluster whose connection peak never rose above 6 had no clients at all; one with open connections but no commands usually means an application that still connects at startup but no longer uses the cache, which is worth a conversation before a deletion.
How much does an idle cache cost?
On-Demand node prices in US East (N. Virginia) from the AWS Price List, as of September 2026, with a month approximated as 730 hours. Memcached nodes are priced the same as Redis OSS nodes of the same type; Valkey is 20% cheaper.
| Node type | Valkey per hour | Valkey per month | Redis OSS per hour | Redis OSS per month |
|---|---|---|---|---|
cache.t4g.micro |
$0.0128 | $9.34 | $0.016 | $11.68 |
cache.t4g.medium |
$0.052 | $37.96 | $0.065 | $47.45 |
cache.m7g.large |
$0.1264 | $92.27 | $0.158 | $115.34 |
cache.r7g.large |
$0.1752 | $127.90 | $0.219 | $159.87 |
cache.r7g.xlarge |
$0.3496 | $255.21 | $0.437 | $319.01 |
Every node in a replication group is billed, replicas included. A typical idle Redis OSS replication group with one primary and one replica on cache.r7g.large costs 2 × $0.219 × 730 = $319.74 a month, or $3,836.88 a year, before snapshot storage. Serverless caches bill for data stored and ElastiCache Processing Units (ECPUs), with a minimum metered storage per cache: 100 MB for Valkey at $0.084 per GB-hour (0.1 × $0.084 × 730 = $6.13 a month) and 1 GB for Redis OSS at $0.125 per GB-hour ($91.25 a month). An idle serverless Redis OSS cache still costs more than a small node.
What does the script do?
- Lists node-based clusters
paginateDescribeCacheClusterswithShowCacheNodeInfo: true, so every node ID is known. Nodes that belong to a replication group are grouped under its ID. - Lists serverless caches
paginateDescribeServerlessCaches, measured with theclusterIddimension and the metric names that match each cache’s engine. - Pulls 14 days of metricsOne
GetMetricDataquery per node and metric, with a one-day period, batched up to 500 queries per call: reads and writes asSum, connections asMaximum. - Rolls up and labelsSums per replication group, cluster or serverless cache and labels it idle, read-only or in use. Read-only caches are worth a look too: nothing refreshes them.
- Reports onlyPrints a table and optional CSV. It never deletes, snapshots or modifies a cache.
Prerequisites
- Node.js 18 or later, npm and
tsx. @aws-sdk/client-elasticacheand@aws-sdk/client-cloudwatch.- A read-only AWS profile; the guide to AWS SDK v3 credential providers such as fromIni and fromSSO shows how the SDK picks it up.
Which IAM permissions does it need?
Two ElastiCache describe actions and one CloudWatch read. cloudwatch:GetMetricData has no resource-level permissions, and the describe calls list everything in the Region, so all three use "Resource": "*".
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListCaches",
"Effect": "Allow",
"Action": [
"elasticache:DescribeCacheClusters",
"elasticache:DescribeServerlessCaches"
],
"Resource": "*"
},
{
"Sid": "ReadCacheMetrics",
"Effect": "Allow",
"Action": "cloudwatch:GetMetricData",
"Resource": "*"
}
]
}
To draft a policy from your own variant of the script, paste it into the IAM policy generator for TypeScript AWS SDK code.
The full script to find idle ElastiCache clusters
// find-idle-elasticache.ts
// Finds ElastiCache node-based clusters and serverless caches that served no reads or writes over the last N days,
// using CloudWatch command and connection metrics. Read-only: it never deletes, snapshots or modifies a cache.
// Usage: npx tsx find-idle-elasticache.ts [--regions us-east-1,eu-west-1] [--days 14] [--csv idle-caches.csv]
import { writeFileSync } from "node:fs";
import {
ElastiCacheClient,
paginateDescribeCacheClusters,
paginateDescribeServerlessCaches,
} from "@aws-sdk/client-elasticache";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Number(flag("--days") ?? 14);
const csvPath = flag("--csv");
// ElastiCache keeps a few connections open to monitor each node: 4 to 6 for Valkey/Redis OSS.
const MONITORING_CONNECTIONS = 6;
// One thing to measure: a node (CacheClusterId + CacheNodeId) or a serverless cache (clusterId).
interface Target {
group: string; // replication group, cluster or serverless cache name
kind: "valkey/redis" | "memcached" | "serverless";
dimensions: { Name: string; Value: string }[];
nodeType: string;
memcached: boolean; // Memcached reports CmdGet/CmdSet instead of GetTypeCmds/SetTypeCmds
}
interface Row {
Region: string;
Cache: string;
Kind: string;
NodeType: string;
Nodes: number;
Reads: number;
Writes: number;
MaxConnections: number;
Verdict: string;
}
async function listTargets(region: string): Promise<Target[]> {
const ec = new ElastiCacheClient({ region });
const targets: Target[] = [];
for await (const page of paginateDescribeCacheClusters({ client: ec }, { ShowCacheNodeInfo: true, MaxRecords: 100 })) {
for (const c of page.CacheClusters ?? []) {
const id = c.CacheClusterId ?? "";
const kind = c.Engine === "memcached" ? "memcached" : "valkey/redis";
for (const node of c.CacheNodes ?? []) {
targets.push({
group: c.ReplicationGroupId ?? id,
kind,
nodeType: c.CacheNodeType ?? "",
memcached: kind === "memcached",
dimensions: [{ Name: "CacheClusterId", Value: id }, { Name: "CacheNodeId", Value: node.CacheNodeId ?? "0001" }],
});
}
}
}
for await (const page of paginateDescribeServerlessCaches({ client: ec }, { MaxResults: 50 })) {
for (const s of page.ServerlessCaches ?? []) {
const name = s.ServerlessCacheName ?? "";
targets.push({
group: name,
kind: "serverless",
nodeType: `serverless ${s.Engine ?? ""}`,
memcached: s.Engine === "memcached",
dimensions: [{ Name: "clusterId", Value: name }], // serverless caches use clusterId = cache name
});
}
}
return targets;
}
// Read and write command metrics differ by engine, for node-based and serverless caches alike.
function metricNames(t: Target): { reads: string; writes: string } {
return t.memcached ? { reads: "CmdGet", writes: "CmdSet" } : { reads: "GetTypeCmds", writes: "SetTypeCmds" };
}
async function measure(region: string, targets: Target[]): Promise<Map<number, { reads: number; writes: number; conns: number }>> {
const cw = new CloudWatchClient({ region });
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const queries: MetricDataQuery[] = targets.flatMap((t, i) => {
const { reads, writes } = metricNames(t);
const q = (id: string, MetricName: string, Stat: string): MetricDataQuery => ({
Id: `${id}${i}`,
MetricStat: { Metric: { Namespace: "AWS/ElastiCache", MetricName, Dimensions: t.dimensions }, Period: 86_400, Stat },
ReturnData: true,
});
return [q("r", reads, "Sum"), q("w", writes, "Sum"), q("c", "CurrConnections", "Maximum")];
});
const totals = new Map<number, { reads: number; writes: number; conns: number }>();
targets.forEach((_, i) => totals.set(i, { reads: 0, writes: 0, conns: 0 }));
for (let n = 0; n < queries.length; n += 500) { // GetMetricData accepts up to 500 queries per call
let NextToken: string | undefined;
do {
const res = await cw.send(new GetMetricDataCommand({
MetricDataQueries: queries.slice(n, n + 500), StartTime: start, EndTime: end, NextToken,
}));
for (const r of res.MetricDataResults ?? []) {
const [kind, idx] = [r.Id?.[0], Number(r.Id?.slice(1))];
const t = totals.get(idx);
if (!t) continue;
const values = r.Values ?? [];
if (kind === "r") t.reads += values.reduce((a, b) => a + b, 0);
if (kind === "w") t.writes += values.reduce((a, b) => a + b, 0);
if (kind === "c") t.conns = Math.max(t.conns, ...values);
}
NextToken = res.NextToken;
} while (NextToken);
}
return totals;
}
async function scanRegion(region: string): Promise<Row[]> {
const targets = await listTargets(region);
if (!targets.length) return [];
const totals = await measure(region, targets);
const byGroup = new Map<string, Row>();
targets.forEach((t, i) => {
const m = totals.get(i) ?? { reads: 0, writes: 0, conns: 0 };
const row = byGroup.get(t.group) ?? {
Region: region, Cache: t.group, Kind: t.kind, NodeType: t.nodeType, Nodes: 0, Reads: 0, Writes: 0, MaxConnections: 0, Verdict: "",
};
if (t.kind !== "serverless") row.Nodes += 1;
row.Reads += m.reads;
row.Writes += m.writes;
row.MaxConnections = Math.max(row.MaxConnections, m.conns);
byGroup.set(t.group, row);
});
for (const row of byGroup.values()) {
const commands = row.Reads + row.Writes;
const noClients = row.Kind === "valkey/redis" ? row.MaxConnections <= MONITORING_CONNECTIONS : row.MaxConnections === 0;
row.Verdict = commands === 0 ? (noClients ? "IDLE: no commands, no clients" : "IDLE: no commands, connections open")
: row.Writes === 0 ? "read-only: check data is still refreshed" : "in use";
}
return [...byGroup.values()];
}
function toCsv(rows: Row[]): string {
const cols: (keyof Row)[] = ["Region", "Cache", "Kind", "NodeType", "Nodes", "Reads", "Writes", "MaxConnections", "Verdict"];
const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of regions) rows.push(...(await scanRegion(region)));
rows.sort((a, b) => a.Reads + a.Writes - (b.Reads + b.Writes));
console.table(rows);
const idle = rows.filter((r) => r.Verdict.startsWith("IDLE"));
console.log(`${idle.length} of ${rows.length} caches had no reads or writes in the last ${days} days (${regions.join(", ")})`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
console.log("Report only: no cache was deleted, snapshotted or changed.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-elasticache @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript
# Last 14 days in two Regions
AWS_PROFILE=readonly npx tsx find-idle-elasticache.ts --regions us-east-1,eu-west-1
# A 30-day window and a CSV for the owning teams
AWS_PROFILE=readonly npx tsx find-idle-elasticache.ts --regions us-east-1 --days 30 --csv idle-caches.csv
Sample output
┌─────────┬─────────────┬──────────────────┬────────────────┬───────────────────┬───────┬──────────┬────────┬────────────────┬─────────────────────────────────────────────┐
│ (index) │ Region │ Cache │ Kind │ NodeType │ Nodes │ Reads │ Writes │ MaxConnections │ Verdict │
├─────────┼─────────────┼──────────────────┼────────────────┼───────────────────┼───────┼──────────┼────────┼────────────────┼─────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'legacy-session' │ 'valkey/redis' │ 'cache.r7g.large' │ 2 │ 0 │ 0 │ 6 │ 'IDLE: no commands, no clients' │
│ 1 │ 'us-east-1' │ 'feature-flags' │ 'serverless' │ 'serverless redis'│ 0 │ 0 │ 0 │ 3 │ 'IDLE: no commands, connections open' │
│ 2 │ 'eu-west-1' │ 'geo-lookup' │ 'valkey/redis' │ 'cache.m7g.large' │ 3 │ 1842210 │ 0 │ 41 │ 'read-only: check data is still refreshed' │
│ 3 │ 'us-east-1' │ 'api-cache' │ 'valkey/redis' │ 'cache.r7g.large' │ 3 │ 98412733 │ 212004 │ 388 │ 'in use' │
└─────────┴─────────────┴──────────────────┴────────────────┴───────────────────┴───────┴──────────┴────────┴────────────────┴─────────────────────────────────────────────┘
2 of 4 caches had no reads or writes in the last 14 days (us-east-1, eu-west-1)
Report only: no cache was deleted, snapshotted or changed.
Names and numbers are illustrative. legacy-session is the clean case: two Redis OSS nodes, no commands and only the monitoring connections, about $319.74 a month at the prices above. feature-flags has three open connections and no commands, so find the client before touching it.
What should you check before deleting an idle cache?
- Seasonal traffic. A cache used for a month-end job looks idle for three weeks. Rerun with
--days 45before deciding. - Pub/sub and other command types.
GetTypeCmdsandSetTypeCmdsdon’t include pub/sub or eval-based commands, which ElastiCache reports asPubSubBasedCmdsandEvalBasedCmds. If a cache is only a message bus or runs Lua scripts, check those metrics too. - Keep a copy.
DeleteReplicationGroupandDeleteServerlessCacheaccept a final snapshot name; snapshot storage is billed per GB-month. Node-based Memcached clusters have no snapshots, so there is nothing to restore. - Commitments. Reserved nodes keep billing after the cluster is gone, and ElastiCache usage can also be covered by Database Savings Plans, so removing a cache can leave part of a plan unused; the script to check Savings Plans coverage and utilization shows how much commitment is at stake. The script to find EC2 Reserved Instances about to expire shows the same expiry-driven thinking for EC2.
- Alarms and owners. Deleting a cache leaves its CloudWatch alarms behind; the script to find CloudWatch alarms stuck in INSUFFICIENT_DATA catches them. Caches without an owner tag are the hardest to delete; find untagged AWS resources with the Tagging API to fix that first.
Troubleshooting
- Every cache shows 0 connections. The profile’s Region doesn’t match the caches, or the dimensions are wrong. Check one node in the CloudWatch console under
AWS/ElastiCache. - Memcached clusters never show “no clients”. Memcached’s
CurrConnectionsincludes internal connections that depend on the node type, so the script only uses commands for the verdict there. ThrottlingExceptionfrom CloudWatch. Large accounts send manyGetMetricDatacalls. The SDK retries automatically; the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 shows how to raise the attempts.- A new cluster shows as idle. A cluster created two days ago has only two days of data. Check its creation time before acting.
Ask ChatWithCloud instead
For one Region, you can ask ChatWithCloud “Which ElastiCache clusters in us-east-1 had no GetTypeCmds or SetTypeCmds in the last 14 days?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the numbers. It uses one profile and Region per session and runs generated code without a confirmation step, so connect ChatWithCloud to a read-only AWS profile first. To see whether ElastiCache is even a big line item, find your most expensive AWS service with Cost Explorer, or ask AI why your AWS bill increased in plain English.
Frequently asked questions
Which CloudWatch metric shows if an ElastiCache cluster is used?
For Valkey and Redis OSS, the sum of GetTypeCmds and SetTypeCmds; for Memcached, CmdGet and CmdSet. CurrConnections alone is misleading because ElastiCache keeps its own monitoring connections open.
Why does my unused Redis cluster show connections?
ElastiCache uses 4 to 6 connections to monitor each Valkey or Redis OSS node. A peak of 6 or fewer over two weeks means no application connected.
Can I stop an ElastiCache cluster instead of deleting it?
There is no stop operation for ElastiCache clusters. To stop paying for nodes, delete the cluster, optionally taking a final snapshot you can restore later.
Is a serverless cache cheaper when it’s idle?
Usually, but not to zero. You pay for the minimum metered storage: 100 MB for Valkey and 1 GB for Redis OSS, about $6.13 and $91.25 a month in us-east-1 at September 2026 prices.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud