Disclosure: I'm the maker of FrameNotes, the tool used as the example here. This post is about a design decision, not a feature tour.
Most web requests finish before the user notices them. A video conversion doesn't. Upload, frame analysis, audio extraction, transcription through a third-party provider, then building several export files: that's a long-running job with several ways to fail, and some of those failures happen in services you don't control.
My first version of FrameNotes handled this the lazy way: one spinner and a "Processing..." label. People refreshed the page, re-uploaded the same file, and reported "it's broken" when it was simply on step three. This is what I changed.
1. Split the job into stages the user can see
Instead of one opaque job, the pipeline reports four named stages:
- Upload: the browser sends the file. There is no server task yet.
- Visual extraction: detect meaningful visual changes, drop near-duplicate frames.
- Transcription: extract audio, send it to the speech-to-text provider, align segments to frames by timestamp.
- Export: build HTML, Markdown ZIP, DOCX, PDF, and presentation-style HTML.
Each stage reports a status and a percentage. The point isn't precision. It's that when something stalls, the user and I both know where.
A simplified sketch of the status payload (illustrative, not the exact production schema):
{
"task_id": "abc123",
"stage": "transcribing",
"progress": 60,
"status": "running",
"error": null
}
2. Make the task survive a refresh
Once the upload completes, the API returns a task ID and processing continues as a background job. The frontend stores recent task IDs in localStorage, so after a refresh it can query their status again instead of starting over. Simplified sketch:
const KEY = "recentTasks";
function rememberTask(id) {
const ids = JSON.parse(localStorage.getItem(KEY) || "[]");
localStorage.setItem(KEY, JSON.stringify([id, ...ids.filter(x => x !== id)].slice(0, 10)));
}
async function restoreTasks() {
const ids = JSON.parse(localStorage.getItem(KEY) || "[]");
return Promise.all(ids.map(id => fetch(`/api/tasks/${id}`).then(r => r.json())));
}
There's one honest caveat I put in the UI: don't close the page during the initial upload, because until the upload finishes there's no server task to restore. Saying that clearly helped more than any clever retry logic.
3. Fail with a message, never with made-up output
The hardest rule to keep: if transcription fails, show an error. Don't fill the gap with placeholder or generated text. A note tool that quietly invents a transcript is worse than one that fails.
The failure causes I actually see, and the message each one maps to:
| Cause | What the user sees |
|---|---|
| File above the beta limit (30 min / 500 MB) | Limit message before processing starts |
| Damaged or unsupported media stream | Upload/processing error at stage 1–2 |
| No usable audio track | Transcription-stage error |
| Provider quota or availability issue | Transcription-stage error, retry later |
| Too little visual change | Note with few frames, explained, not padded |
4. Show limits before the job, not after
Accepted formats (MP4, MOV, AVI, MKV) and size/length limits are shown at upload time. Rejecting a file in one second is kinder than rejecting it after a long wait.
What I'd do earlier next time
- Design the status model before the pipeline, not as a patch after.
- Treat every external dependency (here, the speech-to-text provider) as a stage that can fail on its own.
- Write the error copy at the same time as the happy path.
If you're building anything with slow background jobs, such as media processing, imports, or report generation, I'd love to hear how you handle progress and recovery. And if you want to see the stages in action on a short video of your own, the tool is at framenotes.org.
Written by Mary, the maker of FrameNotes.
Top comments (0)