Thnkrr: Plug-and-Play Check and Server Load

10 October 2026. An investigation of the code on the dev/pilot branch (API at merge commit 225d7f9, web at 67e8c47). Nothing is built or changed here. This is findings plus a plan that waits for approval.

It answers three questions:

  1. Can every outside supplier be swapped by changing settings and copying data, without rewriting the app and without rewriting the rows already in the database?
  2. Is the code modular, and are the database tables kept apart cleanly enough that one part can be replaced without dragging the others along?
  3. What does each student action cost the server, what happens at 3,000 to 5,000 daily active users, and where can the load be cut?

The companion files are PROVIDER-SWAP-RESEARCH-2026-10-09.md (which supplier could replace which, with prices) and PRODUCTION-PLAN-2026-10-09.md. This document looks at the code itself, which those two did not do in detail.


The one-minute version


Words used in this document

WordWhat it means here
Supplier (provider)A company whose service we rent: Amazon for servers, files, email and the AI check
Connector (adapter)A small piece of our code that is the only part that talks to one supplier. Swapping suppliers means writing or choosing one connector, not changing the app
Plug and playA supplier can be changed by a settings change plus a data copy, never by rewriting the app
SettingsValues the app reads when it starts (addresses, names, keys). A swap that is "a settings change" needs no new code
SeamThe line in the code where a connector meets the rest of the app. A clean seam means nothing on the app side knows which supplier is behind it
LeakA place where supplier-specific knowledge has crept past the seam into the app
TableWhere one kind of record lives in the database: students, posts, reviews. Our database is Postgres, an open standard
Data migrationRewriting the rows already in the database into a new shape. The thing we want never to need when swapping a supplier
Schema migrationAdding a table or a column. Normal, cheap, and not what this document worries about
ModuleOne folder of code for one job (feed, search, moderation). The question is whether a module can be changed without touching the others
RequestOne call from a student's browser to the server (open the feed, like a post)
QueryOne question the server asks the database. At our size, the server's cost is mostly counted in queries
N+1The server asks the database once for a list, then once more per item in the list. Fine for 10 items, a problem for 100
IndexA lookup table the database keeps so it can find rows without reading the whole table. A missing index means a full read every time
PollThe browser asking the server "anything new?" on a timer, even when the student does nothing
Daily active users (DAU)People who open the app at least once in a day
Background jobWork the server does on its own clock: the AI check, photo resizing, nightly clean-ups
SweepA background job that runs on a timer and looks for things to do
In memoryState kept inside the running server program: lost on restart, invisible to a second server
Connection poolThe fixed number of database connections one server keeps open (ours: 10). Every query waits for a free one

1. What we checked and how

Every source file of the API (118 files, about 30,000 lines) and the web app on dev/pilot was read, by hand or by three parallel read-only audits. For each outside supplier we asked: is there one interface, how many implementations, is a fake used in tests, and what does the database store about it. For each route a student or a reviewer hits we counted the database queries, looked for loops that query per row, for missing indexes, and for timers in the browser. For each background job we counted what it does every tick when there is nothing to do.

Numbers come from reading the code, not from running it under load. The projections in section 8 use stated assumptions and are estimates to within a factor of two.


2. Outside services, one by one

"Swap" says what a change of supplier takes with the code as it is today. "Rows" says whether the database would need rewriting.

ServiceConnector in codeImplementationsSwap todayRows to rewriteVerdict
Photo and video filesYes, Storage interface (src/lib/storage.ts)One, speaking the S3 language (Amazon S3, MinIO, R2, B2, Google all fit)A settings change for any S3-speaking companyNone. Tables store file names only; the columns are called s3_key_… but hold names, not addressesGood
Delivery-network purge (CloudFront)Yes, CdnPurger interface (src/lib/cdn.ts)One: CloudFront. A do-nothing version when unsetA new one-file adapter per network (Cloudflare, Bunny)NoneGood seam, one adapter
EmailYes, Mailer interface (src/lib/mailer.ts)One, plain SMTP (works with SES, Postmark, Mailgun, Zoho). Logs instead of sending when unsetA settings change plus the new sender's DNS setupNoneGood
AI writing checkYes, Classifier interface (src/modules/moderation/classifier.ts)Three: Amazon Bedrock (production), Anthropic (laptop only, refused in production), dev stub. One chooser (select.ts), one throttle wrapperModel: one setting. Another Bedrock-like host: a new adapter (the research proposes a generic "OpenAI-compatible" one)None. Each verdict is stored with the model's name beside itBest seam in the codebase
Speech-to-textYes, Transcriber interface (src/lib/transcriber.ts)One: whisper.cpp on our own server. None when no model file is setA new adapter for Amazon Transcribe or similarNoneGood, with one wrinkle: it is created inside the video code from settings, not handed in like the others, so tests cannot swap it
Video conversionNo connector. src/modules/media/video.ts runs ffmpeg and ffprobe directlyOne (ffmpeg, open software)Moving to a cloud converter means rewriting that one 380-line fileNoneAcceptable for now; it is open software, not a supplier
Picture checkDoes not exist. Docs 04, 07, 14, 18 and 19 describe an imageScreen connectorNone. Every photo waits for a personBuild the seam (1 day) and one adapter (1 day each)NoneDocuments promise something the code lacks
Guardian adult checkYes, AdultCheckProvider interface (src/modules/consent/adult-check.ts)One dev fake; "none" in productionA DigiLocker adapter when neededNone. The row stores "provider:reference"Good
Background job queueYes, Jobs interface (src/jobs/boss.ts)One: pg-boss, tables inside our own Postgres. An inline mode for testsAnother queue = a new adapter; pg-boss moves with the databaseNoneGood
DatabaseDrizzle over plain PostgresAny hosted Postgres 16A settings change plus a copy. Needs the pg_trgm and citext add-ons, generated columns and advisory locks, all standard PostgresNot applicableGood. Not swappable for a non-Postgres database, by design
Picture hashingOur own code (src/lib/phash.ts, dHash via sharp)OneNot a supplierYes, if the algorithm ever changes: the media.phash column would need recomputing from the originalsNote only
Rate limits and brakesNo seam. In the memory of one processOneA second server silently doubles every limitNoneMust change before a second server (section 6)
Secrets and settingsEnvironment variables, checked at start (src/config.ts)OneAny host that sets environment variablesNoneGood
Deploy, website hosting, logsShell script, systemd, Caddy; Amazon Session Manager per docs/27OneCovered in the provider-swap research: not yet portableNoneOut of scope here

Leak check. No Amazon, Bedrock, S3, CloudFront, whisper or SMTP library is imported outside the connector files, with one exception: the sharp image library is used directly in the media and evidence modules and the seed scripts. That is fine; it is open software doing image work, not a supplier. The web app contains no supplier name at all. Its only assumption is that a video upload returns an ETag header, which every S3-speaking service does.

Where connectors are created. The app hands four connectors to every module through one shared context object: database, jobs, mailer, classifier, storage. Three others are built on the spot from settings inside the module that uses them: the transcriber, the purge, the adult check. It works, but it is inconsistent, and the on-the-spot ones cannot be faked in a test. Cheap to unify.


3. The database: are the tables well separated?

Yes. The 55 tables live in ten files, one per area: identity, profiles, social, moderation, mentorship, safety, care, consent, handles, school pages.

What keeps them apart:

A supplier swap never requires a data migration. Checked for files, email, the AI check, the delivery network, speech, the adult check and the queue.

Debt worth clearing, none urgent:

ItemWhereWhy it matters
A deprecated column still presentschools.approved_domains (replaced by the school_domains table in migration 0023)Nothing reads it; a future reader may think it is live
Five unused tables from the first profile designactivities, honors_awards, projects, courses, volunteeringClutter in every backup and schema diagram
Old values kept in three listsprofile visibility (network, mentors_included), commitment status (enrolled), verificationPostgres cannot drop a list value; harmless, but they must never be written
Column names say "s3"media.s3_key_original, media.s3_key_publicA name only. Renaming is cosmetic and costs a migration; leave it

4. Is the code modular?

Reasonably, with a hub. Nineteen modules under src/modules, each with its own routes and service files, all talking to the outside world through the shared context.

MeasureFinding
Modules that import no other moduleidentity only
The widest moduleprofiles imports nine others (graph, identity, mentorship, moderation, notifications, onboarding, people, schools, search)
Two-way dependenciesprofiles and mentorship; profiles and schools; profiles and onboarding; moderation and notifications
Intended shared piecespeople (who may see whom, name cards), notifications (every feature asks it, it decides), moderation (every piece of writing passes through it)
The biggest filemoderation/service.ts, 2,317 lines, 15 raw SQL statements. Everything that is written by a student goes through it
Shared typesThe @thnkrr/contracts package defines every request and response shape. The API uses it. The web app does not: it keeps a hand-copied 1,750-line mirror and relies on the server rejecting drift
Tests of the seamsIntegration tests run with an in-memory file store, a capturing mailer, a scripted AI and an inline job queue. The seams are proven, not just drawn

What this means in practice: any supplier can be replaced without touching modules; any module can be changed without touching suppliers; but pulling one module out into its own service (for example running moderation on its own machine) would take real work because of the two-way links. Nobody has asked for that, and the pilot does not need it.


5. Server load today: what each action costs

Every signed-in request first pays a fixed cost: three database queries to turn the cookie into a person (session, then user, then roles). The cookie itself is not checked by signature, only by lookup. Counts below include those three.

What a student does

ActionRequestsDatabase queriesAI callsOther work
Open the feed page7 (feed, own profile, mentorships, suggestions, notifications, messages, who am I)About 60 in total; the feed alone is 8 to 10 plus one per video post0None
Load more feed18 to 100None
Open a post112 to 160None
Open someone's profile116 to 20, plus 4 to 5 more on the first view each day. This GET writes a view row and possibly a notification0None
Open own profile212 to 19, strictly one after another, including one lookup per profile field for appeals0None
Search people, per keystroke111 to 210Gated: at most 6 searches at once per server, 2-second cap per query
Open messages list16 plus one per peer thread, and one query that reads every message the person has ever exchanged to pick one preview per thread0None
Open a thread16 to 90None
Write a post115 now, 13 more in the background1Stage-1 rules: about 13 fixed patterns plus two per editable term, run twice
Write a comment113 now, 21 to 22 in the background (the post, the last 50 comments read, 5 sent to the AI, then 7 queries to notify)1As above
Send a peer message114 now, 27 to 28 in the background. The same message row is re-read four times and the conversation four times1As above
Like a post1110None
Upload a photo3 plus a direct upload to storage5 to 6 in the background0Two full image decodes (resize, then hash), one resize, three storage calls, and a read of every row in the picture blocklist for a nearest-match loop in code
Upload a video2 plus chunked direct upload4 in the background0 (the transcript joins the post's text check)Up to 8 child processes: probe, one conversion pass making both sizes, poster, storyboard, audio extract, speech-to-text. The picture is decoded 3 to 4 times. All on 2 processor threads at low priority

The AI prompt is about 1,040 words-worth of policy sent in full on every call (no caching on the Bedrock path), plus the text and up to 10 prior messages for a thread. One call per item, not per field: a post's body, link, photo captions and transcript are joined and checked once.

What the browser does on its own

What is polledHow oftenWhoQueries per poll
Notifications list and unread countEvery 30 s, every page, while the tab is in frontEveryone signed in7 to 10
Messages listEvery 30 s, every pageEveryone signed in9 plus the unbounded preview read
Who am IOn every return to the tab, for any list older than its cacheEveryone10 to 15
Own post, comment, request or profile while it is "being checked"Every 2 to 2.5 sThe author8 to 19
"Checking is busy" bannerEvery 15 s while something waitsThe author3 (plus a 5-second cache)
Review queueEvery 5 sEach reviewer tab3 plus 2 to 7 per queued item and 1 to 4 storage presigns per item, all at once: several hundred for a full queue
Review statsEvery 10 sEach reviewer tab, on every pageA count over the whole reviews table with no filter
Reports, cases, grievances, orders, takedowns, care, activityEvery 5 to 10 s, all mounted at once whatever tab is openEach reviewer tab1 plus 3 to 4 per report; activity reads 600 audit rows with no index

Polls pause when the tab is hidden, with three exceptions that keep running: the photo editor's refresh, the consent waiting screen (every 5 s, unbounded) and the upload status loop.

What the server does with nobody around

JobHow oftenQueries when idleNotes
Job queue polling (pg-boss)ContinuousAbout 6 a second, 500,000 a day, per serverSeven plain queues poll every 2 s, two pooled queues every 1 s, plus pg-boss's own housekeeping
Moderation sweep (stale reviews, care reminders, deadline alerts, suspensions)Every minute8The suspension check reads the whole users table every minute: no index on status or end date. Each open safety item also gets 1 to 3 writes per minute for as long as it stays open
Consent sweepHourly4Three of the four read whole tables (no index on consent status or user status)
Nightly sweep (expire requests, delete old profile views)Daily3The delete has no index on the day column and returns every deleted id into memory
AI keep-aliveDaily1One Bedrock call per model, outside the throttle

Missing indexes that the hot paths want

TableLookup with no indexWho hits it
content_reviewsby surface and subject id (9 places in code)Post page, thread page, requests page, school admin page, reports
mediano index at all beyond the keySchool admin page (two full reads per poll), photo processing
audit_logby time and actionReview activity panel, every 10 s
usersby status, by suspension end, by roleThe every-minute suspension sweep, the mentor directory, the school overview
profile_viewsby dayThe nightly delete
posts and messagesthe sort expression "published or created time"Feed and threads sort every candidate row before cutting to 20
parental_consentsby statusThe hourly consent sweep

Numbers in settings that do not fit a 2-processor, 4 GB server

SettingValueProblem
AI checks in flight at once300Each holds one of only 10 database connections for its two transactions. 300 cannot fit in 10; the rest queue, and any request that waits more than 5 s is answered "busy"
AI checks started per second150Sized for 10,000 students posting in one minute. At that rate the follow-up database work is 1,800 to 4,200 queries a second through 10 connections
Bedrock sockets per server310Harmless, oversized
Photo jobs at once2Right for 2 processors, but video, speech-to-text and the web server share the same two

6. Things that assume one server

All of these live in the memory of the process. A second API server would not know about the first.

WhatEffect with two servers
Request rate limit (300 a minute per person)Becomes 600
Posting brake (10 a minute per person)Becomes 20
Search gate (6 at once)Becomes 12 against the same database
AI throttle (150 a second)Becomes 300 against the same Amazon quota
"Checking is busy" cache, editable-term cache, website index cacheBriefly out of step between servers; harmless
Job wake-up on sendA job sent on server A starts on server B only at B's next 1 to 2 s poll; harmless

Already safe for many servers: item locks (Postgres advisory locks), deadline alerts (unique rows), care reminders (conditional updates), scheduled jobs (pg-boss runs each once).


7. At 3,000 to 5,000 daily active users

Assumptions (change any of them and the numbers scale in proportion): 5,000 people a day; a school day of about 8 hours; 10 to 15 percent of them with the app open at the same moment at peak, call it 600 tabs; each person opens about 15 pages, writes 1 to 2 things, opens 1 profile and uploads a photo one day in ten; 50 videos a day across all schools; 5 reviewers working with a queue of about 50 items.

Database questions per second at peak, with today's code

SourcePer secondShare
Browser polling (notifications and messages, 600 tabs, every 30 s, about 20 queries a pair)~40040%
Reviewer tabs (5 tabs, queue every 5 s at ~250 queries, stats, lists)~30030%
Page opens (about 5 a second, ~40 queries each)~20020%
Job queue polling and sweeps~61%
Writing posts, comments, messages (7,500 a day, ~35 queries each)~91%
Profile views, likes, searches~303%
Total~950

Three quarters of that is the three sign-in queries on every request plus the two polls and the reviewer fan-out. A small Postgres can answer 950 simple questions a second, so this does not fall over on day one. It gets worse with time, not with users:

What breaks first, in order: the reviewer pages (they slow down as the reviews table grows, and they are polled every 5 s); then the 10-connection pool during a lunchtime posting burst, when 300 AI checks each want a connection and students start seeing "busy"; then the database's burst credits on the smallest instance size.

Processor time on the API server

WorkPer dayNote
Answering requests (about 50 to 80 a second at peak)Comfortable on 2 processors; Fastify handles thousands a secondThe JSON and regex work of 300 concurrent AI checks runs on the same event loop
Photos (500 a day, about 1 s of processor each)About 10 minutesFine
Videos (50 a day, 1 to 3 minutes long on average)4 to 7 hours of one processor: conversion at low priority plus speech-to-text, each about real time or slower on 2 threadsShares the two processors with everything above during school hours

Outside services and money

ServicePer dayPer month, list price
AI checks on Bedrock (gpt-oss-safeguard-20b)7,500 calls, about 9 million input tokens, under 1 million outputAbout $25
Storage reads and writesA few thousandUnder $1
EmailA few hundredUnder $1

The AI is not the cost driver at this scale. The database and the one server's processor are where the ceiling is.


8. How to reduce the load, ranked by payoff

Each row says what changes for a student, roughly how much it saves, and how big the change is. None is built. None changes any supplier.

RankChangeWhat it savesSize
1Sign-in check: one query instead of three, and a short memory cache. Keep the session, user and roles in one query; remember the answer for 30 to 60 s per server so polls do not pay it at all. Revocations and suspensions still take effect within a minute, which the sweep already assumesTwo queries off every request: roughly a third of all peak load1 day
2Polling: one small "badges" call instead of two full lists. The 30-second tick fetches an unread count and an unread-messages count (2 queries) instead of the full notifications list and the full messages list. Raise the tick to 60 s. Later, a push channel (server-sent events) replaces the tick entirelyAbout 350 of the 400 polling queries a second1 to 2 days for the badges call; 3 to 4 days for push
3Reviewer pages: no fan-out, cheap stats. Fetch the queue list only; load an item's context, previews and presigned links when the reviewer opens it. Keep a running count of pending and escalated items instead of counting the whole table. Mount a tab's list only when the tab is open. Add the audit-log time indexNearly all of the 300 a second from reviewer tabs, and the part that grows with the table2 to 3 days
4Messages preview: one row per thread. Ask the database for "the latest message in each of these threads" (a lateral join with limit 1) instead of every message everA query that grows with history becomes a fixed small oneHalf a day
5Seven indexes from the table in section 5 (reviews by surface and subject; media by owner and by school, kind, status; audit log by time; users by status and suspension end; profile views by day; posts and messages by their sort expression; consents by status)Every lookup listed stops reading whole tables. Removes the "worse with time" cliffHalf a day; one additive migration
6Run the media and AI workers in a second process. A ROLE=api or ROLE=worker setting: the API process serves requests, the worker process runs jobs. Both already share one database and one job queue; the registration is a single call todayVideo conversion and speech-to-text stop competing with students for the two processors. On AWS this is the same box with two services, or a second small box1 day, plus the deploy change
7Fit the AI numbers to the pool. In-flight checks 20 to 30 and 10 to 20 a second for the pilot, raised with evidence; or a pool of 20 and an in-flight cap tied to it. Same throughput for any real burst, no "busy" errorsRemoves the lunchtime "busy" failure modeAn hour; settings only
8Feed and thread trims. Fetch all video rows on a page in one query; drop the "every active user" sub-query from the comment counts; stop re-reading the same message and conversation four times in the publish path10 to 20 queries per feed open and per message1 day
9Slow the idle job polls. Cron-only queues (sweeps, keep-alive) can poll every 10 to 30 s instead of 2 sAbout 3 of the 6 idle queries a second; also lets the database sleep at night on a burstable instanceAn hour
10Photo blocklist match in the database. Store the hash as a 64-bit integer and let Postgres compute the bit distance, instead of reading every blocklist row into code per uploadGrows with the blocklist today; fixed afterHalf a day

After ranks 1 to 5, the same peak is roughly 200 database questions a second, dominated by real page opens. That fits the smallest RDS size (db.t4g.micro) with headroom; db.t4g.small is the safe choice if the reviewers' tables keep their full history online.


9. The plan

Nothing here is built. Order is by payoff and by what unblocks the next step. Effort is agent days.

Phase A: load, before more schools (about 5 days)

  1. Sign-in check to one query plus a short cache (rank 1).
  2. The seven indexes (rank 5).
  3. Messages preview as one row per thread (rank 4).
  4. Badges call and 60-second tick (rank 2, first half).
  5. AI concurrency and pool numbers fitted to the pilot (rank 7).
  6. Idle job polls slowed (rank 9).

Gate: a load replay (the existing moderation:load script extended with feed, notifications and messages traffic at 600 simulated tabs) shows under 300 database queries a second.

Phase B: reviewers and workers (about 4 days)

  1. Reviewer queue without fan-out, running counters for stats, lists mounted per tab (rank 3).
  2. Worker role split (rank 6), with the deploy script starting two services.
  3. Feed and publish-path trims (rank 8).

Gate: a full queue of 100 items opens in under half a second; a 15-minute video converts without any student request waiting on the database.

Phase C: plug-and-play gaps (about 5 days)

  1. Picture-check seam with a "send to a person" default and one adapter (the research recommends starting with Google Vision SafeSearch or Azure Content Safety on data terms).
  2. Transcriber, purge and adult check handed in through the shared context like the others, so each can be faked in tests.
  3. A second purge adapter (Cloudflare) beside CloudFront.
  4. A video-conversion interface around the ffmpeg calls, with ffmpeg as the one implementation, so a cloud converter is an adapter, not a rewrite.
  5. The web app consumes @thnkrr/contracts directly instead of a hand-copied mirror.

Gate: every connector has a fake, a conformance test and at least one real implementation.

Phase D: before a second server (about 3 days)

  1. Rate limit, posting brake, search gate and AI throttle moved to a shared store. Postgres can hold them (a small counters table with the advisory-lock pattern already in use); no Redis needed at this scale.
  2. Push channel for notifications and messages (rank 2, second half).

Gate: two API processes behind one address pass the integration suite with the same limits as one.

Schema clean-up (any time, half a day)

Drop the deprecated domains column and the five unused tables in one additive-then-drop migration pair, after a backup.


10. The founder's answers


11. What was built (10 October 2026)

Everything in phases A, B and C plus the schema clean-up, on branch feat/load-and-seams in both repos. Phase D (a second API server) was left out by decision. Pull requests into dev/pilot: thnkrr-api #18, thnkrr-web #16.

Measured results

Database questions per call, warm, on a busy test account. "Before" was measured on dev/pilot before any change.

WhatBeforeAfter
A tab's 30-second refresh of the red dots311
Messages list247
Review queue, 40 items11610
Who am I136
Feed118
A post's page1512
Review stats52
BurstBeforeAfter
Five reviewers refreshing a 40-item queue ten times5,800 questions500 questions
An idle feed tab over five minutes, in a real browser20 requests, both full lists10 small badge requests

In the browser, pages open in 130 to 290 milliseconds and taps answer in 100 to 150 milliseconds on the busy account. Nothing froze for more than 70 milliseconds.

One audit item turned out wrong

The audit said 300 AI checks in flight would starve the 10 database connections. A real burst of 2,000 posts said otherwise: student reads stayed at 1 to 2 milliseconds and nobody was told "busy", because a check doesn't hold a connection while it waits for the AI. Lower settings only made the burst five times slower, so the settings were left alone.

A second item was withdrawn: the review page loads every staff list because its main tab is one merged to-do list built from all of them. The real cost was in the queue's own lookups, which are fixed.

What changed, in plain words

Tests added

Final runs: API 700 of 700, browser 145 of 145.

Before deploying


Sources

Files examined on dev/pilot:

●  Have your own?

// Publish your own markdown.

Write your own