Skip to content

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-limited r2.dev hostname 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 is memory = "1gb" in backend/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.

Sources