← All articles

Build Scalable Video Apps Without Managing FFmpeg Servers

Build Scalable Video Apps Without Managing FFmpeg Servers

There's a specific kind of afternoon every developer remembers. You finished the upload feature, the UI looks decent, and now you just need to compress the video before storing it. Ten lines of shell, a child_process.spawn(), done. You ship it. It works. You feel good.

Then Tuesday happens.

Someone posts your app on a forum, forty people upload videos around the same time, and your single VPS starts melting. CPU pegged at 100%, the Node event loop starved, uploads timing out, and your database connection pool coughing up errors. Your video feature just took down your login page.

So you do the reasonable thing. You move FFmpeg into a queue. You add a worker. Then two workers. Then you're reading about autoscaling groups at 1 AM, and somewhere in the back of your head a small voice says: I did not get into software to become a video infrastructure engineer.

This is the exact problem a serverless FFmpeg API solves, and it's why the question of how to build scalable video apps without managing FFmpeg servers keeps coming up in developer forums. The answer isn't "don't use FFmpeg." FFmpeg is fantastic, and nothing else comes close to its codec and filter coverage. The answer is that running FFmpeg and writing FFmpeg commands are two different jobs, and you only signed up for the second one.

Let's talk about how to keep the power of the command line while handing off the servers, queues, and CPU spikes to someone else.

The FFmpeg Server You Didn't Sign Up to Run

Almost every media feature starts the same way. It's a small script, and it takes maybe twenty minutes to write.

It always starts with one command

ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset fast output.mp4

That's it. That's the whole feature. Locally, on your laptop, a 30-second clip encodes in a few seconds and everyone's happy.

The trouble is that "locally on your laptop" and "on a production server under load" are wildly different environments, and the gap between them is where projects get stuck.

The failure modes show up later

Here's what tends to happen in roughly this order:

  1. A traffic spike hits. Three concurrent 1080p encodes will saturate a 2-vCPU box. The fourth request waits, and the fifth times out.
  2. You add a queue. Now jobs pile up. You need a worker process, a way to give it the file, a way to get the result back, and retry logic for when the worker dies mid-encode.
  3. You add a second worker. Now two processes fight over the same CPU. Encoding gets slower per job, not faster, unless you're careful about threading. FFmpeg will happily use every core it can find, which means two FFmpeg processes on a 4-core box often finish later than one process would have.
  4. You need bigger inputs. Someone uploads a 4K, 20-minute file. Your worker's memory and disk fill up. The encode dies at 80% and you have nothing to show for the six minutes it ran.
  5. You get a support ticket. "My video is stuck at 'processing' for an hour." You have no idea which worker it's on or whether it's still alive.
  6. The bill arrives. You've been paying for a machine sized for peak load, sitting idle most of the day.

None of these are FFmpeg problems. FFmpeg is doing exactly what it should. They're operations problems wearing a video costume.

The cost nobody calculates: idle capacity

Here's the part that's easy to miss. If you size your server for the worst case, you're paying for that worst case 24 hours a day. A box that's the right size for your Tuesday spike is maybe 8% utilized the rest of the week. And if you size it for the average, you fall over on Tuesday.

That's the deal with self-hosted media processing: you either overpay or you under-deliver, and the sweet spot moves every time your traffic pattern changes. There's no static configuration that handles a viral moment and a quiet Sunday morning equally well.

What "Scalable Video Apps" Actually Means

Before we go further, let's separate the layers. A lot of confusion comes from treating "video processing" as one thing when it's really three.

Three layers, three different problems

LayerWhat it doesHow you scale it
Product logicUploads, permissions, metadata, UI, notificationsStandard app architecture. Scales like any web app.
Media workThe actual encode, transcode, filter, or conversionCPU-hungry, bursty, unpredictable. This is the hard part.
Storage and deliveryHolding the files, serving them, CDNObject storage plus a CDN. Solved problem.

Your product logic scales fine. Storage scales fine. The middle layer is the one that behaves badly, because media jobs are:

  • Long-running. A job can take 20 seconds or 20 minutes.
  • Resource-hungry. Encoding is one of the few things left that genuinely pins a CPU for its entire duration.
  • Bursty. Traffic arrives in clumps, not curves.
  • Variable. No two files are the same size, duration, or codec.

You can't treat a 20-minute encode the same way you treat a 50ms database query. If you do, you'll build something that works in testing and falls apart in production.

Why queues alone don't fix it

Adding a queue is the correct first instinct, and it does solve one real problem: it decouples the request from the work. The user gets a fast "we're processing this" response instead of a spinning browser tab.

But a queue is a buffer, not a capacity plan. It doesn't give you more CPUs. It just moves the waiting somewhere less visible. If your arrival rate exceeds your processing rate for long enough, the queue grows without bound and every user sees a longer and longer wait.

To actually keep up, you need the ability to spin up more capacity when the queue grows, and release it when the queue drains. Which means you're now managing autoscaling infrastructure, and that's the job you were trying to avoid.

How a Serverless FFmpeg API Works

This is where the model gets nice. Instead of running FFmpeg somewhere you own, you send the command to a service that runs it for you and hands back the result. FFmpeGo is built exactly around this idea: you describe the job as JSON, it runs the process, you get the output URL.

One endpoint, one JSON payload

The core idea is simple enough to explain in a sentence: you POST a payload containing your input file URL and the FFmpeg arguments you want executed. The service spins up the compute, runs your command, writes the result somewhere you can fetch it, and responds when it's done.

Nothing about this is magic, and that's the point. The mental model is the same one you'd use locally:

  • Where's the input? (a URL)
  • What do you want done? (your FFmpeg arguments)
  • Where should the output go? (a URL you control)

That's the whole contract. If you already know FFmpeg, you already know how to use it. There's no proprietary filter DSL to learn, no limited set of "convert this to that" presets that break the moment you need something slightly unusual.

Compute seconds, not instance hours

Pricing is where the serverless model earns its keep. The typical unit is the compute second: the wall-clock time the FFmpeg process actually spent running. A job that takes 12 seconds costs 12 compute seconds. A job that takes 90 seconds costs 90.

Compare that to paying for a machine by the hour. If you run a server 730 hours a month and it processes 40 hours of actual video, you paid for 730 hours to do 40 hours of work. That's an 18x multiplier on idle. For a lot of small and mid-sized apps, that's the single biggest line item in the media pipeline, and it's pure waste.

There's a second detail worth noticing: billing that only counts successful jobs. If a command fails because you mistyped a filter name or the input file was corrupt, you don't pay for the compute that ran before the failure. For anyone iterating on a complex command, that changes how you work. You can experiment freely instead of rationing your test runs.

Arbitrary commands versus preset APIs

There's a whole category of "video API" products that offer a fixed menu: convert to MP4, extract a thumbnail, trim a clip. Those are fine for simple needs, and the tradeoff is real—simple API, simple results.

But the moment you need something off-menu, you're stuck. Maybe you need:

  • Two-pass encoding with a specific target bitrate for a broadcast deliverable
  • A filter_complex graph that overlays a logo and ducks background music when narration starts
  • Loudness normalization to EBU R128 for podcast distribution
  • A tiled contact sheet showing thumbnails every 10 seconds across a whole video

Preset APIs don't do those. FFmpeg does, and a serverless API that accepts arbitrary arguments lets you use all of it. You get the full codec and filter library—the same thing you'd have on your laptop—without the machine underneath.

Wiring a Serverless FFmpeg API Into a Real App

Let's walk through a realistic integration, step by step. We'll use a common pattern: user uploads a file to object storage, your backend kicks off a job, the result lands in a bucket, and you tell the user it's ready.

Step 1: Get the input somewhere reachable

The API needs to be able to download your input. The cleanest approach is to upload the raw file to object storage (S3, R2, GCS, Backblaze B2—whatever you use) and hand over a URL.

Two practical notes here:

  • Use a presigned URL with a generous expiry if your bucket is private. One hour is usually plenty, but check what your service allows.
  • Don't pass URLs that require cookies or session auth. The fetcher is a server, not a browser. A signed URL is the way.

Step 2: Build the arguments

Write out the FFmpeg command exactly as you would locally, then split it into the arguments array. For a standard web-friendly transcode:

-i input.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4

A few habits that save you headaches:

  • Always add -movflags +faststart for MP4 output. Without it, the moov atom sits at the end of the file and the video won't start playing until the whole thing downloads. It costs a couple of seconds of processing and it's the difference between instant playback and a stalled player.
  • Be explicit about the output format. A bare output.mp4 usually works, but adding -f mp4 removes ambiguity.
  • Keep -y in mind. Overwriting behavior varies, so don't rely on it either way.

Step 3: Send the request

The call itself is boring, which is the nicest thing you can say about infrastructure. Here it is via curl:

curl -X POST https://api.ffmpego.com/v2/run \
  -H "Authorization: Bearer $FFMPEGO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [
      "https://your-bucket.s3.amazonaws.com/uploads/raw/clip-9f3a.mp4"
    ],
    "args": [
      "-i", "input.mp4",
      "-c:v", "libx264",
      "-crf", "23",
      "-preset", "medium",
      "-c:a", "aac",
      "-b:a", "128k",
      "-movflags", "+faststart",
      "output.mp4"
    ],
    "outputs": [
      "https://your-bucket.s3.amazonaws.com/processed/clip-9f3a.mp4"
    ]
  }'

And the same thing in Node, wrapped in a function you can reuse:

async function transcode(inputUrl, outputUrl) {
  const res = await fetch('https://api.ffmpego.com/v2/run', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.FFMPEGO_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      inputs: [inputUrl],
      args: [
        '-i', 'input.mp4',
        '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
        '-c:a', 'aac', '-b:a', '128k',
        '-movflags', '+faststart',
        'output.mp4',
      ],
      outputs: [outputUrl],
    }),
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Encode failed (${res.status}): ${body}`);
  }

  return res.json();
}

Python, for the automation crowd:

import os, requests

def transcode(input_url: str, output_url: str) -> dict:
    resp = requests.post(
        "https://api.ffmpego.com/v2/run",
        headers={"Authorization": f"Bearer {os.environ['FFMPEGO_API_KEY']}"},
        json={
            "inputs": [input_url],
            "args": [
                "-i", "input.mp4",
                "-vf", "scale=-2:720",
                "-c:v", "libx264", "-crf", "24",
                "-c:a", "aac", "-b:a", "128k",
                "output.mp4",
            ],
            "outputs": [output_url],
        },
        timeout=600,
    )
    resp.raise_for_status()
    return resp.json()

Step 4: Handle the response honestly

Your code should treat a non-2xx response as a real failure, not something to shrug off. Log the error body. It's usually an FFmpeg stderr excerpt, and it tells you exactly which argument FFmpeg rejected.

The most common causes of a failed job, in my experience:

  • A typo in a filter name (-vf value) or a codec that isn't compiled in
  • Mismatched stream labels in a filter_complex graph
  • An input URL that expired or was never public
  • A missing output filename—FFmpeg always needs a final positional output argument

Also worth doing: compare the file you expected against what actually landed. An encode that "succeeded" but produced a 4 KB file usually means a stream got dropped somewhere.

Step 5: Decide between polling and waiting

There are two shapes of integration.

Synchronous-ish: your request stays open until the job finishes, then returns. This is simplest, and it works great for short jobs—under a minute or so. It falls apart when the encode takes fifteen minutes and your HTTP client or serverless function times out first.

Asynchronous: you fire off the job, store a job ID or your own record, and check on it later. For long encodes, this is the only sane approach. Combine it with a status field on your own record—pending, processing, ready, failed—and show that in the UI. Users are remarkably patient when they can see progress and remarkably impatient when they can't.

One more thing: make your job submission idempotent. If a retry fires twice, you don't want two encodes writing to the same output path and racing each other. A unique key per job, checked before submission, costs you ten lines and prevents a whole category of weird bug reports.

Multi-Input Jobs and filter_complex Without the Headache

Single-file transcodes are the easy half. The interesting work starts when you need to combine things.

Here's where a service that accepts arbitrary commands really shows its value. A preset API can't do any of the following. A filter_complex graph can.

Watermarking a whole library

Drop a logo in the corner of every video, scaled to 12% of the video width, with a bit of padding:

-i input.mp4 -i logo.png \
-filter_complex "[1:v]scale=iw*0.12:-1,format=rgba,colorchannelmixer=aa=0.75[wm];[0:v][wm]overlay=W-w-20:H-h-20" \
-c:a copy output.mp4

The format=rgba plus colorchannelmixer=aa=0.75 combination is what gives you a semi-transparent PNG overlay. Without it, you get a hard-edged logo that looks pasted on.

Concatenating clips into one file

Say you're building an ad from three segments:

-i intro.mp4 -i body.mp4 -i outro.mp4 \
-filter_complex "[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" -c:v libx264 -crf 22 -c:a aac output.mp4

If your clips have different resolutions or frame rates, concat will produce garbage or fail outright. Normalize each input first with a scale and fps filter, or pre-process them into matching intermediates. This is the single most common filter_complex mistake.

Side-by-side comparison videos

Useful for product demos, before/after content, or debugging two encoding settings:

-i left.mp4 -i right.mp4 \
-filter_complex "[0:v]scale=640:-2[l];[1:v]scale=640:-2[r];[l][r]hstack=inputs=2[v]" \
-map "[v]" -map 0:a? -c:v libx264 -crf 23 -c:a aac output.mp4

The ? on -map 0:a? makes the audio mapping optional, so the job doesn't fail on a silent input. Small detail, saves a support ticket.

Why this matters for your product

Once you can run arbitrary graphs, features that used to be "someday" become afternoon projects. Auto-generated highlight reels. Branded exports for every customer tier. A pipeline that takes a raw screen recording and produces a captioned, logo'd, loudness-normalized final cut.

And because you're not managing servers, none of this changes your ops burden. You add a filter, you test it in a few seconds, you ship it.

Practical Use Cases That Actually Show Up in Production

Theory is fine. Here's what people actually build.

User-generated content platforms

The classic case, and the one that breaks most often. Users upload anything: HEVC phone videos, vertical clips, 4K screen recordings, files with weird audio codecs, occasionally a file that isn't really a video at all.

Your pipeline needs to produce a predictable set of outputs:

  • A web-playable MP4 (H.264/AAC) for broad browser support
  • A lower-resolution version for mobile users on slow connections
  • A poster image from around the 10% mark of the video

Because jobs only bill on success, you can throw a validation pass at every upload without worrying about paying for rejects. Run a quick ffprobe-style check, and if the file is unreadable, reject it early instead of feeding it into an encode that will fail three minutes in.

Podcasters turning episodes into video clips

A podcast host uploads a 90-minute episode with separate audio and camera tracks. The pipeline produces a full audio master with loudness normalization plus six short vertical clips for social.

The loudness pass is worth calling out, because it's a real-world need that preset APIs almost never cover:

-i episode.wav -af loudnorm=I=-16:TP=-1.5:LRA=11 -ar 48000 output.wav

-16 LUFS is the common target for stereo podcast delivery. Get this wrong and listeners are constantly adjusting their volume between shows, which is a genuinely annoying experience and a hard thing to debug from a support ticket.

E-commerce product videos

Retailers need consistent output across a catalog of thousands of SKUs, all shot on different phones by different people. The job is standardization:

  1. Scale everything to a fixed canvas size with letterboxing rather than cropping (so no product gets cut off)
  2. Normalize frame rate to 30fps
  3. Apply a consistent light compression so the catalog pages load quickly
  4. Add a small brand watermark

This is a batch job, and batch jobs are where the compute-seconds model shines. You don't pay for the hours the queue sits idle overnight, and you don't need to keep a fleet of workers warm for a nightly run.

Automated thumbnail contact sheets

For long videos, a single thumbnail doesn't tell a viewer much. A tiled sheet of frames every 20 seconds gives a real preview:

-i webinar.mp4 -vf "fps=1/20,scale=320:-1,tile=4x5" -frames:v 1 sheet.jpg

Cheap, fast, and it makes your library browsable in a way individual thumbnails can't match.

Automation engineers gluing systems together

Not every user is building a consumer app. Plenty of people use a serverless FFmpeg API as a piece of a larger automation—a Zapier-style workflow, an internal tool, an ETL job that happens to include media. In those contexts, the value is that FFmpeg becomes a callable function in a system that has no servers at all. No VM to maintain, no cron job on a machine someone forgot about.

Common Mistakes That Cost You Money and Time

I've seen these enough times to write them down.

1. Passing local file paths. -i ./uploads/video.mp4 won't work. The API runs on a different machine. Everything must be a URL.

2. Forgetting -movflags +faststart. Your MP4 plays fine locally because you're reading it from disk. Over HTTP, it stalls. This one wastes an embarrassing amount of debugging time.

3. Using -preset slow for user-facing uploads. Quality per bitrate improves, but the encode takes far longer, and you're billed by the second. medium or fast is the sweet spot for most web video. Reserve slow for archival or broadcast work.

4. Encoding before you know the input. If a file is 45 minutes of 4K, a naive re-encode might take longer than you expect. Probe first, then decide the settings. You'll save compute seconds and avoid timeouts.

5. Not setting a maximum file size. Someone will upload a 4 GB file. Decide what you accept before you're paying to process it.

6. Ignoring -map. Without explicit mapping, FFmpeg picks streams based on its own heuristics. For multi-stream inputs—background music, commentary tracks, multiple camera angles—always map what you want.

7. Retrying failed jobs blindly. If a job failed because the input was corrupt, retrying it three times just wastes time. Distinguish between transient failures (network timeouts) and deterministic ones (bad arguments, unreadable input).

8. Writing results to a public bucket by default. Signed output URLs or a private bucket with a CDN in front. Otherwise your customers' uploads are browsable by anyone who guesses a filename.

9. Hardcoding your API key. Environment variables, a secrets manager, whatever your platform provides. Not in the repo.

10. Not setting a monthly cap. Usage-based pricing is great until a runaway loop submits ten thousand jobs. Set a hard cap so the worst-case bill is a number you chose in advance, not a surprise.

The Cost Math, Honestly

Let me show the shape of the comparison without pretending I know your exact numbers.

Say you process 500 short videos a month, averaging 40 seconds each, at -preset medium for a 720p output.

Self-hosted, small instance:

ItemRough monthly figure
4 vCPU / 8 GB instance running 24/7~$60–$100
Storage and egress~$5–$20
Your time on ops, monitoring, incidents?

The instance line is fixed. It costs the same whether you process 500 videos or 5. That's fine when you're busy and annoying when you're not—and it's the reason self-hosting feels cheap right up until you need a second machine for peak load.

Usage-based:

500 jobs × 40 seconds = ~20,000 compute seconds. At typical rates in this category, that lands in the single-digit-to-low-double-digit dollar range per month. When traffic triples, the bill triples, and the service absorbs the load without you touching anything.

The real comparison isn't the dollar figure, though. It's this: what's an hour of your time worth? If you spend four hours a month on queue tuning, worker restarts, and "why is the encode stuck" tickets, and your hourly rate is anything normal, you've already spent more than the compute costs.

The other quiet win is the free tier. It's enough to build and test a real integration before committing a dollar, which is exactly the right way to evaluate a piece of infrastructure. You should be able to run your actual workload against it, not a sandbox with fake data.

A Pre-Launch Checklist for Your Media Pipeline

Before you ship video features to real users, walk through this.

  • Inputs are URLs, not paths. Every job references object storage or a signed URL.
  • Outputs land in your own storage. You control retention; the service just writes the file.
  • Job records exist in your database. You have a status, a timestamp, and a link to the output. Without this, you can't answer "where's my video?"
  • Failures are visible. Failed jobs surface in your UI with a plain-language message, and they show up in your logs too.
  • Retries are bounded. Max two or three attempts, with a rule for which errors are retryable.
  • A monthly cap is set. You know your worst-case bill before you launch.
  • Long jobs don't block HTTP requests. Anything over a minute goes async.
  • You've tested the ugly inputs. Silent video. Vertical video. A file that's only audio. A 5-second clip and a 40-minute one.
  • Cost per job is tracked. Log the compute seconds for every job. Ten lines of code, and it turns your billing forecast from a guess into a calculation.
  • Storage lifecycle rules exist. Old source files and intermediate outputs should expire. Storage creep is silent and permanent.

The ugly-inputs test is the one people skip, and it's the one that finds the most bugs. Real user uploads are far weirder than anything in your test fixtures.

Where FFmpeGo Fits

If you've read this far and the shape of the problem feels familiar, the pitch is straightforward.

FFmpeGo is a serverless FFmpeg API. You send a JSON payload with your input URLs and the exact FFmpeg arguments you want run, and it handles the rest—spinning up compute, executing the command, and writing your output wherever you point it. One endpoint, /v2/run, covers everything from a simple format conversion to multi-input jobs with full filter_complex graphs.

What that buys you specifically:

  • No containers, no workers, no autoscaling groups. The infrastructure side of the pipeline becomes an HTTP call.
  • Arbitrary commands. Not a menu of presets. If FFmpeg can do it, you can run it—including the watermarks, loudness normalization, overlay graphs, and concatenations we covered above.
  • Compute-second billing. You pay for wall-clock execution time, so a quiet week costs less than a busy one.
  • Success-only charging. Failed jobs that return non-2xx responses don't bill. You can test freely.
  • Hard monthly caps. The worst-case bill is a number you choose.
  • A free tier. Enough to build and validate a real integration before spending anything.

For indie developers shipping a feature this week, and for SaaS teams who'd rather not add "media infrastructure" to the on-call rotation, it removes the part of the job that isn't actually your job.

FAQ

Do I need to learn anything new to use it? Not really, and that's the point. If you can write an FFmpeg command, you can use the API. The only new concepts are that inputs must be URLs and you send the command as a JSON array of arguments instead of a shell string. If you've ever built a command in code with an args array, the mental leap is zero.

What happens if my FFmpeg command has a typo? The job fails and returns a non-2xx response with the FFmpeg error output, which usually names the exact argument it didn't like. Since failed jobs aren't billed, you can iterate on a tricky command without watching a meter spin. Use /v2/run while you're developing—it's the same endpoint you'll use in production.

Can I run multiple inputs, like a video plus a logo overlay? Yes. Multi-input jobs and filter_complex graphs are supported, which is where this kind of service separates itself from preset-based video APIs. Watermarking, concatenation, side-by-side layouts, and audio ducking all work the way you'd expect.

How do I handle jobs that take several minutes? Go asynchronous. Submit the job, store a record in your own database, and check on it later or have your completion logic fire when the output lands. Blocking an HTTP request for a fifteen-minute encode is fragile regardless of which service you use—your client, your load balancer, or your serverless function will time out first.

What if I don't know how long an encode will take? Probe first. Run a quick metadata check on the input to get duration, resolution, and codec, then choose your preset and scaling accordingly. A 4K, 40-minute source at -preset slow is a very different job from a 30-second 720p clip, and treating them the same is how you end up with timeouts and surprise compute bills.

Is this cheaper than running my own server? For bursty workloads, almost always. For a pipeline running near 100% utilization around the clock on hardware you already own, sometimes not. The honest answer is that the comparison usually isn't close once you count the time you spend managing queue depth, worker health, and scaling rules. Run your own numbers with your own job durations—the arithmetic is simple, and you should do it rather than trust a blog post.

Do I still need object storage? Yes. You need somewhere to put the raw upload and somewhere for the output to land. That's a good thing—it means you own your files, control retention, and can put a CDN in front of them. The API handles the processing; storage and delivery stay in your hands.

What to Do Next

Start small and specific. Pick the one media feature you've been putting off—the transcode you've been hand-waving, the watermark pass you keep scheduling for "next sprint," the loudness normalization that would make your podcast sound consistent—and build it as a single API call.

  1. Upload a test file to your existing object storage and generate a signed URL.
  2. Write the FFmpeg arguments exactly as you would on your laptop, then split them into an array.
  3. POST it to /v2/run and look at what comes back. Ten minutes, most likely.
  4. Check the output. Is it the right resolution? Does it start playing immediately over HTTP? Does the audio sound right?
  5. Wrap it in a function with an error path, a status record, and a monthly cap.
  6. Then delete the worker service you were maintaining, and go do something more interesting with the afternoon.

The thing worth remembering is that your video feature was never really about servers. It was about the command—the one you already knew how to write. Everything between that command and your users is overhead, and overhead is exactly the kind of thing worth handing off. Build the product. Let someone else worry about the CPU graphs.