Tracing 200 GB a day to one Discord intent
The NAT gateways were processing 216.62 GB a day, about $296 a month. Almost all of it was coming in, not going out. I pulled the flow logs down from S3 and queried them with DuckDB. Five addresses were doing nearly all of it, and they all resolved to gateway.discord.gg. A shared module opened a Discord connection from every app pod, and one intent made Discord send every member status change to all of them. Taking that intent out took NAT to 14.75 GB a day.
From the start of June to the middle of August the NAT gateways processed about 216 GB a day. At $0.045 a GB, the rate in this region, that is $9.75 a day, near enough $296 a month. Check your own region before you reuse the number.
Nothing explained it. The database had already been moved off the NAT gateway onto PrivateLink. The sites sit behind a CDN, so user traffic does not come back through NAT. The pods talk to each other inside the VPC.
So something was pulling 200 GB a day in from the internet, and I could not say what.
It was one flag in a Discord client.
Getting the logs somewhere I could query them
With the default fields, NAT gateway flow logs obscure the original endpoint because srcaddr and dstaddr
can represent the NAT gateway's network interface rather than the original Internet peer. The packet-level
pkt-srcaddr and pkt-dstaddr fields preserve the original addresses.
The fields you want are
pkt-srcaddr and pkt-dstaddr.
They hold the real address at each end. You
have to ask for them in a custom log format, which I wrote up in
the flow log format that names the real peer.
Same Terraform, same logs, a different answer at the end of it.
The logs land in S3 as Parquet files, split by hour. I copied them to my laptop and opened them with DuckDB:
aws s3 sync s3://<flow-log-bucket>/AWSLogs/.../year=2026/month=05/ ./parquet/
duckdb
One query gives you the list of who is on the other end:
WITH classified AS (
SELECT
bytes,
CASE
WHEN dstaddr IN ('10.42.0.10', '10.42.1.10', '10.42.2.10') THEN pkt_dstaddr
WHEN srcaddr IN ('10.42.0.10', '10.42.1.10', '10.42.2.10') THEN pkt_srcaddr
END AS external_ip
FROM read_parquet('./parquet/**/*.parquet', hive_partitioning = 1)
)
SELECT external_ip,
ROUND(SUM(bytes) / 1024.0 / 1024 / 1024, 2) AS gb
FROM classified
WHERE external_ip IS NOT NULL
AND external_ip NOT LIKE '10.%'
GROUP BY external_ip
ORDER BY gb DESC
LIMIT 10;
The three addresses in the CASE are the private addresses of the three zonal NAT gateway
interfaces. Regional NAT gateways fill these fields in differently, so check yours before copying
this. Each row is either a pod going out or the
internet coming back. Which field holds the far end depends on which way the packet went, so you
have to check both. Get it the wrong way round and every row is your own gateway again.
One thing about the byte counts. The gateway's interface sees every byte twice, once on the private side and once on the public side. AWS's own example shows four records for one round trip: pod to gateway, gateway to internet, internet to gateway, gateway back to pod.
The query keeps two of those four, and it takes both halves to do it. On the two private-side
records the packet-level field holds the real internet peer, so the CASE picks it up. On the two
public-side records that same field is the gateway's own address, so NOT LIKE '10.%' drops them.
What survives is one record per direction, which is what AWS bills. A plain SUM(bytes) over the
same interface, with neither the CASE nor the filter, comes out double.
AWS bills flow logs as vended logs, at $0.50 a GB delivered to S3.
Logging every packet on a busy VPC runs to double figures of GB a day, and you pay for all of it. Work that out against your own traffic before you switch them on. Get your answer, then switch them off.
Wait longer than you want to
A forty six minute capture gives you a suspect. It does not give you a number.
You want a talker that stays big hour after hour. One spike tells you nothing, and if a spike is all you have, you will build a story around it. I let it run for days before I believed anything.
The five addresses
The top five rows were five addresses in the same block, each carrying about the same amount, every day:
162.159.130.234
162.159.133.234
162.159.134.234
162.159.135.234
162.159.136.234
Two checks name them. Both still work today. Forward DNS returns those same five:
dig +short gateway.discord.gg A
And a TLS handshake to any of them returns Discord's certificate:
echo | openssl s_client -connect 162.159.133.234:443 -servername gateway.discord.gg 2>/dev/null \
| openssl x509 -noout -subject
# subject=CN=discord.gg
Over the last full week of May those five were 93% of everything the NAT gateways processed.
Billed GB per day from flow logs, 23–29 May 2026. Discord averaged 163.6 GB a day of a 175.1 GB total.
Moving the database onto a PrivateLink endpoint had taken NAT down to 175 GB a day by the end of May. By June it was back over 200. Discord was the part that grew. It went from 163.6 GB a day in late May to 201.87 by the time it came out. Everything else stayed flat, at 11 to 15 GB a day.
One connection per pod
At this point I expected to find one process somewhere talking to Discord. That is not what was happening.
The backend is a monorepo. The Discord client is built in one shared module, and that module calls
login(), which opens a connection to Discord and holds it open. The auth, user and subscription
controllers all import that module. Those are normal request paths. The sites use them.
So every app pod that served a login opened its own connection. Each pod then got its own copy of everything Discord sent.
The stream did not arrive once. It arrived once per pod.
Anything that holds a connection open opens one per pod once it sits in shared code that normal request paths import. A Discord gateway, a Kafka consumer, a pub/sub subscriber, a websocket to a vendor. Your inbound traffic then scales with your replica count, not with your load, and an autoscaler quietly makes it worse. Check where the client is constructed before you count the connections.
GUILD_PRESENCES
The client was built with a list of gateway intents. An intent tells Discord which events to send you. Ask for fewer, get less.
One of them was GUILD_PRESENCES. That one makes Discord send a
presence update every
time a member of a server your app is in changes state. Online, idle, do not disturb, offline. It
also fires when someone starts a game or plays a song, and when they change their name or avatar. On
a big server this never stops.
Nothing in the application read presence data. Not one line.
The direction of the traffic gives it away:
| Direction | GB/day |
|---|---|
| Inbound, from the internet | 197.51 |
| Outbound, from the VPC | 19.11 |
| Total processed | 216.62 |
Ten bytes in for every byte out. NAT charges both directions, so you do not have to ask for data to pay for it. It arrived, it cost $0.045 a GB, and it was thrown away.
Taking it off
One line came out of the intent list. It went out on 12 August.
const client = new Client({
intents: [
Intents.FLAGS.GUILDS,
Intents.FLAGS.GUILD_MEMBERS,
- Intents.FLAGS.GUILD_PRESENCES,
Intents.FLAGS.GUILD_MESSAGES
]
});
client.login(token);
NAT dropped the same day, and not gradually. The deploy landed around 04:00 UTC and the hourly figures fall off a cliff right there: 8.80 GB in the 03:00 hour, 1.21 GB in the 04:00 hour, 0.51 GB by 05:00. That is why 12 August reads as 45 GB rather than either level — four hours at the old rate, twenty at the new one. The table below starts the after window on the 13th for that reason. A month on, it has not drifted back.
CloudWatch AWS/NATGateway, 1 June – 12 September 2026.
| Window | NAT processed | Cost/day | Cost/month |
|---|---|---|---|
| 1 Jun – 11 Aug (before) | 216.62 GB/day | $9.75 | $296 |
| 13 Aug – 12 Sep (after) | 14.75 GB/day | $0.66 | $20 |
| Difference | 201.87 GB/day | $9.08 | $276 |
216.62 GB a day minus 14.75 leaves 201.87. At $0.045 a GB that is $9.08 a day. Over an average month of 30.4 days, $276.
That sum on its own proves nothing. NAT charges a flat rate per GB, so the cost is just the volume again in different units. Saying the cost fell because the volume fell is not a check.
The check is that two different instruments point the same way. In May the flow logs read actual packets and put 93% of NAT bytes on those five Discord addresses. In August the CloudWatch NAT metrics, which read no packets and know nothing about Discord, lost 201.87 GB a day of 216.62. The volume that left when the intent came out is the volume the packet capture had already pinned on Discord. Different months and different tools, so it is corroboration rather than the same measurement made twice.
External sources
- Amazon VPC pricing — the $0.045 a GB NAT charge, and that it applies in both directions.
- Amazon CloudWatch pricing — what AWS charges to deliver vended logs, which is what VPC flow logs are billed as.
- VPC flow log records —
every field, including
pkt-srcaddrandpkt-dstaddrand when they differ fromsrcaddranddstaddr. - Flow log record examples — the worked NAT gateway example, which is where the four-records-per-round-trip behaviour is visible.
- Discord gateway intents — what each intent turns on, and which ones are privileged.
- Discord presence update event —
the event
GUILD_PRESENCESswitches on, and what it carries.