Limits¶
Two kinds: the ones we chose, and the ones rented from somebody else. The second kind has shaped real decisions in this project, so this is the page to read before deciding something, not after.
External figures checked on 2026-08-21. Free-tier terms change; the numbers below are a starting point, not a contract.
The ones we chose¶
Most of these belong to the circle, not the server: they are columns on it, chosen at creation and changeable by its owner, and the API accepts a range.
| Limit | Default | Accepted | Where |
|---|---|---|---|
| Finished vlog length | 120 s | 30–600 s | max_total_seconds, per circle (CircleCreate) |
| Single take | 20 s | 5–60 s | clip_max_seconds, per circle (CircleCreate) |
| Cadence | 7 days | 3, 5 or 7 | cadence_days, per circle (CircleCreate) |
| Premiere | 24 h | 1–168 h, on update | premiere_hours setting at creation, then per circle (CircleUpdate) |
| Raw footage allowed | 1.05× the finished length | recording_allowance_factor |
|
| Upload, whole vlog | 200 MB | max_upload_bytes |
|
| Upload, one raw take | 100 MB | max_clip_bytes |
|
| Raw takes staged for one turn | 600 MB | 3 × max_upload_bytes |
|
| Comment | 200 characters | COMMENT_MAX_LENGTH in app/schemas/schemas.py |
The per-circle defaults live in the request schema (app/schemas/schemas.py),
not in settings. DEFAULT_MAX_TOTAL_SECONDS, DEFAULT_CLIP_MAX_SECONDS and
DEFAULT_CADENCE_DAYS exist as settings but nothing reads them; #170 is
settling whether they go. A new circle's premiere window does come from the
premiere_hours setting, and only an update can move it within 1–168.
The app offers less than the API accepts: lengths of 60, 90, 120, 180 or 300
seconds and cadences of 3, 5 or 7 days (mobile/src/state/circleOptions.ts).
The allowance being larger than the result is deliberate: recording more than fits is expected, and the edit trims the last take and says what did not make it.
The staging cap is what a server-side render may be handed for one turn: past
600 MB of takes, the next one gets a 413. Uploading the same take again
replaces it rather than counting twice, so a retry near the cap is not refused.
Server-side render¶
| Limit | Value | Where |
|---|---|---|
| Renders running at once | 1 | max_concurrent_renders |
| Wait for a free slot | 300 s, then 503 |
render_queue_timeout_seconds |
| One ffmpeg/ffprobe process | 600 s | ffmpeg_timeout_seconds |
Why one, and what happened at two, is in load-testing.md.
Client timeouts¶
What the app gives up after, in mobile/src/api/:
| Request | Timeout |
|---|---|
| Any API call, by default | 30 s with no response |
render-fallback |
600 s — it can queue behind another render first |
A file upload (fileUpload.ts) |
120 s with no progress, however long it takes overall |
| Forgetting the push token on sign-out | 5 s — best effort, and signing out waits on it |
Rate limits we impose¶
Per client address, in memory, in app/api/ratelimit.py.
| Route | Limit |
|---|---|
POST /auth/login |
10 per 5 min |
POST /auth/register |
5 per hour |
POST /circles/join |
20 per hour |
PUT /turns/{id}/clips/{filename} |
200 per hour, across every turn and filename |
POST /turns/{id}/render-fallback |
10 per hour |
POST /turns/{id}/upload-url |
30 per hour |
POST /auth/password-reset/request |
3 per hour |
POST /auth/password-reset/confirm |
10 per hour |
POST /auth/email-verification/resend |
3 per hour |
POST /auth/email-verification/confirm |
10 per hour |
POST /auth/refresh |
60 per hour |
POST /auth/password |
10 per hour |
DELETE /auth/me |
10 per hour |
A route with an id in its path is counted per rule, not per path: staging a
take under a new filename, or for another turn, draws on the same budget
(#158). The other /turns/… routes -- heartbeat, re-roll votes, complete --
are deliberately not limited; the heartbeat goes out after every saved take.
The reset limits are load-bearing rather than precautionary. A request sends mail to somebody else's inbox, which is the one thing an abuser can do here without an account; and ten confirm attempts an hour against 31⁸ combinations is what makes an eight-character code safe enough to type in. Email verification uses the same codes, so it gets the same two limits.
In memory is the important word: the counters live in the process, which is one of the two reasons the app must run as exactly one machine. Two machines would each count roughly half the requests and every limit would silently be double what it says.
The registration limit is easy to hit while testing — five accounts an hour goes quickly when a demo script makes two of them.
Rented limits¶
Neon — free plan¶
| Compute | 100 CU-hours per project per month (≈400 h at 0.25 CU) |
| Storage | 0.5 GB per project |
| Autosuspend | After 5 min idle, cannot be disabled |
| Point-in-time restore | 6 hours |
| Network transfer | 5 GB per month |
Two consequences worth doing the arithmetic for.
The scheduler interval is not a preference, it is a requirement. At 0.25 CU, staying awake all month costs about 182 CU-hours against an allowance of 100. The compute suspends after 5 idle minutes, so a tick every 60 seconds keeps it awake permanently and blows the budget. At 15 minutes it is up for roughly a third of the time — around 60 CU-hours — which fits, though not enormously comfortably. If anything else starts querying on a timer, redo this sum.
/health deliberately touches no database, or the 30-second health check would
undo the whole thing.
A 6-hour restore window is why scripts/backup.py exists. Neon's own
recovery covers a mistake noticed the same morning. It does not cover one
noticed on Monday, and it does not cover the account going away.
Cloudflare R2¶
- No egress charge. This is the reason R2 was chosen over S3: the shape of the product is one upload read by every member of a circle, forever.
- Served from
vlogroulette-media.mintos.space, a custom domain on the project's own zone, so Cloudflare's rate-limitedr2.devhostname is no longer in the path. - Storage is billed by volume and nothing in this app ever deletes a vlog, so it only grows. At 9 MB total today that is theoretical; it is a product decision waiting to be made, not a bug.
Fly.io¶
- One machine,
shared-cpu-1x, 512 MB (about $3.20 a month). The memory is for FFmpeg on the fallback render, not for the API. - 512 MB is a cost decision, not a measured one. It was 1 GB (about $5.70 a
month) until 2026-10-02, and every render measurement in
load-testing.md was taken at 1 GB, where a single render
already used most of it. Halving it was accepted as a risk while the app is
not public: long or many-clip vlogs on the server-side fallback (iOS, today)
may now OOM, or fall back to
_concat_simple's hard cuts more often. If they do, the fix ismemory = "1gb"inbackend/fly.toml, not code. Go back to 1 GB before the app is public. - Not a limit but a constraint: the machine may not be scaled out. See decisions.md.
Expo — EAS, free plan¶
| Builds | 15 Android and 15 iOS per month |
| Concurrency | 1 |
| Build timeout | 45 min |
| Queue | Low priority; waits can exceed 90 min at busy times |
| EAS Update | 1,000 monthly active users |
This is the tightest limit in the project. August 2026 used 17 Android
builds, over the allowance, and a build cycle is nine minutes plus queueing.
It is why npx expo-doctor and the native compile job in CI matter: catching a
mistake locally is free, catching it on EAS costs one of fifteen.
Expo Push¶
Free and unmetered in practice, but it is a best-effort service in front of FCM
and APNs. It answers 200 to a message it is refusing — the verdict is in the
body. Both senders read it: send_now, which exists so scripts/push_check.py
can tell you the truth, and the everyday send, which posts in batches of 100
and hands back the tokens Expo reports DeviceNotRegistered so they get
deleted (#147). Beyond that it is still fire-and-forget: any other refusal is
logged, and a failed push never stops what triggered it.
Sentry — Developer (free) plan¶
| Errors | 5,000 per month |
| Retention | 30 days |
| Seats | 1 user |
| Replays | 50 per month (not used) |
Traces are switched off deliberately: this is about errors nobody sees, not latency nobody has measured. 5,000 errors a month is generous for a handful of users and would be consumed in minutes by a crash loop — if quota disappears suddenly, look for something failing repeatedly rather than assuming the plan is too small.
Firebase Cloud Messaging¶
No meaningful quota at this scale. The constraint is configuration, not volume:
google-services.json is baked into the binary at build time, so changing
Firebase project means a new build.