Photo by Manuel Luikenga on Unsplash
To find unused load balancers in AWS, list them with the ELBv2 DescribeLoadBalancers API, check each one’s target groups with DescribeTargetGroups and DescribeTargetHealth, and read 14 days of traffic from CloudWatch: RequestCount for Application Load Balancers, NewFlowCount for Network Load Balancers. The script below flags load balancers with no targets, no healthy targets or no traffic, with their monthly cost. It only reads.
Every Application or Network Load Balancer bills by the hour from the moment it’s created, whether it serves a million requests or none. Load balancers outlive their purpose all the time: an ECS service is deleted but its ALB isn’t, a Kubernetes ingress is removed while the controller-created NLB lingers, or a blue/green cutover leaves the old side running. This example is for engineers who want to find unused load balancers in AWS across a region and see why each one was flagged. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that checks targets, target health and traffic together.
It’s part of our AWS practical examples for finding waste. Idle load balancers often sit in front of idle instances, so pair it with the script to detect and stop underutilized EC2 instances by CPU, or, for instances that are already off, the script to find EC2 instances stopped for weeks and still costing you.
What does an unused load balancer cost?
Prices in US East (N. Virginia), as of September 2026, from the official Elastic Load Balancing pricing page:
| Load balancer | Hourly charge | Capacity units | Idle for a month (730 hours) |
|---|---|---|---|
| Application Load Balancer | $0.0225 per hour | $0.008 per LCU-hour | 730 × $0.0225 = $16.43 |
| Network Load Balancer | $0.0225 per hour | $0.006 per NLCU-hour | 730 × $0.0225 = $16.43 |
Partial hours are billed as full hours. An unused load balancer consumes almost no capacity units, so the hourly charge is most of what it costs: about $16 a month, or $197 a year, each. Ten forgotten ALBs across dev and staging accounts come to roughly $1,970 a year. Internet-facing load balancers also use public IPv4 addresses, which are charged separately.
How do you tell that a load balancer is unused?
No single field says “unused”, so the script combines three signals:
| Signal | API | What it means |
|---|---|---|
| No target groups | DescribeTargetGroups with LoadBalancerArn |
Nothing to forward to. On an ALB this can still be valid if listeners only redirect or return fixed responses, so traffic decides. |
| No registered or healthy targets | DescribeTargetHealth |
Targets were removed or are failing health checks. Either the app is gone or it’s broken. |
| No traffic in 14 days | CloudWatch GetMetricData |
ALB: RequestCount plus HTTP_Redirect_Count and HTTP_Fixed_Response_Count. NLB: NewFlowCount. |
Two details make the traffic check reliable. Elastic Load Balancing publishes metrics only when traffic flows, so missing data means zero. And an ALB’s RequestCount counts only requests where it could choose a target, which is why the script adds the redirect and fixed-response counts: an ALB that only redirects HTTP to HTTPS is still in use. To check that every HTTP listener really does redirect, run the script to find load balancers serving plain HTTP without a redirect.
What does the script do?
- Lists load balancers
paginateDescribeLoadBalancers, keepingapplicationandnetworktypes. Gateway Load Balancers are counted and skipped. - Checks targetsFor each load balancer,
paginateDescribeTargetGroups, thenDescribeTargetHealthper group, counting registered andhealthytargets. - Reads 14 days of traffic
GetMetricDatawith daily sums, using theLoadBalancerdimension (app/name/idornet/name/id, the tail of the ARN). - Prints a verdict
REVIEWwith the reasons,in use, orin use (redirects/fixed responses only), flagged rows first, with the monthly hourly charge.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-elastic-load-balancing-v2and@aws-sdk/client-cloudwatchpackages. - A profile with a default region, or
AWS_REGIONset. Run once per region.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadLoadBalancersTargetsAndMetrics",
"Effect": "Allow",
"Action": [
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetHealth",
"cloudwatch:GetMetricData"
],
"Resource": "*"
}
]
}
All four actions are read-only. The IAM policy generator for TypeScript SDK code produces the same list from the script, and the guide to review IAM policies for least privilege covers what to check before attaching it to a scheduled job.
The full script to find unused load balancers in AWS
// find-unused-load-balancers.ts
// Reports Application and Network Load Balancers in one region that have no target groups,
// no registered or healthy targets, or no traffic in the last --days days. Read-only.
// Usage: npx tsx find-unused-load-balancers.ts [--days 14]
import {
ElasticLoadBalancingV2Client,
DescribeTargetHealthCommand,
paginateDescribeLoadBalancers,
paginateDescribeTargetGroups,
type LoadBalancer,
} from "@aws-sdk/client-elastic-load-balancing-v2";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
// USD per load balancer-hour, us-east-1, as of September 2026 (ALB and NLB). LCUs are billed on top.
const LB_PER_HOUR = 0.0225;
const HOURS_PER_MONTH = 730;
const elb = new ElasticLoadBalancingV2Client({}); // region from AWS_REGION or your profile
const cw = new CloudWatchClient({});
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i === -1 ? undefined : process.argv[i + 1];
}
// ALB: requests routed to targets plus redirects and fixed responses. NLB: new flows.
async function trafficCount(lb: LoadBalancer, days: number): Promise<number> {
const dimension = lb.LoadBalancerArn?.split(":loadbalancer/")[1] ?? ""; // app/name/id or net/name/id
const isAlb = lb.Type === "application";
const namespace = isAlb ? "AWS/ApplicationELB" : "AWS/NetworkELB";
const metrics = isAlb ? ["RequestCount", "HTTP_Fixed_Response_Count", "HTTP_Redirect_Count"] : ["NewFlowCount"];
const queries: MetricDataQuery[] = metrics.map((name, i) => ({
Id: `m${i}`,
MetricStat: {
Metric: { Namespace: namespace, MetricName: name, Dimensions: [{ Name: "LoadBalancer", Value: dimension }] },
Period: 86_400,
Stat: "Sum",
},
}));
const end = new Date();
const res = await cw.send(
new GetMetricDataCommand({ StartTime: new Date(end.getTime() - days * 86_400_000), EndTime: end, MetricDataQueries: queries }),
);
// ELB publishes nothing when there is no traffic, so missing data means zero.
return (res.MetricDataResults ?? []).flatMap((r) => r.Values ?? []).reduce((s, v) => s + v, 0);
}
async function targets(lbArn: string) {
let groups = 0;
let registered = 0;
let healthy = 0;
for await (const page of paginateDescribeTargetGroups({ client: elb }, { LoadBalancerArn: lbArn })) {
for (const tg of page.TargetGroups ?? []) {
groups++;
const health = await elb.send(new DescribeTargetHealthCommand({ TargetGroupArn: tg.TargetGroupArn }));
for (const d of health.TargetHealthDescriptions ?? []) {
registered++;
if (d.TargetHealth?.State === "healthy") healthy++;
}
}
}
return { groups, registered, healthy };
}
async function main(): Promise<void> {
const days = Number(arg("--days") ?? 14);
const lbs: LoadBalancer[] = [];
for await (const page of paginateDescribeLoadBalancers({ client: elb }, {})) {
lbs.push(...(page.LoadBalancers ?? []));
}
const checked = lbs.filter((lb) => lb.Type === "application" || lb.Type === "network");
if (checked.length === 0) {
console.log("No Application or Network Load Balancers in this region.");
return;
}
const rows = [];
let unusedCost = 0;
for (const lb of checked) {
const t = await targets(lb.LoadBalancerArn ?? "");
const traffic = await trafficCount(lb, days);
const reasons: string[] = [];
if (t.groups === 0 && traffic === 0) reasons.push("no target groups");
else if (t.groups > 0 && t.registered === 0) reasons.push("no registered targets");
else if (t.groups > 0 && t.healthy === 0) reasons.push("no healthy targets");
if (traffic === 0) reasons.push(`no traffic in ${days}d`);
// An ALB with no target groups but traffic is serving redirects or fixed responses.
const inUse = t.groups === 0 && traffic > 0 ? "in use (redirects/fixed responses only)" : "in use";
const fixed = LB_PER_HOUR * HOURS_PER_MONTH;
if (reasons.length > 0) unusedCost += fixed;
rows.push({
Name: lb.LoadBalancerName ?? "",
Type: lb.Type === "application" ? "ALB" : "NLB",
Scheme: lb.Scheme ?? "",
Created: lb.CreatedTime?.toISOString().slice(0, 10) ?? "",
TGs: t.groups,
Targets: t.registered,
Healthy: t.healthy,
[`Traffic (${days}d)`]: traffic,
"Hourly $/mo": fixed.toFixed(2),
Verdict: reasons.length > 0 ? `REVIEW: ${reasons.join(", ")}` : inUse,
});
}
const flagged = (v: string) => (v.startsWith("REVIEW") ? 0 : 1);
rows.sort((a, b) => flagged(a.Verdict) - flagged(b.Verdict));
console.table(rows);
const skipped = lbs.length - checked.length;
if (skipped > 0) console.log(`${skipped} Gateway Load Balancers not checked.`);
console.log(`Flagged load balancers: about $${unusedCost.toFixed(2)}/month in hourly charges, before LCUs.`);
console.log("Report only: nothing was changed. Check DNS records and listeners before deleting anything.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The script makes one DescribeTargetHealth call per target group and one GetMetricData call per load balancer, which is fine for dozens of load balancers. For hundreds, batch the metric queries: one GetMetricData request accepts up to 500 of them.
How do you run it?
npm install @aws-sdk/client-elastic-load-balancing-v2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-unused-load-balancers.ts
# A 30-day window for load balancers that only see monthly traffic
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-unused-load-balancers.ts --days 30
Sample output
┌─────────┬────────────────────┬───────┬───────────────────┬──────────────┬─────┬─────────┬─────────┬───────────────┬─────────────┬───────────────────────────────────────────────────────┐
│ (index) │ Name │ Type │ Scheme │ Created │ TGs │ Targets │ Healthy │ Traffic (14d) │ Hourly $/mo │ Verdict │
├─────────┼────────────────────┼───────┼───────────────────┼──────────────┼─────┼─────────┼─────────┼───────────────┼─────────────┼───────────────────────────────────────────────────────┤
│ 0 │ 'legacy-api-alb' │ 'ALB' │ 'internet-facing' │ '2023-05-09' │ 1 │ 0 │ 0 │ 0 │ '16.43' │ 'REVIEW: no registered targets, no traffic in 14d' │
│ 1 │ 'staging-web' │ 'ALB' │ 'internet-facing' │ '2025-11-20' │ 2 │ 2 │ 0 │ 1287 │ '16.43' │ 'REVIEW: no healthy targets' │
│ 2 │ 'k8s-ingress-old' │ 'NLB' │ 'internal' │ '2024-02-14' │ 0 │ 0 │ 0 │ 0 │ '16.43' │ 'REVIEW: no target groups, no traffic in 14d' │
│ 3 │ 'prod-web' │ 'ALB' │ 'internet-facing' │ '2022-08-30' │ 3 │ 6 │ 6 │ 48211904 │ '16.43' │ 'in use' │
│ 4 │ 'http-to-https' │ 'ALB' │ 'internet-facing' │ '2022-08-30' │ 0 │ 0 │ 0 │ 90214 │ '16.43' │ 'in use (redirects/fixed responses only)' │
└─────────┴────────────────────┴───────┴───────────────────┴──────────────┴─────┴─────────┴─────────┴───────────────┴─────────────┴───────────────────────────────────────────────────────┘
Flagged load balancers: about $49.28/month in hourly charges, before LCUs.
Report only: nothing was changed. Check DNS records and listeners before deleting anything.
Names and figures are illustrative. staging-web is a different problem from the others: it gets requests but every target is unhealthy, so users are seeing errors. Fix it or remove it, but don’t treat it as idle.
What should you check before deleting a load balancer?
- DNS. Search Route 53 and any external DNS provider for the load balancer’s DNS name. A Route 53 alias record pointing at a deleted load balancer stops resolving; the example on how to point www to CloudFront with a Route 53 alias record shows how alias records reference their targets.
- Deletion protection. If
deletion_protection.enabledis set, someone decided the load balancer mattered. Ask before turning it off. - Who created it. Load balancers created by the AWS Load Balancer Controller, Elastic Beanstalk or CloudFormation come back or break their stack if you delete them by hand. Remove them through the tool that owns them.
- Security groups and certificates. After deletion, clean up security groups that only existed for the load balancer; the script to find unused security groups with no network interfaces lists them. The example to find security groups open to the internet on common ports often turns up these leftovers.
- The rest of the VPC. A VPC that only served this load balancer may also have a NAT gateway nobody needs; the example to find idle NAT gateways costing you money checks their traffic.
Troubleshooting
AccessDeniedonDescribeTargetHealth. Some custom policies grant onlyDescribeLoadBalancers. Add the missing actions, or work through the steps to troubleshoot IAM access denied errors in AWS.- Classic Load Balancers are missing. They use the older Elastic Load Balancing API (
@aws-sdk/client-elastic-load-balancing) and aren’t covered here. - A new load balancer is flagged. It may not have had traffic yet. Check the
Createdcolumn before acting. - Targets show
unusedstate. The target group isn’t used by any listener rule, or the target’s Availability Zone isn’t enabled on the load balancer. Those targets aren’t counted as healthy.
Ask ChatWithCloud instead
You can also ask ChatWithCloud “Which load balancers have no healthy targets?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and answers in plain English, as described in the guide to list AWS resources with natural language. Because ChatWithCloud runs generated code without a confirmation step, keep deletions out of the conversation and connect ChatWithCloud with a read-only AWS profile. Plans are on the ChatWithCloud pricing page.
Frequently asked questions
Do AWS load balancers cost money with no traffic?
Yes. ALBs and NLBs are billed for every hour or partial hour they run. With no traffic you pay the hourly charge and almost nothing in capacity units.
How do I find load balancers with no targets using the AWS CLI?
Run aws elbv2 describe-target-groups --load-balancer-arn ARN for each load balancer, then aws elbv2 describe-target-health --target-group-arn ARN for each group. The script automates that loop.
Can I stop a load balancer instead of deleting it?
No. There’s no stopped state for ALBs or NLBs. To stop the hourly charge, delete it and recreate it later from your infrastructure code.
Is a load balancer with unhealthy targets unused?
Not necessarily. If it still receives traffic, it’s in use and failing. Fix the targets or the health check before deciding to delete it.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
