Skip to main content

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 calls useQuery, the following happens:
  1. The client sends the query function name and its arguments to the Convex server over an open WebSocket.
  2. The server executes the query function, reads from the database, and returns the result.
  3. The server tracks which database tables and indexes that query touched during execution.
  4. When any mutation writes to those tables or affects those index ranges, the server re-executes the query function automatically.
  5. If the result differs from the last result sent to the client, the server pushes the new result down the WebSocket.
  6. The client’s reactive framework (Svelte 5’s rune system) picks up the new value and re-renders affected components.
Convex tracks dependencies at the query level and re-runs queries whose dependencies were touched by a mutation. A query scoped to question stays isolated from unrelated badges writes.

What the frontend code actually looks like

The convex-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:
That single line does everything: initial fetch, WebSocket subscription, automatic re-fetch on server-side data changes, and cleanup when the component unmounts. There is no 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:
If 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:
Passing '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:
  1. Reads the existing userProgress record (if any) for that user and question.
  2. Creates or updates the record with the new selected options, eliminated options, and flag state.
  3. Updates the denormalized flagCount on the question if the flag status changed.
  4. Calls the badge engine, which updates userBadgeMetrics and potentially creates a userBadgeAwards record.
  5. Commits all of those writes atomically.
Once the mutation commits, Convex’s subscription system identifies every active query that touched any of the affected tables and index ranges. Those queries re-execute. Every client with a live subscription to affected data sees the update. This means that when a single student answers a single question:
  • 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.
All of this happens within the same mutation → subscription → re-render cycle. No orchestration code. No event emitters. No message queues.

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 userProgress records 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.
Progress saves use an optimistic update pattern. The Svelte component state updates immediately when the student selects an answer:
Then the mutation fires in the background. If the mutation succeeds, the server-side state matches the optimistic state. If it fails, error handling can revert the UI. In practice, Convex mutations rarely fail because they run in a managed environment with automatic retries for transient errors.

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:
  • startQuizAttempt creates the attempt record and returns a seed for deterministic question ordering.
  • recordAnswer persists each answer as the student works through the quiz.
  • submitQuizAttempt calculates scores and writes the result summary.
Each of these mutations triggers subscription updates. An admin viewing the progress dashboard can see a student’s stats change in real time as they submit a quiz. The quiz history page for the student updates immediately after submission without requiring navigation. Quiz attempts use a 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:
  1. Updates userBadgeMetrics with the new aggregate values.
  2. Evaluates all badge rules applicable to the student’s scope (global, cohort, or class).
  3. Creates a userBadgeAwards record if a new badge is earned.
Because these writes happen inside the same mutation transaction as the progress save, the badge award is visible to the student on their next render cycle. The cohort leaderboard, which queries badge awards, also updates for every student viewing it.

Document processing pipeline

When staff upload a Content Library PDF:
  1. The original file is stored in R2.
  2. LearnTerms extracts page-aware text and layout artifacts.
  3. The extracted material is indexed for source retrieval.
  4. A reusable topic map is prepared for Question Studio.
The Content Library subscribes to the document record and renders the current stage as Stored, Extracted, Indexed, or Mapped. A long or image-heavy document can take several minutes, but the background job continues after the user leaves the page. A failed stage exposes its error and a Resume processing action in the Properties panel.

Denormalization and why it matters for live performance

Convex queries re-execute when their dependencies change. A naive query that counts every userProgress 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.progressStats stores questionsInteracted, questionsMastered, and totalQuestions directly on the user record. Progress queries read these precomputed fields.
  • cohort.stats stores totalStudents, totalQuestions, totalModules, and averageCompletion on the cohort record. The admin dashboard reads these directly.
  • module.questionCount stores the number of questions in a module. Incremented on question creation, decremented on deletion.
  • question.flagCount stores how many students have flagged a question. Incremented and decremented in the saveUserProgress mutation.
Denormalization is a deliberate architectural choice that keeps live queries practical. Each query reads a small number of precomputed fields and re-executes quickly. The tradeoff is write-time complexity. Every mutation that changes progress must also update the denormalized fields. LearnTerms handles this inside the same transaction, so the denormalized values are always consistent with the source data.

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_question on userProgress enables O(1) lookup for “does this student have progress on this question.”
  • by_user_class on userProgress enables range queries for “all progress records for this student in this class.”
  • by_moduleId_order on question enables sorted retrieval of questions within a module.
  • by_moduleId_flagCount on question enables the “top flagged questions” query to read only the questions with the highest flag counts.
These indexes also affect subscription granularity. A query that uses 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 useQuery subscriptions.
  • 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 setInterval fetching data every 5 seconds. No refetchOnWindowFocus. 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.
This is a meaningful reduction in frontend complexity. A typical React or Svelte app with REST APIs or even GraphQL subscriptions would need libraries and patterns to handle all four of these concerns. LearnTerms needs none of them because the data layer handles it.

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. If getStudentsWithProgress 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 like module.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 same useQuery 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 like getStudentsWithProgress 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:
  1. A WebSocket server (Socket.io, Pusher, Ably) running alongside the API server.
  2. Event emission logic in every mutation that publishes “data changed” events to the WebSocket layer.
  3. Channel management to ensure clients subscribe to the right events for the data they’re displaying.
  4. Cache invalidation logic on the frontend to refetch or update cached data when events arrive.
  5. Reconnection handling for dropped WebSocket connections.
  6. 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).
Convex collapses all six concerns into a single query-subscription primitive. This structural simplification removes categories of bugs such as stale cache, missed events, and subscription leaks, along with separate WebSocket, event-bus, and cache infrastructure. The tradeoff is vendor coupling. LearnTerms’ real-time behavior is inseparable from Convex, so a database migration would include a new subscription system. At the current stage and scale, development velocity justifies that migration risk.