EdTech Video Progress Tracking & Certificates

The first version of an online course platform always tracks progress the obvious way: every second the video plays, send the timestamp to the server and save it. It works beautifully with ten students and falls over with ten thousand, because you've built a system that writes to your database once per second per viewer — a self-inflicted write storm that locks tables and takes the platform down right as a cohort starts a lesson together. Good video progress tracking is mostly about not doing that.
The architecture that scales: buffer heartbeats in Redis and flush rarely, stream video adaptively instead of self-hosting MP4s, gate content with drip and server-side quizzes, and generate certificates on a background queue. None of it is exotic; all of it is the difference between a course platform that survives a launch and one that crashes during the welcome email. Here are the seven rules.

Rule 1: Never Self-Host Raw MP4s
The first rule of course video: don't stream raw .mp4 files from your own buckets. Linear files force the browser to pull big chunks regardless of connection, which buffers on cellular and inflates your bandwidth bill. Use adaptive bitrate streaming (HLS/DASH): the video is cut into short multi-resolution segments, and the player reads an .m3u8 manifest, shifting quality up and down on the fly. Pick a provider for your stack:
- Mux — developer-first, great API, webhooks, and analytics; volume pricing climbs.
- Cloudflare Stream — predictable flat-rate pricing on a global edge CDN.
Either way, the upload-and-encode flow rides on the same presigned-URL upload pattern you'd use for any large file.
Rule 2: Buffer Heartbeats in Redis, Flush Rarely
To let students resume, the player reports its position — but persisting every tick is the write storm that kills you. Buffer in Redis on each heartbeat, and only write to Postgres on a pause, a navigate-away, or a loose interval:
1// src/progress/heartbeat.service.ts
2import { Injectable } from '@nestjs/common';
3import Redis from 'ioredis';
4import { PrismaService } from '../prisma/prisma.service';
5
6@Injectable()
7export class VideoProgressService {
8 constructor(private readonly redis: Redis, private readonly prisma: PrismaService) {}
9
10 async heartbeat(userId: string, lessonId: string, seconds: number, flush = false) {
11 const key = `progress:${userId}:${lessonId}`;
12 await this.redis.set(key, String(seconds), 'EX', 86400); // always update the fast cache
13
14 // Persist only on real events, not every tick.
15 if (flush || seconds % 30 === 0) {
16 await this.prisma.userLessonProgress.upsert({
17 where: { userId_lessonId: { userId, lessonId } },
18 update: { lastPositionSeconds: seconds, updatedAt: new Date() },
19 create: { userId, lessonId, lastPositionSeconds: seconds, isCompleted: false },
20 });
21 }
22 }
23}Redis absorbs the per-second churn; your durable store sees a trickle. And because the source of truth lives server-side, resume works across every device — unlike localStorage, which strands progress on one laptop.

Rule 3: Model the Course Hierarchy Cleanly
Accurate progress needs a clean structure — courses, modules, lessons:
1CREATE TABLE courses (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 title VARCHAR(255) NOT NULL,
4 is_published BOOLEAN DEFAULT FALSE
5);
6
7CREATE TABLE modules (
8 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9 course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
10 sort_order INT NOT NULL,
11 drip_delay_days INT DEFAULT 0,
12 title VARCHAR(255) NOT NULL
13);
14
15CREATE TABLE lessons (
16 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17 module_id UUID REFERENCES modules(id) ON DELETE CASCADE,
18 sort_order INT NOT NULL,
19 video_asset_id VARCHAR(100), -- Mux/Cloudflare asset reference
20 is_required BOOLEAN DEFAULT TRUE
21);Completion is then a simple ratio: completed required lessons ÷ total required lessons × 100.
Rule 4: Drip Content by Enrollment Date
Opening every lesson at once tanks completion rates. Gate access by how long the student's been enrolled, in an API guard:
1async function canAccessLesson(userId: string, lessonId: string): Promise<boolean> {
2 const lesson = await db.lessons.findUnique({ where: { id: lessonId }, include: { module: true } });
3 const enrollment = await db.enrollments.findUnique({
4 where: { userId_courseId: { userId, courseId: lesson.module.courseId } },
5 });
6
7 const daysEnrolled = Math.floor((Date.now() - enrollment.createdAt.getTime()) / 86_400_000);
8 if (lesson.module.drip_delay_days > daysEnrolled) {
9 throw new ForbiddenException('This section unlocks later in the course.');
10 }
11 return true;
12}Rule 5: Grade Quizzes Server-Side, Always
This is the one people get wrong. Grade a quiz in client JavaScript and a student flips a variable in dev tools to mark a module complete. Validate quiz answers on the backend, compute completion from verified server records, and gate progression there. The client can display progress; it must never decide it.
Rule 6: Generate Certificates on a Queue
When a student finishes, issue a certificate — but PDF rendering is memory-heavy and will block your event loop if you do it inline. Offload it to a BullMQ worker:
1// src/certificates/certificate.processor.ts
2import { Processor, WorkerHost } from '@nestjs/bullmq';
3import { Job } from 'bullmq';
4
5@Processor('certificate-generation')
6export class CertificateProcessor extends WorkerHost {
7 async process(job: Job<{ studentName: string; courseTitle: string; serial: string }>) {
8 const { studentName, courseTitle, serial } = job.data;
9 const html = `<h1>Certificate of Completion</h1><p>${studentName} completed ${courseTitle}</p>`;
10 const pdf = await this.renderPdf(html); // headless render, in the worker
11 await this.uploadToBucket(`certs/${serial}.pdf`, pdf);
12 }
13 // ...
14}Rule 7: Make Certificates Verifiable

A certificate nobody can verify is a JPEG. Generate a unique SHA-256 hash (or signed serial) per certificate, store it in a public read-only table, and embed a QR/verification link on the PDF. An employer scans it and your endpoint confirms it's genuine and unaltered — turning your certificate from decoration into a credential.
EdTech Video Progress Tracking: The Architecture
| Layer | Dangerous default | Production practice |
|---|---|---|
| Media | Raw .mp4 from your bucket | Adaptive HLS via Mux/Cloudflare |
| Progress | Row write per player tick | Redis heartbeat + staggered flush |
| Quizzes | Client-side grading | Server-side validation |
| Certificates | Inline PDF render | Background queue + verifiable hash |
Stream the video, buffer the heartbeats, grade on the server, and queue the certificates. Build it this way and a course launch becomes a quiet ramp instead of an outage — thousands of students hitting play at once is a CDN's problem, not your database's. The platform that survives the welcome email is the one that never tried to write a row every time someone pressed play.
Frequently Asked Questions
Don't write a row on every player tick — that's a write storm that locks tables under load. Buffer the current timestamp in Redis on each heartbeat, and only persist to PostgreSQL on meaningful events: a pause, navigating away, or a loose interval like every 30 seconds. Redis absorbs the high-frequency churn; your durable store sees a fraction of the writes and stays responsive.
Because it's trapped on one device. A student who starts a lesson on their laptop and continues on their phone loses their place entirely. Persist progress server-side (buffered through Redis, flushed to the database) so resume-where-you-left-off works across every device they use. localStorage is fine as a fallback, but the source of truth has to be on your servers.
No. Serving raw MP4s forces browsers to download big linear chunks regardless of connection, which buffers on slow networks and blows up your bandwidth bill. Use adaptive bitrate streaming (HLS/DASH) via a provider like Mux or Cloudflare Stream — the video is cut into short multi-resolution segments and the player shifts quality on the fly. You get smooth playback and predictable costs.
Grade everything server-side. If you evaluate quiz answers in client JavaScript, a student can flip a variable in dev tools and mark a module complete. Validate quiz responses on the backend, compute completion from verified server records, and gate progression there. The client can show progress, but it must never be the thing that decides it.
Generate a unique SHA-256 hash (or signed serial) per certificate, store it in a public read-only table, and embed a QR/verification link on the PDF. An employer scans it and your endpoint confirms the certificate is genuine and unaltered. Generate the PDF itself on a background queue, not in the request, since rendering is memory-heavy and would otherwise block your API.
