Scalability on AWS: 10,000 users in a day
The scalability report asked what breaks first if 10,000
people sign up in one day, mostly with the Pi in mind. This page asks the
same question for the AWS deployment only: the stack that
scripts/deploy-aws.sh creates from deploy/aws/cloudformation.yaml.
The traffic assumptions are the same (section 1 of that report): ~500
learners online in the peak hour, ~110 dynamic requests a second on
average and ~250 in bursts, 1,500 sign-ups in the peak hour, ~7,000
verification e-mails and ~300 AI generations over the day.
Everything here is read from the code and deploy files on main as of
2026-10-21. Nothing was measured on an instance. The AWS figures are
estimates from the laptop probe in the first report, and they say so where
they appear.
Short version. One t4g.nano has plenty of CPU and bandwidth for the
day, as long as the account keeps T4g’s default unlimited CPU credits.
The day costs about a dollar more than a normal day. What goes wrong is the
same as on the Pi: the 100-an-hour mail ceiling, three threads shared by
password hashing and the database, and two AI slots. AWS adds risks of its
own. Everything runs on one box with no backups, so losing the instance
loses every new account. The box has 512 MB and no swap. And nothing
alerts you or records request times. Resizing to a t4g.small for the
event takes one setting and about two minutes of downtime.
1. What the stack is
| Piece | What it is | Where |
|---|---|---|
| Instance | One t4g.nano: 2 Graviton2 vCPUs (burstable, 5 % baseline each), 512 MiB RAM, no swap | cloudformation.yaml, AWS_INSTANCE_TYPE |
| Disk | 8 GB gp3 root volume (3,000 IOPS, 125 MB/s baseline). Holds the OS, the binary and learn.db. Deleted with the stack. | VolumeSize, DeleteOnTermination: true |
| Proxy | Caddy: HTTPS (Let’s Encrypt), encode zstd gzip, reverse_proxy 127.0.0.1:8080. No log directive, so no access log. | Caddyfile written by deploy-aws.sh |
| App | learnmap.service: MemoryHigh=128M, MemoryMax=192M, TasksMax=64, 2 async workers, 3 blocking threads | deploy/aws/learnmap.service, src/main.rs |
| Network | One Elastic IP, default VPC, one Availability Zone. ~32 Mbit/s baseline bandwidth, bursting to 5 Gbit/s. | StaticIp |
| Backups, alarms, logs off the box | None | — |
| Cost | ~$7.40 a month (docs/aws-setup.md) |
2. The day on one t4g.nano
Estimates, not measurements. A Graviton2 core is taken to be roughly 3–5×
slower than the laptop’s (an Apple M5 Plus). The app uses only two cores for
async work either way (worker_threads(2)), so the laptop figures are
divided by ~4.
| Path | Laptop (measured) | t4g.nano (estimate) | Peak-hour need |
|---|---|---|---|
POST /api/answer | 10,700/s | ~2,500/s | ~85/s |
GET / home, new learner | 8,520/s | ~2,000/s | ~17/s, plus landing views |
GET /lessons/{slug} | 18,900/s | ~4,500/s | (included above) |
| argon2 hash (sign-up, login) | ~35 ms at concurrency 10 | ~30–50 ms each, ~40–60/s on both cores | 0.4/s, bursts of 2–3/s |
CPU. The app and Caddy (TLS handshakes, compression) need about 15–25 % of the two vCPUs in the peak hour and well under 10 % on average. That is above the 5 % baseline, so the instance spends CPU credits. T4g instances start in unlimited mode unless the account’s default was changed. In that mode they never slow down, and use above the baseline costs $0.04 per vCPU-hour, which comes to cents for this day. In standard mode a nano earns 6 credits an hour and holds at most 144. A busy morning spends them, and then the instance is held to 5 % of each core. That is about a twentieth of the speed, and the day would not fit.
Memory. 512 MiB in all and no swap. Amazon Linux 2023 and its services take roughly 150–200 MB, and Caddy 30–60 MB under TLS load. That leaves the app’s 128 MB soft and 192 MB hard limit with some room. The app measured 54 MB on macOS with every built-in themed course copy loaded. (Linux was not measured.)
Disk. ~90 transactions a second at peak, with WAL and
synchronous = NORMAL, is far below gp3’s 3,000 IOPS. ~100 MB of new data
a day fits the 8 GB volume for months.
Bandwidth. ~4 Mbit/s in the peak hour before compression, less after
Caddy’s encode. This is well inside the nano’s 32 Mbit/s baseline. The
first report’s Wi-Fi bottleneck (#7) does not exist here.
Cost of the day (us-east-1 prices, rounded):
| Item | Normal day | Viral day |
|---|---|---|
| t4g.nano ($0.0042/h) | $0.10 | $0.10 |
| CPU above the baseline (unlimited mode) | $0 | ~$0.10–0.30 |
| Elastic IP, 8 GB gp3 | $0.14 | $0.14 |
| Data out (~11 GB uncompressed, ~4–6 GB compressed) | $0 | $0 within the 100 GB/month free allowance, then $0.09/GB |
| or a t4g.small for the event ($0.0168/h) | +$0.30 |
The bill is small. A month of learners who keep coming back could use more than the 100 GB free allowance, which costs a few dollars, not tens.
3. Bottlenecks, ranked
Ranked by when each one bites on a viral day. Numbers 1, 2, 5 and 6 are the first report’s bottlenecks, unchanged by AWS. They are summarised here; the fixes are in that report.
1. The hourly e-mail ceiling drops verification mail
Same as the first report’s #1, and still the first thing users notice:
from the 101st sign-up in an hour, nobody gets a verification e-mail
(src/mail.rs, DEFAULT_PER_HOUR = 100, capped messages are dropped).
AWS changes nothing here. The app sends through Brevo, Postmark or Resend
over HTTPS, so EC2’s block on outbound port 25 does not matter.
- Cheapest fix:
SKRONGLE_MAIL_PER_HOUR=3000in/etc/learnmap/env(so indeploy-aws.sh’s env file), and a provider plan for ~10,000 messages a day. Amazon SES is not one of the supported providers. Adding it would be new code, and a new SES account starts in a sandbox that takes about a day to leave, so don’t start that the week of the event.
2. Password hashing and the database share three threads
Same as the first report’s #2 (max_blocking_threads(3) in
src/main.rs). Graviton hashes faster than the Pi does, so the stalls
are shorter (estimate ~30–50 ms instead of 60–120 ms), but three
simultaneous sign-ins still leave every page and answer waiting.
Measured on the laptop, six concurrent logins cut home-page throughput
18×. With only two vCPUs, more threads would not make hashing faster.
They would only keep SQLite from waiting behind it, which is the point of
the fix: ~8 blocking threads, and a 2-permit semaphore around argon2 and
the Google and Stripe calls.
3. Burstable CPU: one account setting decides the day
- Where:
t4g.nanoincloudformation.yaml. The template sets noCreditSpecification, so the instance gets the account default for T4g, which isunlimitedunless someone changed it. - What breaks, and when: in
standardmode, the moment the credit balance reaches zero, probably in the first busy hours. CPU is then capped at 5 % a core, and every request, TLS handshake and hash runs about 20× slower. Pages time out under the peak’s ~250 requests a second. - How you’d notice: CloudWatch’s
CPUCreditBalancefor the instance falls to 0 andCPUUtilizationsits flat at 5 %. These metrics are free, at 5-minute resolution. - Cheapest fix: check it now, with
aws ec2 describe-instance-credit-specifications --instance-ids <id>. If it saysstandard, runaws ec2 modify-instance-credit-specification --instance-credit-specification "InstanceId=<id>,CpuCredits=unlimited". - Proper fix: set
CreditSpecification: { CPUCredits: unlimited }in the template, so a recreated stack can’t come back in standard mode.
4. 512 MB, no swap, and a unit limit sized for the OS
- Where:
t4g.nanohas 512 MiB. Amazon Linux has no swap by default.deploy/aws/learnmap.servicesetsMemoryHigh=128MandMemoryMax=192M. The memory growers are the first report’s #3: a full themed copy of a course for each (course, theme) pair anyone wears (Courses::get_themed), the cache of user-made courses (no bound onmain, 24 MB with #437), tutor chats, and 256 connections’ buffers. - What breaks, and when: probably not with the built-in courses alone.
All six themed copies measured 54 MB. But user-made courses and themed
copies of them grow without a bound on
main. At 128 MB the kernel throttles the app. At 192 MB it kills it, and the restart loses in-memory state: running AI jobs, tutor chats, games, Google sign-ups at the Terms step, rate-limit windows. With no swap, if the box as a whole runs out (app, Caddy and the OS), the kernel’s OOM killer can pick Caddy instead, and then the site is down until it restarts. - How you’d notice:
journalctl -u learnmapshows the OOM kill and a restart. Learners see Caddy’s 502 for a few seconds. CloudWatch has no memory metric without its agent. - Cheapest fix: for the event,
AWS_INSTANCE_TYPE=t4g.small(2 GiB, $0.0168/h) andscripts/deploy-aws.sh. CloudFormation stops and starts the instance; the Elastic IP and the disk stay. Plan for ~2 minutes of downtime at a quiet hour. Raise the unit toMemoryHigh=512M,MemoryMax=768M, and merge #437. - Proper fix: the first report’s #3: themed copies share the plain store’s lessons, or an LRU cache with a byte budget.
5. One box: an outage loses the day’s sign-ups
- Where:
learn.dblives on the root volume, which hasDeleteOnTermination: true. Since #964 a nightlysqlite3 .backupgoes to an S3 bucket the stack keeps on delete, and a nightly EBS snapshot is kept 14 days (AWS setup, Backups). The stack is one instance in one Availability Zone. - What breaks, and when: a failed instance, a bad deploy or a corrupted database loses every account made since the last nightly backup, up to a day’s sign-ups. AWS recovers a failed instance onto new hardware for most current types, and the volume survives that.
- How you’d notice:
StatusCheckFailedin CloudWatch. The site is down. - Cheapest fix: before a big day,
scripts/deploy-aws.sh --backupby hand, and again after it. - Proper fix: an hourly backup timer instead of a nightly one (the
same unit,
OnCalendar=hourly), or streaming the write-ahead log to S3 (Litestream) if minutes matter.
6. Two AI generation slots, and a thread limit for the tutor
Same as the first report’s #5: MAX_JOBS = 2 in src/ai.rs turns
away roughly half of the peak hour’s ~45 Generate presses. On AWS the
cheap fix is the real fix. Jobs are threads waiting on the learner’s own
API key, and a t4g.small has memory for ~8.
The AWS unit adds one limit: TasksMax=64. The app’s fixed threads take
about ten (workers, blocking pool, the job runner, AI jobs). Every tutor
turn on main spawns its own OS thread (src/tutor.rs). So past ~50
tutor replies at once, the next learner gets the tutor’s “thread failed”
error. #458’s cap of 4 tutor calls makes that unreachable.
7. Per-address sign-up limits hit shared networks
Same as the first report’s #6. Caddy appends the real client address to
X-Forwarded-For, which security::client_ip reads. So a classroom or
a carrier’s shared address still hits REGISTER_PER_IP (20 an hour)
after 20 sign-ups. Raise it about 10×.
8. Nothing tells you any of this is happening
- Where: the Caddyfile has no
logdirective, so no request is logged and no request time is kept. The app logs to journald on the instance only. The stack creates no CloudWatch alarms, and the deploy user can’t create any (it has EC2, CloudFormation and SSM read-only). - What breaks, and when: nothing breaks, but #2, #3 and #4 are only visible as “it feels slow” until a learner complains. Afterwards there is no record of how slow, for how long, or which path.
- Cheapest fix: add
log { output file /var/log/caddy/access.log { roll_size 50MiB roll_keep 5 } format json }to the Caddyfile indeploy-aws.sh. Each line then carriesdurationandstatus. WatchCPUCreditBalance,CPUUtilizationandStatusCheckFailedin the EC2 console’s Monitoring tab during the day. - Proper fix: two CloudWatch alarms in the stack (
StatusCheckFailedfor 2 minutes,CPUUtilization> 80 % for 10 minutes) that e-mail you through SNS. AddCloudWatchFullAccessandAmazonSNSFullAccessto the deploy user.
Later, not on day one
- The home page reads all of a learner’s history (the first report’s #4) and e-mail lookups scan the users table (#8). On Graviton they hurt about 5× less than on the Pi. They are still worth the one migration, weeks after the day.
- Deploys restart the app.
deploy-aws.shrunssystemctl restart: a few seconds of 502, and in-memory state is lost. Don’t release during the peak. - The ceiling of one box. SQLite is one file with one writer, and AI
jobs, tutor chats, games and rate limits live in memory, so the app
can’t run on two instances behind a load balancer. It doesn’t need to.
Bigger instances scale it, but only up to 2 vCPUs until
worker_threads(2)becomes a setting. A non-burstablec7g.large(2 vCPU, 4 GiB) removes the credit question for ~$53 a month.
4. What AWS already does well
- No Wi-Fi, no home uplink. The Pi’s network bottleneck is gone, and
Caddy compresses every text response (
encode zstd gzip). - Automatic HTTPS. Caddy renews certificates and serves HTTP/2, which helps a first visit’s ~170 KB of assets.
- Resizing is one setting.
AWS_INSTANCE_TYPEplus a deploy. The Elastic IP, the disk and the database stay. - The disk is fast enough. gp3’s 3,000 IOPS is about 30× the peak’s write rate, so the Pi’s SD-card stalls don’t apply.
- Cost stays small. A viral day adds about a dollar, or $0.40 with a
t4g.smallfor the day.
5. The five changes worth doing first on AWS
| # | Change | Fixes | Effort |
|---|---|---|---|
| 1 | SKRONGLE_MAIL_PER_HOUR=3000 and a mail plan for ~10k/day. Then per-purpose ceilings, and queue capped mail instead of dropping it (first report). | #1 | 15 min config; ~½ day code |
| 2 | Check the CPU credit mode (set unlimited, and put it in the template). Move to t4g.small for the event and raise the unit to 512M/768M. Merge #437. | #3, #4 | 30 min |
| 3 | A nightly SQLite backup off the box and a snapshot right before the event. Then a DLM snapshot policy in the stack. | #5 | 1 h; ½ day for DLM |
| 4 | A Caddy access log with durations. Then CloudWatch alarms for status checks and CPU. | #8 | 15 min; ½ day for alarms |
| 5 | max_blocking_threads(8) and a semaphore around argon2. SKRONGLE_AI_JOBS (~8) and a log line for refused starts. Raise the per-address limits 10×. Merge #435 and #458. | #2, #6, #7 | ~1 day |
To find these limits before a real crowd does, run the spike tests in Load testing against a copy of this stack.
Follow-up issues:
- #579: the e-mail limit.
- #578: a self-hosted mode on SQLite and a hosted mode on Postgres.
- #580: a background job framework.
- #581: several servers on one database.
- #584: renaming settings to
SKRONGLE_.
Not measured
- Any of it on an instance: the throughput column is the laptop’s numbers divided by ~4, and the memory split between the OS, Caddy and the app is typical for Amazon Linux 2023, not read off a box.
- Caddy’s TLS and compression cost under load, and Let’s Encrypt issuance on a fresh stack.
- Real mail-provider and LLM latency.
- Prices are us-east-1 list prices as of 2026-10. Other regions differ by cents.