Homelab
Proof

U4 / Apps under test

Jellyfin CPU transcoding on 4 vCPU: how many streams?

Tested on Jellyfin 12.4.0.0

Test result

On 4 vCPU of a shared AMD Ryzen 5 PRO 3600 host, Jellyfin 12.4 software-transcoded 1080p H.264 to 720p H.264 with up to 13 streams at once before the slowest fell below real time (0.93x at 14), and 10 streams from a 1080p HEVC file. Direct play of the same files used under 1 % of one vCPU. The host was busy (load average 8.6 at the start), so the ceilings could move by about a stream. This says nothing about Quick Sync or any GPU.

Line drawing: a film reel feeding a strip of film through a CPU chip, with narrower film coming out the other side.

Software transcoding is the part of a Jellyfin server that no CPU spec sheet answers directly, so this test measured it: one fixed source, the server’s default settings, and a growing number of simultaneous streams until one dropped below real time.

Source material

The video is Big Buck Bunny, which the Blender Foundation releases under Creative Commons Attribution 3.0 (“(c) copyright 2008, Blender Foundation / www.bigbuckbunny.org”). The test cut a 60 s excerpt (from 00:01:00) out of the official 1080p H.264 .mov without re-encoding: 1920x1080, 24 fps, about 6.9 Mb/s video and 5.1 AAC audio. A second 60 s file is the same excerpt re-encoded with the image’s own jellyfin-ffmpeg (libx265, -preset veryfast, -crf 23, which took 92 s): 1080p HEVC at about 1.6 Mb/s. The HEVC file is therefore a lower-bitrate, low-effort encode and not a typical release file.

How the streams were made

Each stream was an HTTP request to /Videos/{id}/stream.ts with VideoCodec=h264, MaxWidth=1280, MaxHeight=720, VideoBitRate=4000000 and AAC stereo audio. Hardware acceleration was none. Jellyfin’s own transcode log shows the command it ran: libx264 with -preset veryfast -crf 23 (the encoder preset setting was auto) and a 4 Mb/s -maxrate. The test client read each stream as fast as the server produced it and divided the 60 s of media by the request’s wall time. That number is the real-time factor: 1.0x means the transcode kept up with playback exactly. N streams were started at the same moment, 3 times per N, and N was raised until the slowest stream’s median fell below 1.0x.

Transcoding settings in the Jellyfin image (defaults)
SettingValue
HardwareAccelerationType (encoding.xml)none
EncoderPreset (encoding.xml)auto
H264Crf (encoding.xml)23
EnableThrottling (encoding.xml)false
EncodingThreadCount (encoding.xml)-1
ffmpeg video encoder in the logged commandlibx264
`-preset` in the logged commandveryfast
`-crf` in the logged command23
x264 options in the logged commandsubme=0:me_range=16:rc_lookahead=10:me=hex:open_gop=0

Read from /config/config/encoding.xml and the newest FFmpeg.Transcode log inside the container after the runs.

Simultaneous software transcodes to 720p H.264 + AAC stereo, Jellyfin default settings
SourceStreams at onceSlowest stream, real-time factor (3 runs)Median streamJellyfin CPU, mean / peak (% of one vCPU)Slowest stream at least 1.0x
1080p H.264 (24 fps, about 6.9 Mb/s)18.84x (7.35 to 9.02x)8.84x311 / 397yes
1080p H.264 (24 fps, about 6.9 Mb/s)24.93x (4.72 to 5.14x)4.93x345 / 403yes
1080p H.264 (24 fps, about 6.9 Mb/s)33.60x (3.52 to 3.60x)3.60x364 / 401yes
1080p H.264 (24 fps, about 6.9 Mb/s)42.54x (2.51 to 2.59x)2.56x399 / 409yes
1080p H.264 (24 fps, about 6.9 Mb/s)51.97x (1.91 to 1.99x)1.98x396 / 406yes
1080p H.264 (24 fps, about 6.9 Mb/s)61.60x (1.59 to 1.93x)1.62x400 / 405yes
1080p H.264 (24 fps, about 6.9 Mb/s)71.86x (1.86 to 1.88x)1.88x399 / 400yes
1080p H.264 (24 fps, about 6.9 Mb/s)81.63x (1.63 to 1.64x)1.64x378 / 401yes
1080p H.264 (24 fps, about 6.9 Mb/s)91.45x (1.45 to 1.45x)1.46x396 / 401yes
1080p H.264 (24 fps, about 6.9 Mb/s)101.30x (1.30 to 1.30x)1.31x399 / 400yes
1080p H.264 (24 fps, about 6.9 Mb/s)111.18x (1.18 to 1.18x)1.19x384 / 401yes
1080p H.264 (24 fps, about 6.9 Mb/s)121.09x (1.08 to 1.09x)1.09x391 / 401yes
1080p H.264 (24 fps, about 6.9 Mb/s)131.00x (1.00 to 1.01x)1.01x399 / 401yes
1080p H.264 (24 fps, about 6.9 Mb/s)140.93x (0.93 to 0.93x)0.94x388 / 401no
1080p HEVC (24 fps, about 1.6 Mb/s)110.31x (10.30 to 10.40x)10.31x258 / 391yes
1080p HEVC (24 fps, about 1.6 Mb/s)25.40x (5.38 to 5.40x)5.41x367 / 400yes
1080p HEVC (24 fps, about 1.6 Mb/s)33.60x (3.59 to 3.60x)3.60x355 / 401yes
1080p HEVC (24 fps, about 1.6 Mb/s)42.69x (2.69 to 2.70x)2.70x367 / 400yes
1080p HEVC (24 fps, about 1.6 Mb/s)52.15x (2.12 to 2.15x)2.16x371 / 401yes
1080p HEVC (24 fps, about 1.6 Mb/s)61.79x (1.79 to 1.79x)1.79x394 / 401yes
1080p HEVC (24 fps, about 1.6 Mb/s)71.53x (1.53 to 1.53x)1.54x395 / 401yes
1080p HEVC (24 fps, about 1.6 Mb/s)81.34x (1.34 to 1.35x)1.35x381 / 402yes
1080p HEVC (24 fps, about 1.6 Mb/s)91.19x (1.19 to 1.19x)1.19x385 / 401yes
1080p HEVC (24 fps, about 1.6 Mb/s)101.07x (1.07 to 1.07x)1.07x399 / 401yes
1080p HEVC (24 fps, about 1.6 Mb/s)110.97x (0.96 to 0.97x)0.97x399 / 401no

Real-time factor = 60 s of media divided by the wall time of the request, the stream consumed as fast as the server produces it. Median of 3 runs with the range in parentheses. 400 % CPU is all 4 vCPUs. CPU figures come from docker stats samples about 2 s apart (N=1 rows have only 2 to 4 samples). All N streams start together, so the slowest stream's last seconds run on a less busy CPU. The host load average at the start of the run was 8.59 8.29 8.08 (env.txt), well above the other tests, so the host was shared with other jobs; the 13 and 10 stream ceilings could move by about one stream. VM 4 vCPU, 4 GB, no GPU, hardware acceleration set to none.

Most simultaneous transcodes that kept every stream at 1.0x or better
SourceStreams
1080p H.264 (24 fps, about 6.9 Mb/s)13
1080p HEVC (24 fps, about 1.6 Mb/s)10

Highest N whose median slowest-stream factor over 3 runs was at least 1.0. The first N that fell below 1.0 stopped the search for that source.

  • One stream ran at 8.84x (H.264 source) and 10.31x (HEVC), using about 311 % and 258 % of one vCPU on average, so a single stream already spreads across most of the 4 cores.
  • With the CPU saturated (about 400 % from 4 streams on), the slowest stream’s rate fell roughly in inverse proportion to N: 2.54x at 4 streams, 1.30x at 10, 1.00x at 13, 0.93x at 14 for H.264, and 1.07x at 10 and 0.97x at 11 for HEVC.
  • The H.264 curve is not monotonic: the slowest stream was 1.60x at 6 streams (range 1.59 to 1.93x) and 1.86x at 7. The test did not look into why. The host was shared with other jobs, and its load average at the start of the run was 8.59 (against 0.35 to 2.12 for the other tests in this series), so noise from the host is likely in every row.
  • A 1080p HEVC source transcoded faster than the H.264 one for 1 to 6 streams (level at 3; probably because of its much lower bitrate, which the test did not separate from decode cost) but slower from 7 streams on (1.53x against 1.86x at 7), and it supported fewer streams at the limit (10 against 13). The 13-stream result is only just above real time (1.003 to 1.006x), so treat the ceiling as 13 plus or minus about one. The test did not separate decode cost from encode cost.

All N streams were started together and the faster ones finish early, so the slowest stream’s last seconds run on a less busy CPU. These streams also ran flat out, because EnableThrottling was false in the image’s encoding.xml. A real player pulls video at playback speed, so a sustained session may use less CPU than a stream that finishes a 60 s file in 7 s. Treat the stream count as a ceiling for 720p veryfast output on this CPU, not a promise: each real stream also needs the 1080p decode, audio work and headroom for the rest of the server.

Direct play

Direct play (no transcoding) served at the file's own bitrate
FileDuration of the transferBytes sentJellyfin CPU, mean / peak (% of one vCPU)CPU samples
1080p H.264 (24 fps, about 6.9 Mb/s)59.3 s55.2 MB0.65 / 5.629
1080p HEVC (24 fps, about 1.6 Mb/s)59.8 s15.1 MB0.40 / 5.830

GET /Videos/{id}/stream?static=true, with curl limited to the file's average byte rate so the transfer lasts about as long as the video. This measures the server only, not a player decoding the video. CPU samples are about 2 s apart (docker stats --no-stream).

Direct play served each file at its own average bitrate (a curl rate limit), and Jellyfin used 0.65 % (H.264) and 0.40 % (HEVC) of one vCPU on average, with 5.6 % and 5.8 % peaks in the CPU samples, which are about 2 s apart (29 and 30 samples over about 60 s). The single-stream CPU rows in the table rest on only 2 to 4 samples for the same reason. This measures the server only: a client that cannot decode HEVC, or has a different subtitle or audio need, would force a transcode. Which clients can direct play which files was not tested.

Jellyfin web interface Movies page with two items, Big Buck Bunny and Big Buck Bunny HEVC, 2008

What this says about hardware

The Jellyfin hardware docs say that HEVC, VP9 and AV1 can be very demanding on the CPU without hardware acceleration, and recommend a GPU for transcoding. This test used the CPU only, with HardwareAccelerationType set to none. It says nothing about Intel Quick Sync, VAAPI, NVENC or any other hardware path, which can change these numbers by a large factor that was not measured here. It also did not test HDR tone mapping, which the transcoding docs describe as very slow in software.

compose.yml, as tested (Jellyfin 12.4)
services:
  jellyfin:
    image: jellyfin/jellyfin:12.4
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8096"
    volumes:
      - jf_config:/config
      - jf_cache:/cache
      - ./media:/media:ro
volumes:
  jf_config:
  jf_cache:
Idle footprint, measured on the reference test box. Median of 12 samples after settling.
StackVersionRAM at idleCPU at idleImages on diskReady after
Jellyfin12.4149 MiB0.01%2490 MB3 s
lab/tests/jellyfin/bench.py
Show the full file
#!/usr/bin/env python3
"""Runs in the VM. bench.py <outdir> [max_streams=14] [reps=3]
For each source (H.264 1080p, HEVC 1080p; 60 s excerpts of Big Buck Bunny) and N = 1, 2, 3 ... simultaneous software transcodes to
720p H.264 + AAC stereo (GET /Videos/{id}/stream.ts, progressive, consumed as fast as the server produces it), 3 reps each.
Real-time factor (RTF) of a stream = media duration / wall time of the request. Stops raising N for a source when the slowest
stream of the median rep drops below 1.0x. Direct play: GET /Videos/{id}/stream?static=true paced at the file's own bitrate."""
import sys, os, json, time, subprocess, uuid, statistics as st
out = sys.argv[1]; MAXN = int(sys.argv[2]) if len(sys.argv) > 2 else 14; REPS = int(sys.argv[3]) if len(sys.argv) > 3 else 3
os.makedirs(out, exist_ok=True); d = json.load(open(os.path.expanduser("~/jf/ids.json"))); TOK = d["token"]; DUR = 60.04
items = {"h264": d["items"]["Big Buck Bunny"], "hevc": d["items"]["Big Buck Bunny HEVC"]}
def url(it, transcode=True):
    base = f"http://127.0.0.1:8080/Videos/{it['id']}/"
    if not transcode: return base + f"stream?static=true&DeviceId=lab-dp&MediaSourceId={it['source']}&api_key={TOK}"
    return (base + f"stream.ts?DeviceId=lab-{uuid.uuid4().hex[:8]}&MediaSourceId={it['source']}&VideoCodec=h264&AudioCodec=aac&MaxWidth=1280&MaxHeight=720"
            f"&VideoBitRate=4000000&AudioBitRate=128000&TranscodingMaxAudioChannels=2&PlaySessionId={uuid.uuid4().hex}&api_key={TOK}")
def sh(c): return subprocess.run(c, shell=True, capture_output=True, text=True).stdout
cname = sh("docker ps --format '{{.Names}}' | grep jellyfin").strip().splitlines()[0]
def stats_loop(path):  # CPU/RAM samples of the Jellyfin container, about 2 s apart (docker stats --no-stream takes about 2 s)
    return subprocess.Popen(f"while true; do echo $(date +%s.%N),$(docker stats --no-stream --format '{{{{.CPUPerc}}}},{{{{.MemUsage}}}}' {cname}); sleep 0.5; done > {path}", shell=True, preexec_fn=os.setsid)
def stop(p):
    import signal; os.killpg(os.getpgid(p.pid), signal.SIGTERM)
res = {"runs": [], "container": cname}
def run_n(codec, n, rep):
    csv = f"{out}/cpu-{codec}-n{n}-r{rep}.csv"; sp = stats_loop(csv); time.sleep(2)
    t0 = time.time(); procs = [subprocess.Popen(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code} %{time_total} %{size_download}", url(items[codec])], stdout=subprocess.PIPE, text=True) for _ in range(n)]
    rows = []
    for p in procs:
        o = p.communicate()[0].split(); rows.append({"http": o[0], "secs": float(o[1]), "bytes": int(o[2]), "rtf": DUR / float(o[1])})
    t1 = time.time(); time.sleep(2); stop(sp)
    cpu = []
    for l in open(csv):
        try: ts, c, m = l.strip().split(",", 2); ts = float(ts)
        except ValueError: continue
        if t0 <= ts <= t1: cpu.append((float(c.strip("%")), m))
    r = {"codec": codec, "n": n, "rep": rep, "t_start": t0, "t_end": t1, "streams": rows, "min_rtf": min(x["rtf"] for x in rows), "mean_rtf": st.mean(x["rtf"] for x in rows),
         "cpu_mean": st.mean(c for c, _ in cpu) if cpu else None, "cpu_peak": max(c for c, _ in cpu) if cpu else None, "cpu_samples": len(cpu)}
    res["runs"].append(r); print(json.dumps({k: v for k, v in r.items() if k != "streams"}), flush=True); return r
for codec in ("h264", "hevc"):
    for n in range(1, MAXN + 1):
        rr = [run_n(codec, n, rep) for rep in range(1, REPS + 1)]
        if st.median(r["min_rtf"] for r in rr) < 1.0: break
    sh("sleep 3")
# ffmpeg command lines and the speed it reported, from Jellyfin's own transcode logs
sh(f"docker exec {cname} sh -c 'cp /config/encoding.xml /tmp/enc.xml 2>/dev/null; true'")
open(f"{out}/encoding.xml", "w").write(sh(f"docker exec {cname} cat /config/config/encoding.xml 2>&1 || docker exec {cname} find /config -name encoding.xml"))
open(f"{out}/ffmpeg-cmdline-sample.txt", "w").write(sh(f"docker exec {cname} sh -c 'f=$(ls -t /config/log/FFmpeg.Transcode-*.log | head -1); head -n 12 $f | cut -c1-900'"))
open(f"{out}/ffmpeg-speed-lines.txt", "w").write(sh(f"docker exec {cname} sh -c 'for f in $(ls -t /config/log/FFmpeg.Transcode-*.log | head -20); do echo $f; tr \"\\r\" \"\\n\" < $f | grep -o \"speed=[0-9.]*x\" | tail -1; done'"))
# direct play at real-time pace: curl limited to the file's average byte rate
dp = []
for codec in ("h264", "hevc"):
    size = os.path.getsize(os.path.expanduser(f"~/jf/media/Big Buck Bunny {codec.upper()} (2008).mp4")); rate = int(size / DUR)
    csv = f"{out}/cpu-directplay-{codec}.csv"; sp = stats_loop(csv); time.sleep(2); t0 = time.time()
    o = subprocess.run(["curl", "-s", "-o", "/dev/null", "--limit-rate", str(rate), "-w", "%{http_code} %{time_total} %{size_download}", url(items[codec], False)], capture_output=True, text=True).stdout.split()
    t1 = time.time(); time.sleep(2); stop(sp)
    cpu = [float(l.split(",")[1].strip("%")) for l in open(csv) if l.count(",") >= 2 and t0 <= float(l.split(",")[0]) <= t1]
    dp.append({"codec": codec, "http": o[0], "secs": float(o[1]), "bytes": int(o[2]), "rate_Bps": rate, "cpu_mean": st.mean(cpu), "cpu_peak": max(cpu), "samples": len(cpu)}); print(json.dumps(dp[-1]), flush=True)
res["direct_play"] = dp
json.dump(res, open(f"{out}/bench.json", "w"), indent=1)

What went wrong

Jellyfin answered HTTP 503 for several seconds after the container reported healthy, so the setup script retries until the first-run wizard endpoints respond. A first dry run used a 110 s timeout that cut the run off before the direct-play step; the reported run has no timeout.

What this test did not cover

Sources and how this was made

lab/tests/jellyfin/run.sh
Show the full file
#!/usr/bin/env bash
# jellyfin: software transcoding on 4 vCPU. Big Buck Bunny (Blender Foundation, CC BY 3.0) 60 s excerpts: 1080p H.264 (stream copy of the
# official 1080p file) and a 1080p HEVC re-encode made with jellyfin-ffmpeg (libx265). Jellyfin 12.4 transcodes to 720p H.264 with its default settings.
set -uo pipefail
source "$(dirname "$0")/../_e/lib.sh"
T=jellyfin; D="$ROOT/lab/tests/$T"; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"
rm -rf "$OUT"; mkdir -p "$RAW" "$ROOT/public/shots/$T"
start_vm e-$T || exit 1
collect_env "$OUT"
vm scp-to "$D/jfsetup.py" jfsetup.py; vm scp-to "$D/bench.py" bench.py; gx 'mkdir -p ~/jf/media ~/jf/src'; vm scp-to "$ROOT/lab/stacks/jellyfin/compose.yml" jf/compose.yml
gx 'mv ~/jfsetup.py ~/bench.py ~/jf/; cd ~/jf; curl -sL -o bbb.zip https://download.blender.org/peach/bigbuckbunny_movies/big_buck_bunny_1080p_h264.mov.zip; sha256sum bbb.zip; unzip -o -q bbb.zip -d src; sha256sum src/*.mov; ls -l src; docker pull -q jellyfin/jellyfin:12.4 >/dev/null; docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}"' > "$RAW/source-download.txt" 2>&1
gx 'cd ~/jf; FF="docker run --rm --entrypoint /usr/lib/jellyfin-ffmpeg/ffmpeg -v $HOME/jf/src:/src:ro -v $HOME/jf/media:/media jellyfin/jellyfin:12.4 -hide_banner -loglevel error -y"
$FF -ss 60 -i /src/big_buck_bunny_1080p_h264.mov -t 60 -map 0:v:0 -map 0:a:0 -c copy "/media/Big Buck Bunny H264 (2008).mp4"
S=$(date +%s); $FF -i "/media/Big Buck Bunny H264 (2008).mp4" -map 0:v:0 -map 0:a:0 -c:v libx265 -preset veryfast -crf 23 -c:a copy -tag:v hvc1 "/media/Big Buck Bunny HEVC (2008).mp4"; echo "hevc_encode_secs=$(( $(date +%s) - S ))"
ls -l media; for f in media/*; do docker run --rm --entrypoint /usr/lib/jellyfin-ffmpeg/ffprobe -v $HOME/jf/media:/media:ro jellyfin/jellyfin:12.4 -hide_banner -v error -show_entries stream=codec_name,width,height,r_frame_rate,bit_rate:format=duration -of compact "/$f"; done' > "$RAW/excerpts.txt" 2>&1
gx 'cd ~/jf && docker compose up -d >/dev/null 2>&1; timeout 200 python3 jfsetup.py' > "$RAW/setup.txt" 2>&1
gx 'docker images jellyfin/jellyfin --format "{{.ID}}"; docker exec jf-jellyfin-1 /usr/lib/jellyfin-ffmpeg/ffmpeg -version | head -1; docker exec jf-jellyfin-1 /jellyfin/jellyfin --version 2>&1 | head -1; nproc' > "$RAW/versions.txt" 2>&1
gx 'cd ~/jf && nohup python3 bench.py ~/jfo > ~/jfo.log 2>&1 & echo started'
for _ in $(seq 1 400); do gx 'test -f ~/jfo/bench.json' 2>/dev/null && break; sleep 15; done
mkdir -p "$RAW/vm"; vm scp-from '~/jfo/.' "$RAW/vm/" ; cp "$RAW/vm/bench.json" "$RAW/bench.json"; gx 'cat ~/jfo.log' > "$RAW/bench.log"
tunnel 18092 8080
( cd "$D" && node shot.mjs http://127.0.0.1:18092 "$ROOT/public/shots/$T/home.png" ) > "$RAW/shot.txt" 2>&1
untunnel
python3 "$D/summarize.py" "$RAW" "$OUT"

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.