RE:NODE

Sizing13 min read

Sizing a web app for launch day: cache, queries, headroom

Launch traffic is short, spiky and mostly cacheable. Turn a visitor estimate into requests per second, size the workers, and fix what actually breaks.

Updated

0 readers

Launch day traffic looks alarming and is usually the easiest traffic you will ever serve: a great many people asking for the same few pages within the same hour. The trap is not volume. It is that every one of those identical requests reaches your application, your application reaches the database, and the database does the same work ten thousand times to produce the same bytes. Fix that and a launch that would have needed four times the plan runs on the plan you have.

The sequence that works is short, and the order matters more than any individual step. Convert your traffic guess into requests per second. Work out how many concurrent workers that implies. Cache everything that does not depend on who is asking. Find the one slow query before it finds you. Load test the result. Then, and only then, think about the plan. Most launch-day outages are one unindexed query or one uncached template, not one missing gigabyte.

Turn the traffic estimate into requests per second#

"We are expecting twenty thousand visitors" is not a number you can size against. Turn it into requests per second and it becomes one.

Take your expected visits for the day, decide what share arrives in the busiest hour, and assume roughly half of that hour lands in its busiest ten minutes. That is pessimistic enough to be useful and optimistic enough not to be silly.

code
20,000 visits, 60% in the launch hour   = 12,000 visitshalf of those in the busiest 10 minutes =  6,000 visits6,000 / 600 seconds                     =    10 page views per second

Then multiply by requests per page view. A page view is not one request: it is the document plus its stylesheets, scripts, fonts, images and whatever analytics you attached. Fifteen to forty subrequests is normal. At ten page views a second and twenty subrequests you are serving 200 requests a second in total, of which ten touch your application and 190 are static files.

That split is the whole story. Ten dynamic requests a second is trivial for any framework on any plan. Two hundred static file requests a second is trivial for any web server. What kills a launch is when the split is wrong - when the images are resized on the fly, or the "static" marketing page is rendered from the database every time.

Expected visits in the peak hourPage views/sDynamic req/s (uncached)Dynamic req/s (60s cache)
1,0000.50.5under 0.1
10,00055under 0.1
50,0002525under 0.1
200,000100100under 0.1

The last column is not a rounding trick. A page cached for sixty seconds is rendered at most once a minute regardless of how many people ask for it. That is the difference between needing a plan and needing a directive.

How many workers that actually needs#

The formula is Little's Law, and it is the only piece of queueing theory a web developer needs:

code
concurrency = requests per second x average response time

Ten dynamic requests a second at 200 ms each needs two workers handling requests at any instant. Ten a second at 2 seconds each needs twenty. Response time, not traffic, is what sets the worker count, which is why fixing a slow endpoint is worth more than doubling the plan.

Add headroom, because averages hide the tail. Size for the 95th percentile response time and then add 50 per cent. In the example above, if p95 is 600 ms rather than 200 ms, you need six workers, so provision nine or ten.

For PHP-FPM, that number is pm.max_children, and it also has a memory constraint:

/etc/php/8.3/fpm/pool.d/www.conf
pm = dynamicpm.max_children = 12pm.start_servers = 4pm.min_spare_servers = 2pm.max_spare_servers = 6pm.max_requests = 500pm.status_path = /fpm-status

Set pm.max_children to the memory you can spare divided by the real resident size of a worker. Measure it with ps rather than guessing: a WordPress worker with a typical plugin load sits at 60-120 MB, so on a 2 GB plan with 1.2 GB available for PHP you get ten to twelve children, not fifty. Setting it higher does not give you more throughput, it gives you swapping and then an out-of-memory stop.

pm.max_requests = 500 recycles each worker after 500 requests, which papers over slow leaks in plugins you did not write. And pm.status_path is worth enabling before launch: the listen queue figure it reports is the cleanest possible signal that you are out of workers. Anything above zero, sustained, means requests are waiting for a child rather than for your code.

Node and Python are different shapes of the same problem. One Node process uses one core, so concurrency comes from running several instances behind the proxy, or from cluster. Gunicorn's rule of thumb for synchronous workers is (2 x cores) + 1, and an async worker class changes the maths entirely. In every case, more workers than you have memory for is worse than fewer. How much RAM does WordPress need works the PHP case through in detail, and node memory limits covers the ceiling a Node process sets for itself.

Cache the pages that do not change#

A marketing page rendered per request is identical work repeated thousands of times for an identical result. Cached for even sixty seconds it becomes one render and thousands of reads from memory. That single change is worth more than any plan upgrade you could buy on the morning.

most requests end hereuncached pages onlyone per cache periodqueriesVisitorCDN or browserhashed assets, imagesReverse proxy10s page cacheApplicationrenders onceDatabasethe real ceiling
Where a launch-day request should stop

Three layers, cheapest first. Use all of them.

Browser and CDN, for anything with a hash in its name. A build that emits app.4f9c2b.js can be cached forever, because a new build produces a new name:

code
Cache-Control: public, max-age=31536000, immutable

A short proxy cache for HTML. Ten seconds is enough to flatten a spike and short enough that nobody notices staleness. In nginx:

nginx
proxy_cache_path /var/cache/nginx keys_zone=micro:10m max_size=1g inactive=10m;map $http_cookie $bypass_cache {    default                0;    ~*session|logged_in    1;}location / {    proxy_pass http://127.0.0.1:3000;    proxy_cache micro;    proxy_cache_valid 200 301 302 10s;    proxy_cache_lock on;    proxy_cache_use_stale updating error timeout http_500 http_502 http_503;    proxy_cache_background_update on;    proxy_cache_bypass $bypass_cache;    proxy_no_cache $bypass_cache;    add_header X-Cache-Status $upstream_cache_status;}

Two of those directives are the ones that matter under load. proxy_cache_lock on means that when a cached page expires, one request regenerates it and the rest wait for that copy, instead of a thousand requests all stampeding your application at the same instant. proxy_cache_use_stale means that if the application falls over, visitors keep getting the last good copy rather than a 502. Between them they turn a bad five minutes into an invisible one. Check X-Cache-Status in your browser's network tab before launch: if it says MISS on every reload, something in your response is preventing caching, and it is usually a Set-Cookie header on a page that does not need one.

Application-level caching for expensive fragments. The product count, the leaderboard, the "recently joined" list. These are usually a handful of queries doing most of the damage, and a sixty-second memoisation removes them entirely.

What cannot be cached: anything logged in. That is fine, because logged-in pages are a much smaller share of launch traffic than you expect. The rule is to keep the cacheable and the personalised strictly apart, and never to put a per-user greeting in the page shell, because it makes the whole document uncacheable for one line of text. HTTP caching headers explained covers the header semantics properly, including stale-while-revalidate and why an ETag set wrongly quietly disables everything.

The database is what falls over#

When a launch fails, the application is usually fine and waiting. The database is what ran out, and it almost always ran out in one of three ways.

A query with no index. Fast on your laptop with 200 rows, catastrophic with 200,000 because it is a sequential scan. Find them before the day. In PostgreSQL, log anything slow and then read the plan:

sql
-- postgresql.conflog_min_duration_statement = 200   -- millisecondsshared_preload_libraries = 'pg_stat_statements'
sql
SELECT calls, round(mean_exec_time::numeric, 1) AS avg_ms, queryFROM pg_stat_statementsORDER BY mean_exec_time * calls DESCLIMIT 10;

Order by total time rather than by average, because the query that runs ten thousand times at 40 ms hurts more than the report that runs once at 4 seconds. The column is mean_exec_time on PostgreSQL 13 and later and mean_time before that. Then run EXPLAIN (ANALYZE, BUFFERS) on the worst offender and look for a sequential scan over a large table. Reading EXPLAIN ANALYZE walks through what the output means.

Connection exhaustion. Every framework opens a pool, every worker has a pool, and max_connections is finite. Ten app instances with a pool of 20 each is 200 connections requested against a default that is often 100, and each PostgreSQL backend costs several megabytes of memory before it does anything. The symptom is FATAL: sorry, too many clients already and an application that appears to hang. Size the pools to the database, not to the application: total pool size across all instances should sit comfortably below max_connections with room for your own psql session. Connection pools and limits is the whole story, and tuning PostgreSQL on a small server covers the settings that matter at 1-8 GB.

Writes that serialise. A counter column that every visitor increments, a session row updated on every request, an analytics insert in the request path. These do not show up in testing with one user and turn into lock contention with a thousand. Move anything that does not have to be synchronous out of the request: a queue, a background worker, or an append-only log you aggregate later. Background jobs on a small server covers doing that without new infrastructure.

Assets, images and the bytes nobody budgets#

The single most common cause of a slow launch page is not the server. It is a 4 MB hero image, uncompressed, served at full resolution to a phone. Twenty thousand visitors times 4 MB is 80 GB of transfer for one decorative photograph, and every one of those visitors waited for it.

Before the day:

  • Resize images to the largest size they are actually displayed at, and serve modern formats.
  • Turn on Gzip or Brotli for text responses. It is one directive and typically cuts HTML, CSS and JavaScript by 70-80 per cent.
  • Give every build artefact a content hash in its filename so that it can be cached permanently.
  • Put fonts on your own origin with font-display: swap rather than blocking render on a third party.
  • Remove the analytics and chat widgets you are not using. Each one is a DNS lookup, a connection and a script on the critical path.

None of this is hosting, which is exactly why it is worth saying here: a plan upgrade does not make a 4 MB image smaller. TTFB, Core Web Vitals and hosting is honest about which of these numbers hosting moves and which it does not.

Load test it before the day#

Testing with a real tool takes twenty minutes and is the only way to know any of the above worked. Test against a staging copy with production-shaped data, not against an empty database, because the whole point is to find the query that only hurts at scale.

For a quick check of one endpoint:

bash
$ oha -z 60s -c 50 https://staging.example.com/$ hey -z 60s -c 50 https://staging.example.com/

For a realistic ramp, k6 is worth the extra file:

javascript
import http from "k6/http";import { check, sleep } from "k6";export const options = {  stages: [    { duration: "1m", target: 50 },    { duration: "3m", target: 200 },    { duration: "1m", target: 0 },  ],  thresholds: { http_req_duration: ["p(95)<500"] },};export default function () {  const res = http.get("https://staging.example.com/");  check(res, { "status is 200": (r) => r.status === 200 });  sleep(1);}

Read three things from the result, in this order: the error rate, the 95th percentile duration, and where the curve bends. The interesting number is not the maximum throughput. It is the concurrency at which p95 starts climbing, because that is your real ceiling, and it is usually a long way below the point at which requests start failing. Keeping a staging copy around for this is cheap, and staging and production on one account covers doing it without confusing the two.

Have the upgrade path, not the upgrade#

Buying four times the plan for a day you cannot predict wastes money in both directions: you pay for capacity you did not need, and you still have the unindexed query.

The better position is to know exactly what you would do and how long it takes. Write it down before the day:

  1. Which tier you would move to, and what it costs.
  2. How long the move takes and whether it restarts the process. Changing plan does not rebuild the server, so the files, the database slot and the domain stay exactly where they are.
  3. What you would turn off first if you had to shed load: the search endpoint, the live counter, the recommendation widget. A feature flag you can flip in ten seconds is worth more than a tier you cannot buy in under a minute.
  4. What your static fallback looks like. An error_page 502 503 504 /maintenance.html; with a real page behind it is a better outcome than an nginx default.

App and web plans include a reverse-proxy slot, so the domain and its certificate are already pointed at the server and renew themselves; the real client address arrives in X-Forwarded-For, which matters if you are rate limiting by IP. In Express that means app.set("trust proxy", 1) before any rate limiter, or every visitor will look like the proxy. What a reverse proxy does explains the rest of the header handling.

What to watch on the day, in order#

Have these open before the first visitor arrives, and read them top to bottom rather than staring at whichever one is prettiest.

SignalWhereWhat it means if it moves
Error rateApplication log, proxy logThe only number that is always bad
p95 response timeProxy log or your APMRising before errors do; your early warning
Worker saturationpm.status_path listen queueRequests waiting for a process, not for code
Database connectionspg_stat_activity countApproaching max_connections means a hang next
CPUPanel graphPinned at your share is slow, not broken
MemoryPanel graphThe floor rising, not the peak, is the warning
DiskPanel graphLogs and image uploads fill faster than you expect

CPU at 100 per cent is not an emergency by itself. It is a hard throttle to the share you bought, so a server sitting there is slow rather than broken, and it is never suspended for it. Memory reaching the limit is different: the container is stopped and restarted clean rather than left to swap, which for a web application means dropped in-flight requests. Watch the memory floor between spikes, because that is the number that tells you whether you are actually growing or just busy. Reading a server load graph covers the shapes worth acting on.

FAQ#

How much traffic can a small plan handle?

More than most people expect once pages are cached, and far less than they expect if they are not. A 1 GB plan serving a ten-second-cached marketing page will handle hundreds of requests a second. The same plan rendering that page from the database on every request will struggle past twenty.

Should I upgrade before launch just in case?

Only if you have measured something. Load test first: if p95 stays flat at three times your expected concurrency, the plan is not your risk. If you are genuinely unsure and the launch matters, one month at a higher tier is cheap insurance, but do the caching work anyway or you will just move the failure.

Why does the site get slower before it errors?

Because requests queue. When workers are all busy, new requests wait rather than fail, so latency climbs while the error rate stays at zero. That gap is your warning window, which is why p95 response time is the signal to watch and error rate is the signal that you missed it.

Is a CDN necessary for a launch?

Not necessary, but it is the cheapest capacity you can buy for a spike, because it removes almost all of the static requests before they reach you. If your audience is spread across continents it also removes a chunk of latency that no plan upgrade can touch.

What breaks first when a web app runs out of memory?

On a container that hits its limit, the process is stopped and restarted clean, so everything in flight is lost and any in-memory session store is emptied. That is the argument for keeping sessions in a database or a signed cookie before launch day rather than after it.

Can I deploy a fix during the launch?

You can, and a rolling restart behind the proxy makes it invisible, but change one thing at a time and have the previous version ready to go back to. Zero-downtime deploys on a small server covers the mechanics on a single box.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000