Say you're on vacation and you've just recorded a fairly large video of yourself skydiving for the first time, naturally, you'd want to send it to a friend or family member on the other side of the world. After the upload finishes and the video eventually reaches them, they press Play and marvel in the moment, re-living the experience with you.
That sounds like a trivial thing to do, but somewhere between the press of both buttons a plethora of things have happened.
The video had to make it through your shitty internet connection, travel somewhere to be stored, possibly be processed into different formats and qualities, and eventually make its way to someone thousands of kilometres away in a form their device can actually play.
And somehow, most of this happens without either of you having to think about it.
That simple journey from uploading to playing the video is what we're going to unpack.
So what needs to happen behind the scenes to make that Upload to Play journey work.
Before thinking about databases, queues, CDNs, or any of the other boxes we might eventually draw, it helps to start with the people actually using the system, the creator (not God) and the viewer.
From the creator’s side, the experience should be pretty straightforward. They should be able to upload a video, see how far along the upload is, recover if their connection drops, and know when the video is finally ready to watch.
So, at a minimum, a creator should be able to:
- Upload a video
- See the upload progress
- Resume an interrupted upload
- Know when the video is processing
- Eventually make the video available for playback
The person receiving that skydiving video has a different set of expectations. They probably do not care how we stored it, where we processed it, or which server is delivering it. They just want to press Play and have it work.
In turn, a viewer should be able to:
- Open the video
- Start playback quickly
- Watch it across different network conditions
- Seek through it
- Ideally not care where in the world our servers are located
These are our functional requirements, basically, the things the system needs to allow our users to do.
So far, everything sounds pretty reasonable.
But systems are rarely designed around the happy path alone. For instance how would the system behave when someone uploads a 20 GB file?, or their Wi-Fi disappears at 97%?
What happens when 500,000 people suddenly try to watch the same video?, when one of our transcoding workers crashes halfway through processing?
These questions are less about what the user can do, and more about how well the system holds up when things get messy.
Those are our non-functional requirements.
And this is where things start getting fun.
The upload
Let’s start with the obvious part: getting the video off the user’s device and into our system.
The simplest approach would be to send the entire file to our backend in one request:
Client
|
| POST /video
|
v
Application Server
|
v
Storage
Well, if there’s one thing I’ve learned from designing systems, it’s that the simplest approach usually doesn’t scale very well. For a 5 MB profile picture, this might be perfectly fine.
But remember, we are dealing with video, that skydiving clip could easily be several gigs, and suddenly one giant request starts looking a lot less attractive.
What happens if the connection drops halfway through? What if the upload reaches 9.8 GB out of 10 GB and fails?
Do we really want to tell the user:
"Sorry. upload failed, please try again." I'm pretty sure that violates the Geneva Convention, and might have to sue, or at least treat it as a cosmic sign to delete the app.
There's also the backend itself. If every large video has to pass through our application server before reaching storage, we are making that server do a lot of heavy lifting for very little reason.
So the straightforward approach works, but it does not hold up very well once the files get large.
We need a better way to handle upload.
Uploading in chunks
Instead of treating the video as one giant file, we can break it into smaller pieces and upload those pieces separately.
Think of our large skydiving video as something like this:
10 GB video
Chunk 1
Chunk 2
Chunk 3
Chunk 4
...
Chunk 500
now each chunk can be uploaded on its own, that changes the failure story quite a bit.
Say the user gets all the way to chunk 421 and their Wi-Fi suddenly disappears. Maybe the hotel connection gives up, maybe they walk into an elevator, or the internet just decides it has done enough for the day.
Chunks 1 through 420 are still there.
When the connection comes back, we continue from 421 or 422 instead of starting the entire 10 GB upload again, that is the idea behind resumable uploads.
A simple flow might start with the client asking our backend to create an upload session:
POST /uploads
The server responds with something like:
uploadId: abc123
That ID gives us a way to keep track of the upload as each piece arrives.
The client can then send the chunks one by one
PUT /uploads/abc123/part/1
PUT /uploads/abc123/part/2
PUT /uploads/abc123/part/3
Once everything is uploaded:
POST /uploads/abc123/complete
The backend can verify that all the pieces arrived and mark the upload as complete.
So now, when the user's internet connection fails at 97%, it is still annoying.
But at least it is no longer a "please upload the entire 10 GB again" kind of annoying.
Phew! Progress.
But should the video pass through our backend?
chunking solves one problem, but I hate to be the bearer of bad news: it also exposes another.
Right now, every chunk still has to pass through our application server before it reaches storage:
Phone → API Server → Object Storage
That works, but our backend is now basically acting like a very expensive middleman, making every chunk pay a latency toll on its way to storage.
It receives several gigabytes of video data, only to turn around and send that same data somewhere else.
There is usually little reason to do that.
A better approach is to let the backend handle authorization, then allow the client to upload the chunks directly to object storage.
For example:
┌──────────────┐
│ API Server │
└──────┬───────┘
│
signed upload URL
│
v
Client ───────────────────────> Object Storage
The application server still decides who is allowed to upload, but it no longer needs to carry the actual video data itself.
With something like Amazon S3, this can be done using multipart uploads and pre-signed URLs. The backend gives the client permission to upload specific chunks, and the client sends them directly to object storage.
That means our API server can go back to doing what it was actually built for: handling application logic.
That leaves our API server free to handle application logic instead of babysitting every chunk on its way to storage.
We have the video. Now what?
Great, the upload worked, our skydiving video is sitting safely in storage.
Let’s say the original file looks something like this:
holiday-video.mov
3840 × 2160
HEVC
65 Mbps
So technically, we have a video, the problem is that we still do not have a video that will work well for everyone.
Someone watching on a fast fibre connection might be perfectly fine with that file, another person watching on mobile data, with a shaky connection, is probably going to have a very different experience.
And by “different experience”, I mean staring into the abyss of the loading spinner. We also cannot assume every device supports the same codec, resolution, or bitrate.
Streaming platforms solve this by creating multiple versions of the same video.
For example:
2160p → 15 Mbps
1080p → 6 Mbps
720p → 3 Mbps
480p → 1.5 Mbps
360p → 700 Kbps
A viewer with a strong connection can get a higher-quality version, while someone on a weaker network can get something lighter.
This is where our transcoding pipeline comes in.
Do not transcode inside the upload request
It might be tempting to do everything in one go:
Upload video
↓
Transcode video
↓
Return HTTP 200
We shouldn't do this.
Transcoding is expensive work. I still remember how violently my Mac fans were spinning the first time I tried transcoding using FFmpeg. Depending on the size and format of the video, it can take seconds, minutes, or much longer. Our creator should not have to keep one HTTP request alive while a server somewhere turns their 4K skydiving video into five different versions, that would make the upload flow slower, more fragile, and much harder to recover from if something fails halfway through.
A better approach would be to separate uploading from processing, once the upload is complete, we can create a job and place it on a queue:
Object Storage
|
v
Upload Service
|
v
Message Queue
|
v
Transcoding Workers
A worker can then pick up that job and process the video in the background.
From the creator's point of view, the API can simply respond with something like:
Upload complete.
Processing...
And they can get on with their day while the heavier work happens somewhere else.
What happens when uploads arrive faster than we can process them?
So far, we have decoupled uploading from transcoding, awesome, but now we have something else to worry about.
Imagine our transcoding workers can comfortably process 100 videos every minute, and on a normal day, around 100 videos arrive every minute, well no need to be paranoid in that case, but then something happens, there's a major event the app suddenly goes viral, or maybe the internet just decides today is our stress test, now 5,000 videos arrive within the same window, then the panic sets in.
Our transcoding workers cannot suddenly become 50 times faster just because we asked nicely, sorry man, but this ain't La La land. Without something in between, all of those jobs hit the processing system at once, and at some point they won't be able to keep up.
This is where a queue starts to make sense.
Instead of demanding to process the video right now, the upload service can say "Here is another video. Process it when you have capacity". The queue becomes a buffer between the rate at which videos arrive and the rate at which we can actually process them.
Our workers can keep pulling jobs at a sustainable pace, and if the backlog starts growing, we can scale horizontally by adding more workers.
┌──> Worker 1
Queue ────────┼──> Worker 2
├──> Worker 3
└──> Worker N
The important part is that a sudden spike in uploads no longer has to become a sudden spike in pain everywhere else.
The queue also gives us something else: isolation.
If transcoding slows down, users should still be able to upload videos normally. The processing backlog can grow without immediately dragging the upload experience down with it.
In other words, one part of the system having a bad day does not mean everyone else has to join in.
Designing for the Not-So-Expected
At some point it will happen.
Not because we're are terrible engineers. After all, computers crash, containers restart, networks disappear, the best we can do is breathe and reboot.
Lol.. our pipeline should not need a pep talk every time that happens. It should be designed to recover, say a worker picks up this job:
Transcode video #93821
It gets halfway through, then crashes, we do not want that job to disappear into the void. A queue can make the message available again after a timeout, so another worker can pick it up and retry the work. So does this solve the problem? Almost.
What if the first worker actually finished the job, but crashed before it could report that completion? Now another worker receives the exact same job and tries to process it again. If running the same job twice creates duplicate records, duplicate files, or corrupt state, we have just traded one problem for another. This is why operations in the pipeline should be idempotent where possible, in simple terms, processing the same job more than once should still leave us with the same final result.
Turning one video into something streamable
At this point, our transcoder has done its job and produced several versions of the video:
Original
|
├── 1080p
├── 720p
├── 480p
└── 360p
Great. We now have different quality levels, but we do not want the player downloading one giant MP4 file every time someone presses Play, just like the way with upload we also want to handle streaming by chunking: breaking each rendition into much smaller media segments.
Using HLS, for example, we might eventually have something like:
master.m3u8
1080p/
playlist.m3u8
segment001.ts
segment002.ts
segment003.ts
720p/
playlist.m3u8
segment001.ts
segment002.ts
480p/
...
The master playlist tells the player which renditions are available, and each rendition has its own playlist pointing to the individual media segments.
If you want to go deeper on this, I wrote a separate article on the basics of media streaming here
Now the player has options, kind of like me deciding what show to watch on Netflix. Good network? Move up to 1080p, network suddenly struggling? Drop to 480p.
Where do we put all this stuff?
At this point, one uploaded video has turned into a number of things:
- The original file
- Several encoded renditions
- Hundreds or thousands of media segments
- Playlists and manifests
- Thumbnails
- Subtitle files
- Metadata
That is a lot more than the single video file we started with. All of those media files belong in object storage, not on the local disk of an application server.
Application servers come and go, video files should not. The metadata though, is a different story.
Things like the video title, owner, duration, processing state, and manifest location are better suited to a database:
Video
------------------------
id
ownerId
title
status
duration
createdAt
manifestUrl
One field here is especially useful: status.
A video is not simply uploaded or not uploaded. It moves through a small lifecycle:
UPLOADING
↓
UPLOADED
↓
PROCESSING
↓
READY
Or:
PROCESSING
↓
FAILED
That status matters because both the user and the rest of the system need to know what is happening, the creator needs to know whether the video is still uploading, being processed, or ready to share. The backend needs the same information so it knows whether playback should even be allowed yet.
How consistent does our system need to be?
This is usually the point where system design conversations start throwing around phrases like "CAP Theorem", and everyone nods like Google wasn't involved five minutes ago.
But the more useful question is simpler, "How consistent does this particular piece of data actually need to be?": Say the upload has finished, but one replica still thinks the video is processing for another two seconds.
The creator sees:
Processing...
for a little bit longer, annoying? maybe, but I believe no one would lose their head over this.
For something like video processing status, we can usually tolerate a little stale data if it helps keep the system available. But not every part of the platform gets that luxury, if two services disagree about whether a user is allowed to watch paid content, that is a much bigger problem. The same goes for billing, ownership, entitlements, or important state transitions in the processing pipeline.
So rather than saying:
"Our system chooses availability over consistency."
I think it is more useful to ask what guarantee each part of the system actually needs. A processing status can often be eventually consistent, a payment or entitlement decision probably should not be.
The trade-off depends on the data, not the diagram.
The video is ready. Now 1 million people want it.
Great news, the video is finally ready, even better news, it starts getting popular.
Now imagine people in Lagos, London, Tokyo, Dubai, New York, or maybe even Helheim, all trying to watch the same 1080p segments at roughly the same time. This is great news for the business, but not so great for our origin server.
If every single request has to travel all the way back to the same storage location, things can get expensive and slow very quickly.
Viewer ──────────────────────> Origin
This is where a Content Delivery Network (CDN) starts to earn its place.
Instead of making every viewer travel all the way back to the origin, we place edge servers closer to them:
Viewer
|
v
Nearby CDN Edge
|
| cache miss
v
Origin
Think of a retail food franchise, say KFC, sure its headquarters is located in the USA, but I do not need to travel halfway across the world every time I want a bucket of chicken, I can just go to the nearest branch here in Lagos, or better yet, order one on Chowdeck and have it delivered to me. Same bucket of chicken, much shorter journey, and I have saved myself the trouble of getting a visa, buying a flight ticket, and packing a suitcase just because I was hungry.
A CDN works in a similar way. The origin still holds the original content, but once a video segment has been cached at an edge location closer to the viewer, there is no reason to keep fetching that same segment from the origin every single time.
The first request might make the long trip, the next thousand do not have to.
And finally, someone presses Play
We have uploaded the video, processed it, stored it, and pushed it closer to viewers, now we finally get to the other button in our story, the Play button.
The viewer opens the video, and the application asks our backend for the information it needs to start playback.
GET /videos/93821
Before handing anything over, the backend may need to answer a few questions:
- Does the video exist?
- Is it ready?
- Is the user allowed to watch it?
- Is the content restricted?
- Do we need a signed playback URL?
Once those checks pass, the application receives the manifest URL and gives it to the player.
The player then requests that manifest from the CDN:
Player
|
v
CDN
|
v
master.m3u8
The manifest tells the player which renditions are available.
From there, the player picks one that makes sense for the current network conditions and starts requesting media segments:
segment001
segment002
segment003
...
As network conditions change, the player can switch up or down between rendition qualities. All of that happens while our viewer is hopefully just watching the skydiving video and thinking, "Yeah, I'm definitely never doing that"
And that is the whole journey, one person pressed Upload, somewhere else another person pressed Play. Everything we designed in between exists to make those two actions feel almost boringly simple.
Putting everything together
At this point, our architecture looks something like this:
┌──────────────┐
│ API Service │
└──────┬───────┘
│
create upload session
│
v
Client ─────────────────────> Object Storage
|
| upload complete
v
Message Queue
|
┌─────────┼─────────┐
v v v
Worker Worker Worker
|
v
Transcode
|
v
Package HLS/DASH
|
v
Object Storage
|
v
CDN
|
v
Player
Of course, a real production system would probably have a lot more going on: authentication, DRM, subtitles, analytics, moderation, observability, geo restrictions, playback authorization, and probably a billing system somewhere quietly waiting to ruin everyone’s afternoon.
But for what we set out to understand, this is enough to see the journey from Upload → Play as one complete system.
And more importantly, none of these boxes appeared by accident.
System design is mostly a collection of problems
This is probably the biggest thing I take away from designing systems like this.
We did not wake up one morning and decide:
"You know what this architecture needs? Object storage, a queue, some workers, and a CDN. That looks scalable."
Each component showed up because we ran into a problem, large uploads can fail, so we introduced chunking and resumable uploads.
Application servers should not spend their lives carrying giant video files around, so we let clients upload directly to object storage.
We introduced async workers, because video processing is expensive, and a queue because traffic does not always arrive politely.
Different viewers have different devices and network conditions, so we had to transcode into multiple renditions and use adaptive bitrate streaming.
Workers fail, so we retry jobs and make those operations idempotent where possible.
Viewers can be thousands of kilometres away.
So we bring the content closer to them with a CDN.
Some data can be a few seconds stale without causing any real harm.
Other data absolutely cannot.
So we choose consistency guarantees based on the operation instead of forcing one rule on the entire system.
That, to me, is when system design starts to make more sense.
You stop memorizing architectures, you stop adding boxes because some diagram on the internet had them, and you begin asking one simple question, "What problem are we actually trying to solve?". Once you can answer that, the boxes and arrows usually start explaining themselves.
Final thoughts
A streaming platform is a lot more than a video player and a large bucket of MP4 files.
Behind a simple Upload button is a pipeline responsible for receiving large files, processing them, storing them, distributing them, and eventually delivering the right version of the video to the right viewer.
And all of that was just for Video On Demand (VOD).
Live streaming comes with a different set of problems entirely: continuous ingestion, latency, real-time encoding, segment generation, synchronization, failover, and the small inconvenience that you cannot exactly tell a live event to hold on for a minute while your transcoder restarts. That probably deserves an article of its own.
For now, the next time you upload that vacation video and someone thousands of kilometres away presses Play, spare a tiny thought for all the work happening between those two buttons.
The interface makes it look simple, the system underneath is doing everything it can to keep it that way.
Top comments (0)