Estudador
AI-generated practice questions, checked against the source curriculum before a student ever sees them.
THE SHORT VERSION
Exam practice needs volume, and volume is where the economics break. Generating questions with a language model is easy; making sure the answer key is right is not, and paying for every single one is worse. So generated questions go into a pool that belongs to nobody, and the model is only called when someone exhausts it — which fixes the cost of a subject at its heaviest user and leaves every student after that one close to free. Today 43.5% of exercises are served straight from the pool at no cost, and that share climbs as the pool fills. This version has been in production for about two months.
- WHEN
- 2025 — present
- MY PART
- Architecture, backend, frontend, payments, and the server it runs on.
- BUILT WITH
- Next.js · NestJS · MongoDB · Clerk · Asaas API · DeepSeek API
CONTEXT
Estudador gives students practice questions. You pick a level, a subject and a topic, it hands you an exercise, then marks it and explains the answer.
Generating questions with a language model is the easy part. A wrong answer key is the real product risk here, not latency — a student who learns something incorrect is worse off than one who waited half a minute. And every generated question costs money, so the obvious design pays the model again every time a second student asks for the same thing. Most of the engineering below exists because of that second problem.
WHAT CONSTRAINED IT
- I am the only engineer on it, which means every design has to be one I can also operate.
- It sells at R$24 and R$48 a month, which leaves very little room for a per-question API bill. The cost model had to be part of the architecture rather than something I looked at later.
- I decided not to do behavioural advertising or build user profiles. That's the legal basis the product runs on, so the code has to keep the promise — it isn't a paragraph in a policy page.
HOW IT WORKS
WHAT IT LOOKS LIKE


DECISIONS
- 01
The question pool has no owner
Generated questions go into a shared pool, so the model is only called when somebody exhausts a subject — which fixes that subject's cost at its heaviest user.
The only personal filter when serving a question is whether that particular student has already seen it. With m students of similar appetite in the same subject, the reuse rate is 1 − 1/m: two students reuse half, ten reuse ninety percent, a hundred reuse ninety-nine.
Production currently serves 43.5% of exercises straight from the pool. At this size, with students spread thin across many subjects, that is roughly what the formula predicts — which is the reason to measure it rather than assume it.
- INSTEAD OF
- A pool per student
- WHAT IT COST
- Two students in the same subject can be handed the same question, so anti-repetition has to be tracked per person instead of being a property of the pool. And a subject with exactly one student never reuses anything — that tail is the only place where the AI bill gets close to half of what the plan brings in. I mitigate it by not fragmenting the catalogue, which is a product decision, not an infrastructure one.
- 02
Warm the pool while the student is still answering
After serving a question I start generating the next one and don't wait for it, so the student's next question comes out of the pool in under a second instead of twenty-six.
This costs nothing in quota and can't collide with the one-exercise-at-a-time lock, and not by accident: the daily quota counts solutions, and the lock is a unique index on a field that only exists while a solution is pending. Pre-generation creates neither. Not creating a solution is what makes it free.
- INSTEAD OF
- Generating on demand every time
- WHAT IT COST
- I pay for questions nobody may answer. I took that trade because the pool is shared — a student who abandons the session still leaves a piece behind that serves whoever asks next, including themselves tomorrow.
- 03
Recent questions go in the user message, never the system prompt
The examples that stop the model repeating itself go in the user message, because putting them in the system prompt would break the provider's cache and multiply the input cost by around thirty.
The provider caches the prompt prefix, and the cache only works if the system prompt is byte-for-byte identical between calls. Anything that varies per request has to live after it.
- INSTEAD OF
- One prompt with everything in it
- WHAT IT COST
- The instructions live in two places, which is less obvious to read six months later.
- 04
One usage row per call to the provider, not per exercise
Every trip to the model writes its own row, so what the retries cost shows up instead of disappearing into an average.
Cost is stored as an integer in micro-dollars, because the provider's price table is already micro-dollars per token and the conversion is exact.
The price table is a list of eras with effective dates, picked by the date of the call. A price change lands on its own at the right moment with no deploy timed to it, and a row from last month stays explainable by the table that was in force when it happened.
- INSTEAD OF
- One row per exercise, with the cost averaged
- WHAT IT COST
- More rows, and the reported figure is higher than a per-operation average would show. That is precisely the reason to do it, but it remains the cost.
- 05
Ads are contextual, derived on the server
Ad targeting is derived on the server from the exercise the student just answered, which meant I never had to store a profile — or even their education level.
The browser asks for a slot with the id of that exercise and nothing else. From it the server gets three things at once: the education level to target, whether the exercise belongs to the person asking, and a key to deduplicate the impression.
The solution they just answered already says what level they're at, at the exact moment I need to know it, so there was no reason to keep a field on their account saying the same thing.
- INSTEAD OF
- Targeting from a stored profile
- WHAT IT COST
- Targeting is coarser than a profile would give, and worth less to an advertiser. In exchange I keep less data and the legal basis holds without a consent banner.
WHAT CAME OUT OF IT
- 43.5%
- of exercises cost nothing to servereuse rises as the pool fills
- 0.97
- model calls per question served
- 12.1%
- call failure rateretries are counted, not averaged away
- 70%
- of tokens are outputthe model's reasoning is the cost driver
WHAT I WOULD DO DIFFERENTLY
There are no automated tests. What holds the system together is database invariants, schema validation on the model's output, strict typing and a few assertion scripts. It is the first thing I would add.
There's no queue and no scheduler — background work is fired and forgotten inside the process. That's why pre-generation can't move to off-peak hours, which is the largest cost saving still on the table.
The failure I return to most often is the pre-generation guard, which began with "if this question came from the pool, the subject is warm, skip". But pre-generation produces exactly one question, and the reused one was usually that question. Consuming it emptied the pool, and the guard read its own previous success as a reason not to refill. A student alternated between an instant question and a twenty-second one forever. A cache guard has to measure what's left, never where the last one came from.