Your app was working last night. This morning it returns errors, and somewhere a dashboard says "instance unhealthy." What does that actually mean?
If you build on any backend platform — InsForge, Supabase, a VPS, your own AWS account — your code ultimately runs on a rented computer. When that computer runs out of one of three things, your app goes down. This post explains what those three things are, what actually happens when each one runs out, and the real default numbers involved. Every number here comes from load tests we ran on our own production instances this week.
What is EC2?
EC2 (Elastic Compute Cloud) is AWS's service for renting virtual computers, called instances. An instance is a slice of a physical server in an AWS datacenter, with a fixed amount of three resources:
- CPU — how fast it can compute
- RAM — how much it can hold in working memory
- Disk — how much it can store permanently
You pick a size and pay for it whether you use it or not. The smallest common size, a t4g.nano, has 2 vCPUs, 512MB of RAM, and whatever disk you attach (8GB is typical). A t4g.xlarge has 16GB of RAM and costs about 32× more. Sizing is the whole game: too small and you hit walls, too big and you burn money on idle capacity.
The three resources fail in completely different ways, and knowing the difference is most of debugging "my server is down."
CPU: you don't crash, you slow down
CPU is the least dangerous resource to exhaust. If your app needs more compute than the instance has, requests queue up and everything gets slower — but nothing dies.
The trap on small instances is burstable CPU. AWS t-family instances (t3, t4g) don't give you the full CPU all the time. You earn CPU credits while idle and spend them when busy. A nano earns credits slowly; sustain high CPU for long enough and you're throttled to a small baseline (around 10–20% of a core). Your app is still "up" — it's just suddenly 5–10× slower, which users experience as down.
Symptom: everything works but latency climbs from 50ms to seconds, especially after sustained traffic. Fix: check CPU credit balance before assuming your code got slower.
RAM: the one that actually kills things
RAM is where running programs live. Unlike CPU, you cannot gracefully use "a bit more" RAM than exists. When it's gone, the operating system has two moves, in order:
1. Swap. The kernel moves the least-used memory pages out of RAM. Modern setups use zram — compressed swap that lives in RAM itself. Sounds circular, but it works: on our instances zram compresses about 3.4×, so 217MB of "swapped" memory only occupies ~65MB of physical RAM. A server showing heavy swap usage is not necessarily in trouble — cold pages sitting in compressed swap is the system working as designed. What matters is churn: if hot data is being compressed and decompressed on every request, latency spikes.
2. The OOM killer. When swap can't absorb it either, the kernel's out-of-memory killer picks the process using the most memory and kills it. No warning, no graceful shutdown. And here's the cruel part: the biggest process on a backend box is almost always your database.
We measured exactly this. On a 512MB instance running Postgres with its stock configuration, we opened 90 concurrent database connections. Postgres tried to serve all of them, blew through its memory limit at around 45 seconds, and got OOM-killed mid-flight. Throughput collapsed to 11 requests/second with 6-second latencies, and clients hung for over 12 minutes while everything restarted. From the outside: "the instance is down."
The same test against a properly tuned config: the excess connections were rejected instantly with "sorry, too many clients already," memory stayed bounded, and the database never blinked. Failing fast beats falling over — a rejected connection retries in milliseconds; a killed database takes everything down with it.
Disk: the silent one
Disk is storage — your database files, logs, uploaded assets. It fails quietly: no throttling, no killer, just writes start failing. Databases handle this badly; Postgres can crash or refuse to start, and in the worst case you're recovering from a corrupted write.
The classic culprit isn't data — it's logs. An app logging errors in a crash loop can write gigabytes overnight. The especially nasty version: the disk fills, which breaks the SSH/agent tooling you'd use to log in and fix it, and now you need a reboot or console access just to delete files.
Symptom: sudden failures across unrelated features, database errors mentioning "no space left on device." Fix: log rotation, and an alert at 80% disk usage — not 95%, because you need room to act.
What a real 512MB server actually runs
Here's the default memory footprint of a typical small backend stack — these are measured numbers from a production instance running Postgres, PostgREST, and a Node.js API in Docker:
| Component | Typical footprint |
|---|---|
| Postgres (tuned, under load) | 90–150MB |
| Node.js API server | ~110MB |
| PostgREST | 10–30MB |
| Docker daemon + containerd + shims | ~50MB |
| Host agents (SSM, monitoring) | 10–30MB |
| Linux kernel + system | ~30MB |
Add it up: a "simple" three-service stack wants 300–400MB before serving a single request, on a box with 418MB usable. That's why small instances live near their limits, why swap exists, and why one bad default can tip the whole thing over.
And bad defaults are everywhere, because most software assumes a big server:
- Postgres ships with
shared_buffers=128MBandmax_connections=100— reasonable on a 16GB machine, a death sentence inside a 150MB container. Tuned for the box (32MB buffers, 30 connections), the same database survives load that kills the default config. - Node.js sets its heap ceiling from the machine's total RAM — it will happily grow past what your container allows and get killed rather than garbage-collect sooner.
- npm itself costs memory: running a server via
npm startkeeps two npm wrapper processes alive forever. We measured the difference — runningnodedirectly cut the container's footprint by 16MB, an 18% reduction, from a one-line change. - Docker containers have no memory limit by default. Unlimited containers on a small host means the OOM killer picks the victim, not you.
So why was your instance "down"?
Ranked by how often we actually see each cause:
- RAM: the OOM killer took out the database — usually triggered by a connection spike or a query that needed more memory than existed.
- Crash loops — the database blipped for 10 seconds, and an unhandled error in the app turned that into minutes of downtime as the process crash-restarted. (We found and fixed exactly this in our own API: a Postgres restart emitted one late error event with no handler, and Node's default response to that is to exit.)
- Disk full — almost always logs.
- CPU credits exhausted — not technically down, but slow enough that users call it down.
- It wasn't down — it was restarting for a deploy or resize, and the health check caught it mid-restart.
What to actually do
If you run your own instances: size configs to the container, not the software's imagination of a big server. Cap connections so overload fails fast. Set container memory limits so you choose what dies first. Rotate logs. Alert on available memory and disk, not "usage percent."
If you build on a managed platform, this is the stuff your platform should be doing for you — it's exactly what we spent this week load-testing and tuning across InsForge instance tiers, so a project on a nano gets nano-sized configs and a project on an xl actually uses its 16GB. Your app shouldn't go down because a default assumed a server you don't have.
