What “live” means in LearnTerms
LearnTerms is not a static app that fetches data on page load and waits for you to refresh. Every query the app makes against Convex is a live subscription. When the underlying data changes — because a student answered a question, a curator published new content, or an admin reassigned a role — every client displaying that data sees the update immediately, without polling, without manual refresh, and without any additional code on the frontend. This is not a bolt-on feature. It is the default behavior of every data read in the application. There is no “live mode” toggle. There is no special API for real-time versus non-real-time queries. The entire app is live, all the time.Why this matters for a study platform
Most study tools treat data as something you fetch once and display. A student loads their flashcards, takes a quiz, and sees results. If the instructor changes something, the student has to reload. If another student flags a question, nobody sees it until they navigate away and come back. LearnTerms operates differently because the domain demands it:- Admins monitoring cohort progress need to see completion percentages update as students work through modules during a study session. Stale data on a progress dashboard defeats the purpose of having one.
- Students studying a module benefit from seeing their progress stats reflect the work they just did without navigating away. If a student masters a question, the module progress bar should move immediately.
- Curators authoring questions need to see their changes reflected instantly. If a curator adds a question to a module, the module’s question count should update without a page reload — both for the curator and for any student currently viewing that module.
- Badge and achievement systems lose their motivational value if a student earns a badge but doesn’t see it until they refresh. The badge engine runs server-side on every progress mutation, and the award appears on the client as soon as it’s written to the database.
- PDF processing pipelines that chunk documents for AI generation run asynchronously on the server. The admin who triggered the job sees progress updates (chunks processed, current step, completion) in real time, streamed through the same subscription mechanism as everything else.
How Convex makes this work
The subscription model
Convex’s query system is built on persistent WebSocket connections. When the frontend callsuseQuery, the following happens:
- The client sends the query function name and its arguments to the Convex server over an open WebSocket.
- The server executes the query function, reads from the database, and returns the result.
- The server tracks which database tables and indexes that query touched during execution.
- When any mutation writes to those tables or affects those index ranges, the server re-executes the query function automatically.
- If the result differs from the last result sent to the client, the server pushes the new result down the WebSocket.
- The client’s reactive framework (Svelte 5’s rune system) picks up the new value and re-renders affected components.
badges table does not cause a re-execution of a query that only reads from question.
What the frontend code actually looks like
Theconvex-svelte library provides a useQuery hook that returns an object with data, isLoading, and error properties. The Svelte component declares it and renders from it:
useEffect for polling. There is no refetchInterval. There is no event bus listening for “data changed” messages. The query is live by default.
When the arguments to a query are reactive (they depend on component state), useQuery accepts a function that returns the arguments:
selectedStudentId changes (the admin clicks a different student row), the old subscription is torn down and a new one is created for the new argument set. The transition is seamless.
Conditional queries with skip
Not every query should fire immediately. If the data required to build the query arguments is not yet available (e.g., the user hasn’t loaded yet), the query can be skipped:'skip' tells the client to not open a subscription until the dependency is available. This prevents wasted queries against the server and avoids errors from passing undefined as an argument.
Mutations and the write path
Mutations in Convex are transactional. When a student saves progress on a question, the mutation:- Reads the existing
userProgressrecord (if any) for that user and question. - Creates or updates the record with the new selected options, eliminated options, and flag state.
- Updates the denormalized
flagCounton the question if the flag status changed. - Calls the badge engine, which updates
userBadgeMetricsand potentially creates auserBadgeAwardsrecord. - Commits all of those writes atomically.
- That student’s module progress bar updates.
- The admin’s cohort progress dashboard updates (the student’s row shows a new completion percentage).
- The admin’s “top flagged questions” list updates if the student flagged or unflagged the question.
- The student’s badge count updates if the answer triggered a badge rule.
- Any other student viewing the same module’s aggregate stats sees the flagCount change on that question.
Where live data appears in the product
Student module study
The primary study interface at/classes/[classId]/modules/[moduleId] subscribes to module data, question data, and user progress. As the student works through questions:
- Their selected and eliminated options are saved via mutation after each interaction.
- The progress bar at the top of the module reflects the current state of
userProgressrecords for that module. - If another student flags the same question, the flag count on that question updates in real time, visible to curators viewing the question list.
Admin progress dashboard
The admin progress page at/admin/progress subscribes to two primary queries:
getCohortProgressStatsreturns aggregate numbers: total students, total questions, total modules, and average completion percentage across the cohort.getStudentsWithProgressreturns a list of every student in the cohort with their individual progress statistics.
getUserModuleStats for that specific student. This query returns a per-class, per-module breakdown of the student’s progress. If the student is actively studying while the admin is watching, the modal updates live.
Custom quiz system
The custom quiz system at/classes/[classId]/tests/new uses mutations for every step of the quiz lifecycle:
startQuizAttemptcreates the attempt record and returns a seed for deterministic question ordering.recordAnswerpersists each answer as the student works through the quiz.submitQuizAttemptcalculates scores and writes the result summary.
status field (in_progress, submitted, timed_out, abandoned) that is live-queryable. If an admin wanted to build a view of “currently active quizzes,” they could query by status and see attempts appear and disappear in real time as students start and finish quizzes.
Badge engine
The badge system evaluates rules after every progress mutation. When a student crosses a threshold (e.g., mastering 50 questions, maintaining a 7-day study streak), the badge engine:- Updates
userBadgeMetricswith the new aggregate values. - Evaluates all badge rules applicable to the student’s scope (global, cohort, or class).
- Creates a
userBadgeAwardsrecord if a new badge is earned.
PDF processing pipeline
When an admin uploads a PDF to the content library, the processing pipeline runs as a Convex action (server-side, outside the transaction):- The PDF is fetched from UploadThing and sent to Gemini for chunking.
- Each chunk is written to the
chunkContenttable via a mutation. - The
pdfProcessingJobsrecord is updated with progress after each chunk. - The admin’s UI subscribes to the job record and shows a live progress indicator.
Denormalization and why it matters for live performance
Convex queries re-execute when their dependencies change. A naive query that counts everyuserProgress record in a cohort to calculate average completion would re-execute on every single progress save by every student. For a cohort of 200 students with 2,000 questions, that query would touch hundreds of thousands of records on every re-execution.
LearnTerms avoids this by denormalizing aggregate statistics:
users.progressStatsstoresquestionsInteracted,questionsMastered, andtotalQuestionsdirectly on the user record. Progress queries read these fields instead of countinguserProgressrecords.cohort.statsstorestotalStudents,totalQuestions,totalModules, andaverageCompletionon the cohort record. The admin dashboard reads these directly.module.questionCountstores the number of questions in a module. Incremented on question creation, decremented on deletion.question.flagCountstores how many students have flagged a question. Incremented and decremented in thesaveUserProgressmutation.
Indexes and query efficiency
Convex uses indexes to make queries fast and to narrow the scope of subscription dependencies. LearnTerms defines composite indexes on most tables:by_user_questiononuserProgressenables O(1) lookup for “does this student have progress on this question.”by_user_classonuserProgressenables range queries for “all progress records for this student in this class.”by_moduleId_orderonquestionenables sorted retrieval of questions within a module.by_moduleId_flagCountonquestionenables the “top flagged questions” query to read only the questions with the highest flag counts.
by_user_class to read progress for a single student in a single class will only re-execute when mutations affect that specific user-class combination — not when any student in any class saves progress.
What the frontend does not manage
Because Convex handles subscriptions, cache invalidation, and state synchronization, the frontend does not need:- A state management library. There is no Redux, Zustand, or Pinia. Svelte stores exist for UI-only state (toast notifications, theme preferences), but all server data flows through
useQuerysubscriptions. - Manual cache invalidation. When a mutation writes data, all affected queries update automatically. There is no
queryClient.invalidateQueries()call. There is no stale-while-revalidate configuration. The cache is always consistent because Convex manages it. - Polling or refetch intervals. No
setIntervalfetching data every 5 seconds. NorefetchOnWindowFocus. The WebSocket connection delivers updates as they happen. - Optimistic update rollback infrastructure. Convex mutations in a managed environment have high reliability. The app uses optimistic UI updates (setting Svelte state before the mutation returns) but does not build elaborate rollback mechanisms because mutation failures are rare.
Current limits
Convex query re-execution cost
Every live query has a computational cost. If 200 students are on the progress dashboard simultaneously, each with a subscription togetCohortProgressStats, and one student saves progress, that query re-executes 200 times (once per subscriber). The query itself is fast (it reads denormalized fields), but the aggregate load scales with the number of concurrent subscribers multiplied by the mutation rate.
For LearnTerms’ current cohort sizes (optometry school classes), this is well within Convex’s capacity. For a platform with tens of thousands of concurrent users, the subscription fan-out could become a bottleneck.
No partial updates
Convex subscriptions send the full query result on every update, not a diff. IfgetStudentsWithProgress returns a list of 200 students and one student’s progress changes, the entire list is re-sent to every subscriber. For small result sets, this is negligible. For large result sets with frequent updates, the bandwidth cost adds up.
LearnTerms mitigates this with query design: admin analytics queries use pagination (limit and offset parameters), and student-facing queries are scoped to the current module or class, keeping result sets small.
Offline and poor connectivity
Convex’s WebSocket connection requires an active network connection. If a student loses connectivity mid-study, in-flight mutations will fail and pending subscription updates will not arrive. The app does not currently implement offline-first patterns like local-first databases or service workers for offline mutation queuing. For the target use case (students studying on campus or at home with stable internet), this is acceptable. For a mobile app used in areas with unreliable connectivity, this would need addressing.No server-sent events or webhook-style push
Convex’s real-time model is client-pull via subscriptions, not server-push via events. There is no way to say “when a badge is earned, push a notification to the student’s phone.” Notifications, if ever built, would need to be implemented outside Convex (e.g., via a separate push notification service triggered by a Convex action).Write contention on hot records
Denormalized counters likemodule.questionCount and question.flagCount are updated by mutations from many different users. In Convex, mutations that write to the same document are serialized. If 50 students flag the same question simultaneously, those 50 mutations execute sequentially, not in parallel. For the current user base, this serialization is imperceptible. At scale, hot counters could introduce latency.
Future capabilities
Real-time collaborative study sessions
The subscription infrastructure already supports multi-user reactivity. A “study room” feature where multiple students work through the same module simultaneously — seeing each other’s progress, discussing flagged questions, or competing on speed — would require new queries and UI, but no new real-time infrastructure. The sameuseQuery subscriptions that power the admin dashboard would power collaborative views.
Live instructor-led sessions
An instructor could project a dashboard showing real-time cohort performance during a review session. As students answer questions on their devices, the instructor’s screen updates live. This is already possible with the existing admin progress dashboard; a dedicated “session mode” view would be a UI-only addition.Presence and activity indicators
Convex can store ephemeral state (e.g., “user X is currently viewing module Y”) in a table and subscribe to it. This would enable “who’s studying right now” indicators on the dashboard, typing indicators in future discussion features, or live cursor positions in a collaborative question editor.Granular subscription scoping
As cohort sizes grow, queries likegetStudentsWithProgress could be split into per-page subscriptions, where each page of the student list is a separate query with a separate subscription scope. This would reduce the re-execution cost when a single student’s data changes, because only the query for the page containing that student would re-execute.
Convex components and the ecosystem
Convex’s component model (convex.config.ts) already integrates third-party modules like @convex-dev/polar for subscription billing and @convex-dev/rate-limiter for usage limits. As the Convex ecosystem grows, LearnTerms can adopt components for notifications, presence, search, and analytics without changing the core subscription model.
Edge caching for read-heavy queries
Some queries (e.g., the list of classes in a cohort, the list of modules in a class) change infrequently but are read by every student. Convex could introduce edge caching for queries with low mutation rates, reducing latency for geographically distributed users. This is a platform-level improvement that LearnTerms would benefit from automatically.How this compares to traditional architectures
In a traditional REST or GraphQL architecture, building the same live experience would require:- A WebSocket server (Socket.io, Pusher, Ably) running alongside the API server.
- Event emission logic in every mutation that publishes “data changed” events to the WebSocket layer.
- Channel management to ensure clients subscribe to the right events for the data they’re displaying.
- Cache invalidation logic on the frontend to refetch or update cached data when events arrive.
- Reconnection handling for dropped WebSocket connections.
- Consistency guarantees between the REST API response and the WebSocket event (race conditions where a query returns stale data because the event hasn’t fired yet).