What “live” means in LearnTerms
Every LearnTerms query 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 — each client displaying that data receives the update automatically. Live behavior is the default for application data reads. The same query API handles the initial result and later updates.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.
- Document processing pipelines run asynchronously on the server. Staff see the current Stored, Extracted, Indexed, and Mapped state update as background work progresses.
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.
question stays isolated from unrelated badges writes.
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
Some queries need user or cohort data before they can run. Use'skip' until those arguments are available:
'skip' defers the subscription until the dependency is available. This prevents wasted queries and avoids undefined arguments.
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 module’s flag analytics update 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 combines several scoped live queries. getStudentsWithProgress supplies student coverage and activity data, while curriculum and module views load bounded analytics for the selected class or module. The overview also loads a limited ranked sample of modules with low coverage.
These queries re-execute when relevant progress data changes. The workspace also watches its WebSocket connection and warns when displayed data may be stale after an interruption.
The admin can click a student row to open a detail modal, which subscribes to 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.
Document processing pipeline
When staff upload a Content Library PDF:- The original file is stored in R2.
- LearnTerms extracts page-aware text and layout artifacts.
- The extracted material is indexed for source retrieval.
- A reusable topic map is prepared for Question Studio.
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 precomputed fields.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 for one student and class re-executes when mutations affect that indexed range.
Responsibilities Convex handles
Convex handles subscriptions, cache invalidation, and state synchronization. The frontend can omit:- 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. - Large rollback frameworks. The app uses focused optimistic UI updates and handles failures at the affected workflow.
Current limits
Convex query re-execution cost
Every live query has a computational cost. If many staff members subscribe to the same cohort progress view while students are active, relevant queries re-execute for each subscriber. The aggregate load scales with the number of subscribers and the mutation rate, so LearnTerms keeps module analytics bounded and loads question detail in pages. 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.Full-result updates
Convex subscriptions send the full query result on every update. IfgetStudentsWithProgress returns a list of 200 students and one student’s progress changes, the entire list is re-sent to every subscriber. For large result sets with frequent updates, the bandwidth cost adds up.
LearnTerms mitigates this with query design: question analytics use pagination, module analytics return bounded lists, and student-facing queries are scoped to the current module or class.
Offline and poor connectivity
Convex’s WebSocket connection requires an active network connection. A connection loss interrupts in-flight mutations and subscription updates. LearnTerms currently expects online study sessions and resumes live updates after reconnection. 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.Mobile push notifications
Convex’s application clients receive updates through query subscriptions. Native mobile push notifications would require a push 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. Convex serializes mutations that write to the same document. A burst of writes to one counter executes sequentially and can introduce latency at scale.
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).