Imagine a student with a weak internet connection trying to watch a video lesson, but the player stutters and rewinding resets the progress—forcing them to start over. This is a common issue in many LMS platforms built without a solid architecture. Developing a lesson system is a complex task: you need stable tracking for video, text, and audio content, adaptive delivery, and fault tolerance.
Our LMS development includes a robust lesson system with video lessons in LMS, audio lessons, text lessons, and HLS transcoding for progress tracking. Using Tiptap editor for text, React video player for video, and a solid LMS backend, we ensure lessons with subtitles and accurate progress tracking. We build lesson systems where each content type has reliable tracking and adaptive delivery. Based on experience with 50+ educational portals, we've identified key technical challenges: fragile tracking during video rewinding, slow loading of large files, lack of audio-to-transcript synchronization. Here's how we solve them.
Which technical problems does the lesson system solve?
- Video progress tracking — users may scrub backward, so progress should only record the maximum position. We save it every 10% and mark the lesson complete at 90%+ watch time, not just at the end.
- Transcoding large files — raw MP4 stutters on slow connections. We transcode video to HLS with multiple bitrates (360p–1080p) and use background workers (Bull) for asynchronous processing.
- Text lessons without reading confirmation — simply scrolling to the bottom isn't enough. We use Intersection Observer to detect 90% scroll and automatically complete the lesson.
- Audio without synchronization — listeners lose track without text. We display a transcript next to the player and optionally highlight the current word.
How we ensure reliable progress tracking for video lessons
The core challenge: users can seek backward, but progress must only increase. In the ReactPlayer component, we track the maximum played value (0–1) and save it every 10%.
import ReactPlayer from 'react-player';
import { useState, useRef, useCallback } from 'react';
function VideoLesson({ lesson, enrollmentId, onComplete }) {
const playerRef = useRef<ReactPlayer>(null);
const [played, setPlayed] = useState(0);
const [completed, setCompleted] = useState(lesson.progress?.completed ?? false);
const maxPlayedRef = useRef(lesson.progress?.progress ?? 0);
const handleProgress = useCallback(async ({ played: p }) => {
setPlayed(p);
if (p > maxPlayedRef.current) {
maxPlayedRef.current = p;
if (Math.floor(p * 10) > Math.floor((p - 0.01) * 10)) {
await saveProgress(enrollmentId, lesson.id, p);
}
}
}, []);
const handleEnded = useCallback(async () => {
if (!completed) {
setCompleted(true);
await fetch(`/api/enrollments/${enrollmentId}/lessons/${lesson.id}/complete`, {
method: 'POST',
});
onComplete?.(lesson.id);
}
}, [completed]);
const handleProgress2 = useCallback(({ played: p }) => {
handleProgress({ played: p });
if (p >= 0.9 && !completed) {
handleEnded();
}
}, [handleProgress, handleEnded, completed]);
return (
<div className="space-y-4">
<div className="relative aspect-video bg-black rounded-xl overflow-hidden">
<ReactPlayer
ref={playerRef}
url={lesson.content.videoUrl}
width="100%"
height="100%"
controls
onProgress={handleProgress2}
onEnded={handleEnded}
config={{
file: {
tracks: lesson.content.subtitles.map(s => ({
kind: 'subtitles',
src: s.url,
srcLang: s.lang,
label: s.label,
})),
},
}}
/>
</div>
{lesson.content.chapters?.length > 0 && (
<div>
<h3 className="font-semibold text-gray-800 mb-2">Contents</h3>
<ul className="space-y-1">
{lesson.content.chapters.map((ch, i) => (
<li key={i}>
<button
onClick={() => playerRef.current?.seekTo(ch.time)}
className="text-sm text-blue-600 hover:underline"
>
{formatTime(ch.time)} — {ch.title}
</button>
</li>
))}
</ul>
</div>
)}
{completed && (
<div className="flex items-center gap-2 text-green-600 text-sm">
<span>✓</span> Lesson completed
</div>
)}
</div>
);
}
Why we use HLS instead of direct MP4?
Direct MP4 loads the entire file — on a poor connection, users wait minutes. HLS splits video into 6-second segments: playback starts immediately, switching quality based on speed. HLS starts 3–10× faster than MP4, and on weak connections the difference can reach 15×. We transcode uploaded video using ffmpeg in the background.
The standard HTTP Live Streaming describes adaptive video delivery and ensures smooth playback.
import { createReadStream } from 'fs';
import ffmpeg from 'fluent-ffmpeg';
async function transcodeToHLS(inputPath: string, outputDir: string): Promise<string> {
await fs.promises.mkdir(outputDir, { recursive: true });
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.outputOptions([
'-profile:v baseline',
'-level 3.0',
'-start_number 0',
'-hls_time 6',
'-hls_list_size 0',
'-f hls',
])
.output(path.join(outputDir, 'index.m3u8'))
.outputOptions([
'-vf scale=-2:720',
'-b:v 2500k',
])
.on('end', () => resolve(`${outputDir}/index.m3u8`))
.on('error', reject)
.run();
});
}
videoProcessingQueue.process(async (job) => {
const { uploadedPath, lessonId } = job.data;
const outputDir = `storage/lessons/${lessonId}/hls`;
await job.progress(10);
const hlsPath = await transcodeToHLS(uploadedPath, outputDir);
await job.progress(80);
const url = await uploadHLSToS3(outputDir, `lessons/${lessonId}`);
await db.lessons.update(lessonId, {
content: { videoUrl: url, status: 'ready' }
});
await job.progress(100);
});
More about background transcoding
We use a Bull queue for asynchronous video processing. After the file is uploaded to the server, the job enters the queue, and a worker process transcodes the video to HLS via ffmpeg. This doesn't block the main thread and allows scaling to multiple workers. Transcoding time depends on video size: a 10-minute 1080p clip takes about 5 minutes. Using HLS can reduce hosting costs: for a 100-hour video library, monthly bandwidth savings exceed $200.Text and audio lessons
How we use Intersection Observer to detect reading completion
For text lessons, we use the Tiptap editor (based on ProseMirror). Content is stored as HTML or Markdown. To mark a lesson as read, we track scroll to the end via Intersection Observer.
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import { Youtube } from '@tiptap/extension-youtube';
function TextLessonViewer({ content, onComplete }) {
const editor = useEditor({
content: content.body,
editable: false,
extensions: [StarterKit, Image, Youtube],
});
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onComplete?.();
},
{ threshold: 0.9 }
);
const marker = containerRef.current?.querySelector('.lesson-end-marker');
if (marker) observer.observe(marker);
return () => observer.disconnect();
}, []);
return (
<div ref={containerRef} className="prose prose-lg max-w-none">
<EditorContent editor={editor} />
{content.attachments?.length > 0 && (
<div className="mt-8 border-t pt-6">
<h3 className="font-semibold">Lesson materials</h3>
<ul className="space-y-2 mt-3">
{content.attachments.map(att => (
<li key={att.url}>
<a href={att.url} download className="flex items-center gap-2 text-blue-600 hover:underline">
<span>📎</span>
<span>{att.name}</span>
<span className="text-gray-400 text-sm">({formatFileSize(att.size)})</span>
</a>
</li>
))}
</ul>
</div>
)}
<div className="lesson-end-marker h-1" />
</div>
);
}
Audio lessons with transcript
Audio lessons are simpler: we use the native audio element with custom styling. The transcript is displayed below the player, helping hearing-impaired users and those who can't play audio.
function AudioLesson({ lesson, onComplete }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
return (
<div className="space-y-6">
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-2xl p-8">
{lesson.content.thumbnailUrl && (
<img src={lesson.content.thumbnailUrl}
className="w-32 h-32 rounded-full mx-auto mb-6 object-cover" alt="" />
)}
<audio
ref={audioRef}
src={lesson.content.audioUrl}
controls
className="w-full"
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onEnded={onComplete}
/>
</div>
{lesson.content.transcript && (
<div className="prose">
<h3>Transcript</h3>
<p className="text-gray-700 whitespace-pre-line">
{lesson.content.transcript}
</p>
</div>
)}
</div>
);
}
Format comparison
| Type | Rendering | Progress | Storage | Complexity |
|---|---|---|---|---|
| Video | ReactPlayer (HLS) | 90% watched or end | HLS segments in S3 | High (transcoding) |
| Text | Tiptap (HTML/MD) | 90% scroll | Database (JSON) | Medium (editor) |
| Audio | Native audio | 90% played | MP3 in S3 | Low |
Switching to HLS saves up to 50% bandwidth compared to MP4, and adaptive bitrate reduces bandwidth costs by 30–50%.
| Feature | HLS | MP4 |
|---|---|---|
| Adaptive bitrate | Yes | No |
| Time to first frame | 1–3 seconds | 10+ seconds |
| Bandwidth savings | Up to 50% vs MP4 | Inefficient |
| Subtitle support | Yes | Only embedded |
| Browser support | All modern | All |
Process and timeline
- Analysis — we study your requirements: content types, number of lessons, integrations (SCORM, SSO).
- Design — we outline the architecture: data models, API endpoints, frontend components.
- Implementation — we write code from scratch or on top of your CMS. We use TypeScript, React, Next.js, Laravel (or your stack).
- Testing — we verify tracking on all content types, emulate slow internet, test large file uploads.
- Deployment — we deploy on your hosting or our infrastructure. We provide access to the source code and documentation.
Estimated timeline: from 1 to 2 weeks for a basic system with video (HLS), text, and audio. If additional types (SCORM, live lessons) or complex analytics are needed, the timeline is discussed separately. The cost is calculated individually. Basic system development starts at $5,000. Order lesson system development – we'll prepare a custom commercial proposal within 24 hours.
What's included and guarantees
- Source code of the lesson system (frontend + backend)
- API for integration with your existing LMS
- Deployment and customization documentation
- Lesson administration console (upload, edit, ordering)
- Post-launch support: 2 weeks free
We have been developing LMS for schools, universities, and corporate portals for many years. All projects are delivered on time and supported until full stability. We guarantee that tracking will work regardless of user actions (seeking, pause, replay). Get a free consultation for your project – contact us. It's free.







