What it actually is
Elastic Load Balancing is not one service. It is a family of managed proxies that sit in front of your targets, and the whole decision is which layer of the stack you want the proxy to understand. The current generation is three types: the Application Load Balancer (ALB), the Network Load Balancer (NLB) and the Gateway Load Balancer (GWLB). The Classic Load Balancer is the previous generation, and the docs tell you to migrate off it.
The short version: an ALB terminates HTTP and routes on what it reads inside the request (host, path, header, query string). An NLB forwards TCP, UDP and TLS flows at connection level and does not read your payload. A GWLB sits at layer 3 and exists for one job, steering traffic through a fleet of virtual appliances such as firewalls. If you are not running inspection appliances, you will never need the third one, and that is fine.
Everything else, the pricing, the limits and the error codes, follows from that layer choice.
The model
Every load balancer has the same four nouns. A load balancer is the set of managed nodes, one per enabled Availability Zone, that your DNS name resolves to. A listener is a protocol and port the nodes accept. A rule (on ALB, and since July 2026 on NLB too) maps a condition to an action. A target group is the pool the action forwards to, with its own health check.
AWS operates the nodes: they scale, they are replaced, you never patch them. You operate the target groups: what is registered, how health is judged, how fast a target drains. Most "ELB incidents" are target group configuration, not load balancer failures.
Two defaults matter more than they look. First, health checks default to a 30 second interval, a 5 second timeout, 5 consecutive successes to become healthy and 2 consecutive failures to become unhealthy. Second, if every target in a target group is unhealthy, the load balancer fails open and sends traffic to all of them anyway. That protects you from a broken health check and hurts you when the targets really are down.
Cross-zone behaviour differs by type. On an NLB, cross-zone load balancing is off by default (load_balancing.cross_zone.enabled is false), so each node only sends to targets in its own zone. Turn it on and you get even spread at the price of regional data transfer. Know which one you are running before you size a fleet.
Two ALB attributes are worth memorising. The idle timeout defaults to 60 seconds with a range of 1 to 4000, and the documented rule is that your application's keep-alive must be higher than it. The target group deregistration delay defaults to 300 seconds, which is why a deploy that "drains" can take five minutes.
When to use it, when not to
You need | Use | Why |
|---|---|---|
Host or path routing, redirects, fixed responses, JWT validation, Lambda or container targets over HTTP | ALB | It reads HTTP, so routing is a rule, not a service |
Static IPs per zone, UDP, raw TCP, TLS passthrough or termination at connection level, very high connection rates | NLB | Layer 4 forwarding, no payload parsing |
Inline firewall or IDS appliances you must route all traffic through | GWLB | Transparent layer 3 steering with GENEVE |
A fixed IP in front of an HTTP app | NLB in front of ALB | An NLB target group can hold an ALB |
Plain HTTP API with auth, throttling and usage plans | Often API Gateway instead | An ALB does none of the three |
The confusion I see most is ALB versus NLB for HTTP services. If you need path routing, pick the ALB. If a customer needs to allowlist your IPs, put an NLB in front of it. Both at once is a supported pattern, and we will build it below. The other confusion is ALB versus API Gateway. ALB charges for load balancer hours and capacity units. It has no per-request price and no usage plans. For steady high-volume traffic that difference is large, and for spiky tiny traffic the hourly floor makes it the more expensive option.
What it costs
The pricing page uses US East (N. Virginia) rates in its worked examples. ALB is $0.0225 per hour plus $0.008 per LCU-hour. NLB is $0.0225 per hour plus $0.006 per NLCU-hour. GWLB is $0.0125 per hour per Availability Zone plus $0.004 per GLCU-hour, and the endpoints are billed separately under PrivateLink pricing. Classic is $0.025 per hour plus $0.008 per GB processed. Partial hours bill as full hours. Check your Region and the current page before you quote any of this to a finance team.
The part that surprises people is the capacity unit. You are billed on the single highest of several dimensions, not the sum. For ALB an LCU is 25 new connections per second, 3,000 active connections per minute, 1 GB per hour of processed bytes (0.4 GB for Lambda targets), or 1,000 rule evaluations per second, and the first 10 rules are free. A busy but tiny-payload API is billed on connections. A download service is billed on bytes. Look at the metric ConsumedLCUs before you guess.
Three smaller charges hide beside the headline price. Each public IPv4 address a load balancer uses costs $0.005 per hour, and a load balancer in two zones holds two. Data transfer is billed separately per the EC2 pricing page, which is where cross-zone traffic shows up. Capacity Unit Reservation lets you pre-buy a floor of capacity at the reserved rate for ALB, NLB and GWLB, and anything above it bills at the standard rate.
The free tier: 750 hours per month shared between ALB and Classic, plus 15 LCUs for ALB. NLB and GWLB are not mentioned in the free tier on the pricing page, so assume they bill from the first hour.
The limits that bite
From the ALB quotas page: 50 ALBs per Region, 100 rules per ALB (adjustable), 100 target groups per ALB (not adjustable), 1,000 targets per ALB (adjustable), 25 extra certificates per ALB and 3,000 target groups per Region, shared with NLB. Each rule can have at most 5 condition values and 6 wildcards, and none of that moves. A target group can be attached to only one load balancer. Request headers are capped at 64 K in total and 16 K per single header.
The NLB quotas have one number that ambushes people: 500 targets per Availability Zone per NLB, adjustable. A target registered in N target groups counts N times, and an ALB registered as a target counts as 50 (cross-zone off) or 100 (cross-zone on). With cross-zone enabled the cap is 500 targets per load balancer regardless of how many zones you use.
Two operational limits are not in a quota table. Lambda targets are limited to a 1 MB request and a 1 MB response, and one function per target group. Health checks do not support WebSockets.
Check your own account at any time with aws elbv2 describe-account-limits, rather than trusting any blog, including this one.
Build it: an ALB with a Lambda target, behind an NLB
We will build the pattern from the table above: an internet-facing ALB that serves a Lambda function on /api/*, answers /health itself, and sits behind an NLB with one static address per zone. No instances to manage, so the bill stays tiny.
Prerequisites: an AWS account, the AWS CLI v2 configured, Python 3.12 and pip install boto3, and a default VPC with subnets in at least two Availability Zones in one Region. The identity you run it with needs, at minimum, elasticloadbalancing:*, lambda:CreateFunction, lambda:AddPermission, lambda:DeleteFunction, iam:CreateRole, iam:PassRole, iam:AttachRolePolicy, iam:DetachRolePolicy, iam:DeleteRole, and ec2:DescribeVpcs, ec2:DescribeSubnets, ec2:CreateSecurityGroup, ec2:AuthorizeSecurityGroupIngress, ec2:DeleteSecurityGroup. Use a sandbox account.
Estimated cost of following along for two hours: roughly $0.15 to $0.25 (ALB plus NLB hourly charges, one LCU or NLCU floor each, and four public IPv4 addresses), plus fractions of a cent for Lambda. Treat it as an estimate and check your bill.
Step 1: Lambda function and role
import boto3, io, json, time, zipfile
REGION = "us-east-1"
iam, lam = boto3.client("iam"), boto3.client("lambda", region_name=REGION)
CODE = '''
def handler(event, context):
return {"statusCode": 200, "statusDescription": "200 OK",
"isBase64Encoded": False,
"headers": {"Content-Type": "application/json"},
"body": '{"hello": "from lambda", "path": "%s"}' % event["path"]}
'''
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("app.py", CODE)
trust = {"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}
role = iam.create_role(RoleName="elb-demo-role", AssumeRolePolicyDocument=json.dumps(trust))
iam.attach_role_policy(RoleName="elb-demo-role",
PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
time.sleep(10) # IAM propagation
fn = lam.create_function(FunctionName="elb-demo", Runtime="python3.12",
Role=role["Role"]["Arn"], Handler="app.handler", Code={"ZipFile": buf.getvalue()})
print(fn["FunctionArn"])
The response shape is the contract. The Lambda target docs require isBase64Encoded, statusCode and headers in the return value. Omit one and the ALB returns a 502 and your function logs look perfectly healthy. That is the first of many "healthy function, broken response" bugs.
Step 2: the ALB, its target group and the rules
elb = boto3.client("elbv2", region_name=REGION)
ec2 = boto3.client("ec2", region_name=REGION)
vpc = ec2.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])["Vpcs"][0]["VpcId"]
subs = ec2.describe_subnets(Filters=[{"Name": "vpc-id", "Values": [vpc]}])["Subnets"]
by_az = {s["AvailabilityZone"]: s["SubnetId"] for s in subs}
subnets = list(by_az.values())[:2]
sg = ec2.create_security_group(GroupName="elb-demo-sg", Description="elb demo", VpcId=vpc)["GroupId"]
ec2.authorize_security_group_ingress(GroupId=sg, IpPermissions=[{"IpProtocol": "tcp",
"FromPort": 80, "ToPort": 80, "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}])
alb = elb.create_load_balancer(Name="elb-demo-alb", Subnets=subnets, SecurityGroups=[sg],
Scheme="internet-facing", Type="application")["LoadBalancers"][0]
elb.get_waiter("load_balancer_available").wait(LoadBalancerArns=[alb["LoadBalancerArn"]])
print(alb["DNSName"])
Now the Lambda target group. The permission step is the one the console does for you and the API does not, so it is where scripted builds usually fail:
tg = elb.create_target_group(Name="elb-demo-lambda", TargetType="lambda")["TargetGroups"][0]
lam.add_permission(FunctionName="elb-demo", StatementId="elb1",
Action="lambda:InvokeFunction", Principal="elasticloadbalancing.amazonaws.com",
SourceArn=tg["TargetGroupArn"])
elb.register_targets(TargetGroupArn=tg["TargetGroupArn"], Targets=[{"Id": fn["FunctionArn"]}])
lst = elb.create_listener(LoadBalancerArn=alb["LoadBalancerArn"], Protocol="HTTP", Port=80,
DefaultActions=[{"Type": "fixed-response", "FixedResponseConfig":
{"StatusCode": "404", "ContentType": "text/plain", "MessageBody": "no route"}}])["Listeners"][0]
elb.create_rule(ListenerArn=lst["ListenerArn"], Priority=10,
Conditions=[{"Field": "path-pattern", "Values": ["/api/*"]}],
Actions=[{"Type": "forward", "TargetGroupArn": tg["TargetGroupArn"]}])
elb.create_rule(ListenerArn=lst["ListenerArn"], Priority=5,
Conditions=[{"Field": "path-pattern", "Values": ["/health"]}],
Actions=[{"Type": "fixed-response", "FixedResponseConfig":
{"StatusCode": "200", "ContentType": "text/plain", "MessageBody": "ok"}}])
Notice what the ALB did without any compute of ours: it answered 404 for unknown paths and 200 for /health itself. Fixed responses are free of targets, which makes them the right tool for health endpoints, maintenance pages and a safe default action.
Step 3: put an NLB in front of the ALB
nlb = elb.create_load_balancer(Name="elb-demo-nlb", Subnets=subnets,
Scheme="internet-facing", Type="network")["LoadBalancers"][0]
elb.get_waiter("load_balancer_available").wait(LoadBalancerArns=[nlb["LoadBalancerArn"]])
ntg = elb.create_target_group(Name="elb-demo-alb-tg", TargetType="alb", Protocol="TCP",
Port=80, VpcId=vpc, HealthCheckProtocol="HTTP", HealthCheckPath="/health")["TargetGroups"][0]
elb.register_targets(TargetGroupArn=ntg["TargetGroupArn"], Targets=[{"Id": alb["LoadBalancerArn"]}])
elb.create_listener(LoadBalancerArn=nlb["LoadBalancerArn"], Protocol="TCP", Port=80,
DefaultActions=[{"Type": "forward", "TargetGroupArn": ntg["TargetGroupArn"]}])
print(nlb["DNSName"])
Per the NLB docs, an alb target group must use TCP, its port must match an ALB listener port, it must live in the same VPC and account as the ALB, and it holds exactly one ALB. Health checks go through to the ALB and must hit a listener with the same port and protocol, which is why we built /health as a fixed response in Step 2. I created the NLB without a security group on purpose. The same page warns that an NLB created without security groups cannot have them added later, and that QUIC listeners require an NLB with none, so decide this at creation time in real systems.
Verify it works
Wait two minutes, then confirm the NLB sees the ALB as healthy:
python - <<'PY'
import boto3
elb = boto3.client("elbv2", region_name="us-east-1")
arn = elb.describe_target_groups(Names=["elb-demo-alb-tg"])["TargetGroups"][0]["TargetGroupArn"]
print(elb.describe_target_health(TargetGroupArn=arn)["TargetHealthDescriptions"][0]["TargetHealth"])
PY
curl -s -i http://<NLB_DNS_NAME>/api/orders
curl -s -i http://<NLB_DNS_NAME>/nothing
curl -s http://<ALB_DNS_NAME>/health
Expected: the health state reads healthy, /api/orders returns HTTP 200 and {"hello": "from lambda", "path": "/api/orders"}, /nothing returns 404 with body no route, and /health returns ok. If the target stays in initial for more than a few minutes, jump to the next section.
When it breaks
Most of what you will hit is in the troubleshooting reference. Break it on purpose once so the codes mean something. Deregister the Lambda from its target group with aws elbv2 deregister-targets and request /api/orders again: you get a 503, because the target group has no registered targets. Then re-register and change the function's return value to drop statusCode: now it is a 502, with a function that ran fine.
502 Bad Gateway means the load balancer reached the target and did not like what came back. The causes the docs list: the target reset or closed the connection, a malformed response or bad headers, a response header over 32 K, a Lambda that errored, timed out or returned a body over 1 MB, and a deregistration delay that expired before an in-flight request finished. The classic cause on instance fleets is a target keep-alive shorter than the ALB idle timeout, so the target closes a connection the ALB believes is alive. Set the app keep-alive above 60 seconds.
503 means no registered targets, or all of them in the unused state. Check registration first.
504 means the ALB could not connect within its 10 second connection timeout, or connected and waited out the idle timeout. Two causes people miss: a network ACL that blocks return traffic on ephemeral ports 1024 to 65535, and a target that sends a Content-Length larger than the body it returns.
460 is a client closing the connection before the idle timeout. It is counted in HTTPCode_ELB_4XX_Count and is not your server's fault, but it often means your slowest endpoint is slower than your clients' patience.
Watch HTTPCode_ELB_5XX_Count (errors the load balancer generated) separately from HTTPCode_Target_5XX_Count (errors your targets returned). They have different owners and different fixes.
Cleanup
Order matters, because an NLB must release the ALB before the ALB can be deleted. Run this and confirm the bill stops:
elb.delete_listener(ListenerArn=elb.describe_listeners(LoadBalancerArn=nlb["LoadBalancerArn"])["Listeners"][0]["ListenerArn"])
elb.deregister_targets(TargetGroupArn=ntg["TargetGroupArn"], Targets=[{"Id": alb["LoadBalancerArn"]}])
elb.delete_load_balancer(LoadBalancerArn=nlb["LoadBalancerArn"])
elb.delete_load_balancer(LoadBalancerArn=alb["LoadBalancerArn"])
time.sleep(60) # wait for ENIs to release before deleting the groups
elb.delete_target_group(TargetGroupArn=ntg["TargetGroupArn"])
elb.delete_target_group(TargetGroupArn=tg["TargetGroupArn"])
ec2.delete_security_group(GroupId=sg)
lam.delete_function(FunctionName="elb-demo")
iam.detach_role_policy(RoleName="elb-demo-role", PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
iam.delete_role(RoleName="elb-demo-role")
If the security group refuses to delete, a load balancer network interface is still releasing; wait a minute and retry. Then run aws elbv2 describe-load-balancers and expect an empty list, and check Cost Explorer the next day for ELB and public IPv4 lines at zero.
What changed lately, and what to do next
ELB has moved fast. The NLB docs list weighted target groups for default actions (November 2025), QUIC and TCP_QUIC listener support (November 2025), and, on July 10, 2026, listener rules that let one dual-stack NLB route IPv4 and IPv6 clients to separate target groups at no extra charge. ALB gained request transforms for host headers and URLs in October 2025 and JWT access token validation in November 2025. Re-read the document history pages before you design around any of these.
Three extensions, easiest first. Turn on access logs to S3 and query one hour of them in Athena to see which of your 502s are yours. Switch the target group's load_balancing.algorithm.type to least_outstanding_requests on an instance fleet and compare p99 latency. Then replace the fixed-response /health rule with a real dependency check and decide, deliberately, what a "healthy" target means for your system.
The question to leave you with: when every target is unhealthy, the load balancer sends traffic to all of them anyway. Is that the behaviour you want, and have you ever tested it?
Sources
What is Elastic Load Balancing?Elastic Load Balancing pricingQuotas for your Application Load BalancersQuotas for your Network Load BalancersHealth checks for Application Load Balancer target groupsApplication Load Balancer attributesUse Lambda functions as ALB targetsCreate a target group for an Application Load Balancer behind an NLBTroubleshoot your Application Load BalancersNetwork Load BalancersAmazon VPC pricing (public IPv4)AWS Network Load Balancer now supports Listener Rules

