Find Unused Load Balancers in AWS With No Healthy Targets

Network rack in a data center with patch panels and bundled cables

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?

  1. Lists load balancerspaginateDescribeLoadBalancers, keeping application and network types. Gateway Load Balancers are counted and skipped.
  2. Checks targetsFor each load balancer, paginateDescribeTargetGroups, then DescribeTargetHealth per group, counting registered and healthy targets.
  3. Reads 14 days of trafficGetMetricData with daily sums, using the LoadBalancer dimension (app/name/id or net/name/id, the tail of the ARN).
  4. Prints a verdictREVIEW with the reasons, in use, or in 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-v2 and @aws-sdk/client-cloudwatch packages.
  • A profile with a default region, or AWS_REGION set. Run once per region.

Which IAM permissions does it need?

unused-load-balancers-policy.json

{
  "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

// 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?

Terminal

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

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.enabled is 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

  • AccessDenied on DescribeTargetHealth. Some custom policies grant only DescribeLoadBalancers. 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 Created column before acting.
  • Targets show unused state. 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