Homelab
Proof

U4 / Apps under test

FreshRSS vs Miniflux with 50 feeds and 1,000 entries

Tested on FreshRSS 1.30.4

Test result

Both readers imported the same 50-feed OPML and reached 1,000 entries per user. Miniflux refreshed everything in a median 1.18 s and used 72.2 MiB including PostgreSQL; FreshRSS took a median 13.50 s but idled at 17.3 MiB in a single container. Both refused to fetch from a private-network feed server until told to allow it.

Line drawing: a simple feeding conveyor carrying small blank newspaper-like cards into two small reader devices.

The numbers say Miniflux is much faster at pulling a batch of feeds and FreshRSS is lighter when idle. Which matters depends on how often you refresh and how much RAM the box has to spare, so the details below are what each cost on one test box.

Test setup

A local nginx served 50 generated Atom feeds of 20 entries each, 1,000 entries in all, with about 1.2 KB of generated text per entry. The feeds and the OPML file that lists them (51 files, 1,368,332 bytes) came from a fixed-seed script, so both readers got identical content. Each reader ran in its own fresh VM. Per reader there were three rounds, one new user per round: import the OPML, then refresh until 1,000 entries exist. FreshRSS used SQLite and its CLI (import-for-user.php, actualize-user.php); Miniflux used its API (POST /v1/import, then PUT /v1/feeds/refresh polled until the entry count reached 1,000).

Two things that had to be changed first

A feed server on the same Docker network is a private address, and both readers refuse those by default.

  • Miniflux: the configuration docs say FETCHER_ALLOW_PRIVATE_NETWORKS is disabled by default and private networks are refused. With the setting removed, all 50 feeds showed a parsing error (“refusing to access private network host”) and 0 entries arrived. The test sets it to 1.
  • FreshRSS: with the allowlist removed, the refresh logged “URL is not allowed to be resolved” for every feed URL and 0 entries arrived from the lab feeds. The comments in FreshRSS’s own config.default.php say requests to internal hosts such as 127.0.0.1 are blocked by default (including 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16) and describe an internal_host_allowlist setting that takes values such as rss-bridge:80. The test sets it through the INTERNAL_HOST_ALLOWLIST environment variable to feeds:80; the file’s comments do not name that variable, but with it the fetches worked.

FreshRSS had a third surprise: creating a user also subscribes that user to a default feed on GitHub (the FreshRSS releases feed), which showed up as a 51st feed and 10 entries from the internet. The CLI’s --no-default-feeds option removes it, and the benchmark runs use it. These three findings come from a separate check, private-check.sh, run on 2026-11-02 with both settings removed and the same 50 feeds; its output is in raw/private-freshrss.json and raw/private-miniflux.json.

Results

50 generated feeds, 1000 entries per user: import, first refresh, RAM and storage (seconds: median and range of 3 rounds)
ReaderOPML import (s)Refresh until 1000 entries (s)Entries after refresh, rounds 1/2/3Idle RAM before (MiB; Miniflux includes its PostgreSQL)Idle RAM after 3 rounds (MiB)Highest RAM sampled during a refresh (MiB)Storage
FreshRSS 1.30.4 (SQLite)0.69 (0.69-0.89)13.50 (12.71-15.41)1000/1000/100016.517.332.1 (32.1-32.8)11.5 MB whole data directory (3 users, 3,000 entries)
Miniflux 2.3.4 (PostgreSQL 18.6)0.22 (0.20-0.25)1.18 (1.17-1.22)1000/1000/100052.072.271.8 (69.0-103.7)18.5 MB database after 3 users (empty schema: 9.0 MB); 76.3 MB PostgreSQL volume on disk

Each round used a new user on the same instance, so rounds 2 and 3 ran with data from earlier rounds present. Feeds come from a local nginx, so there is no network latency and no feed is slow. The refresh-time RAM is the highest of the roughly one-per-second docker stats samples, so a short peak can be missed (Miniflux's refresh lasts about 1.5 s); for FreshRSS it includes the CLI process, which runs inside the container. FreshRSS refresh is its CLI `actualize-user.php`; Miniflux refresh is `PUT /v1/feeds/refresh` polled until the entry count reached 1000. VM 4 vCPU, 3 GB.

  • Every round reached exactly 1,000 entries and 50 feeds on both readers.
  • Miniflux’s refresh took 1.17 to 1.22 s; FreshRSS’s 12.71 to 15.41 s. The two were not timed the same way: FreshRSS’s figure is a docker exec of its PHP CLI, so it includes starting that process, while Miniflux’s is an API call followed by polling the entry count every 0.2 s. A plausible reason for the gap is parallel fetching: Miniflux’s docs give a default of 16 background workers. How FreshRSS’s CLI fetches was not checked, so treat that as a guess.
  • The two idle RAM columns are a quiet server. Miniflux grew from 52.0 to 72.2 MiB after three users’ worth of data; FreshRSS went from 16.5 to 17.3 MiB. The “highest sampled” column reflects docker stats taken about once a second, so Miniflux’s roughly 1.2 s refresh got one sample per round and a short spike could be missed; FreshRSS’s CLI refresh ran inside the container and was sampled seven or eight times.
  • Storage is not directly comparable: FreshRSS’s whole data directory is 11.5 MB for three users, while Miniflux’s PostgreSQL database is 18.5 MB, of which 9.0 MB was the empty schema before any user existed.
FreshRSS main stream showing 1,000 unread articles from feeds named Lab feed 50 downward, each titled Feed N entry 19
Miniflux unread list showing 1,000 unread entries with titles like Feed 10 entry 0, each from a feed named Lab feed N

What this says, and what it does not

On this test, if you refresh dozens of feeds often or run a shared server, Miniflux’s batch speed and API are the larger advantages; FreshRSS is the smaller process and its default stack is one container with SQLite. That is an opinion from one box and generated feeds. Real feeds are slow, rate-limited and sometimes broken, and none of that is in these numbers. The refresh times especially are best read as “how fast can the reader process responses that arrive instantly”.

Steps

Compose files (the lab stacks plus the feed server and the two private-network settings), the driver and the script:

lab/tests/feeds/files/freshrss.yml
Show the full file
# lab/stacks/freshrss/compose.yml minus the CRON_MIN schedule (so no background refresh interferes with the timings), plus a static feed server.
# FreshRSS refuses to fetch private addresses unless the host is allowlisted (INTERNAL_HOST_ALLOWLIST).
services:
  freshrss:
    image: freshrss/freshrss:1.30.4
    restart: unless-stopped
    environment:
      TZ: UTC
      INTERNAL_HOST_ALLOWLIST: "feeds:80"
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - freshrss_data:/var/www/FreshRSS/data
      - freshrss_ext:/var/www/FreshRSS/extensions
      - ./feeds:/feeds:ro
  feeds:
    image: nginx:1.29-alpine
    restart: unless-stopped
    volumes:
      - ./feeds:/usr/share/nginx/html:ro
volumes:
  freshrss_data:
  freshrss_ext:
lab/tests/feeds/files/miniflux.yml

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
# lab/stacks/miniflux/compose.yml plus a static feed server.
# Miniflux refuses private networks by default; FETCHER_ALLOW_PRIVATE_NETWORKS=1 lets it reach the local feed server.
services:
  miniflux:
    image: miniflux/miniflux:2.3.4
    restart: unless-stopped
    depends_on:
      db: {condition: service_healthy}
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DATABASE_URL: postgres://miniflux:lab-only-not-a-secret@db/miniflux?sslmode=disable
      RUN_MIGRATIONS: "1"
      FETCHER_ALLOW_PRIVATE_NETWORKS: "1"
      CREATE_ADMIN: "1"
      ADMIN_USERNAME: admin
      ADMIN_PASSWORD: lab-only-not-a-secret
  db:
    image: postgres:18.6-alpine3.24
    restart: unless-stopped
    environment:
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: lab-only-not-a-secret
      POSTGRES_DB: miniflux
    volumes:
      - miniflux-db:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux"]
      interval: 10s
      start_period: 30s
  feeds:
    image: nginx:1.29-alpine
    restart: unless-stopped
    volumes:
      - ./feeds:/usr/share/nginx/html:ro
volumes:
  miniflux-db:
lab/tests/feeds/feedtest.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: feedtest.py freshrss|miniflux. Three rounds, one fresh user per round: import the OPML (50 feeds), then refresh until
1000 entries exist. Prints JSON with seconds for each step. Timing starts when the command is issued."""
import base64, json, re, subprocess, sys, threading, time, urllib.request
tool = sys.argv[1]; PW = "lab-only-not-a-secret"
def sh(c, check=True):
    return subprocess.run(c, shell=True, capture_output=True, text=True, check=check)
out = {"tool": tool, "rounds": []}
NAMES = ["fr-freshrss-1"] if tool == "freshrss" else ["mf-miniflux-1", "mf-db-1"]
class Peak:
    """samples docker stats back to back (about one sample per second) and keeps the highest summed MemUsage"""
    def __enter__(self):
        self.stop = False; self.peak = 0.0; self.n = 0; self.t = threading.Thread(target=self.run); self.t.start(); return self
    def run(self):
        U = {"B": 1 / 1048576, "KiB": 1 / 1024, "MiB": 1, "GiB": 1024}
        while not self.stop:
            o = sh("docker stats --no-stream --format '{{.Name}} {{.MemUsage}}'", False).stdout; tot = 0.0
            for l in o.splitlines():
                m = re.match(r"(\S+) ([\d.]+)(\w+)", l)
                if m and m.group(1) in NAMES: tot += float(m.group(2)) * U[m.group(3)]
            self.n += 1; self.peak = max(self.peak, tot)
    def __exit__(self, *a):
        self.stop = True; self.t.join()
if tool == "freshrss":
    C = "fr-freshrss-1"; X = f"docker exec -u www-data -w /var/www/FreshRSS {C} php"
    def count(u): return int(sh(f"docker exec {C} php -r '$p=new PDO(\"sqlite:/var/www/FreshRSS/data/users/{u}/db.sqlite\");echo $p->query(\"select count(*) from entry\")->fetchColumn();'").stdout)
    def feeds(u): return int(sh(f"docker exec {C} php -r '$p=new PDO(\"sqlite:/var/www/FreshRSS/data/users/{u}/db.sqlite\");echo $p->query(\"select count(*) from feed\")->fetchColumn();'").stdout)
    for k in (1, 2, 3):
        u = f"u{k}"; r = {"user": u}
        sh(f"{X} ./cli/create-user.php --user {u} --password {PW} --no-default-feeds")
        t = time.perf_counter(); p = sh(f"{X} ./cli/import-for-user.php --user {u} --filename /feeds/feeds.opml"); r["import_s"] = round(time.perf_counter() - t, 2); r["import_out"] = (p.stdout + p.stderr).strip()[:200]
        r["feeds_after_import"] = feeds(u); r["entries_after_import"] = count(u)
        with Peak() as pk:
            t = time.perf_counter(); p = sh(f"{X} ./cli/actualize-user.php --user {u}"); r["refresh_s"] = round(time.perf_counter() - t, 2); r["refresh_out"] = (p.stdout + p.stderr).strip()[:200]
        r["peak_mem_mib_sampled"] = round(pk.peak, 1); r["peak_samples"] = pk.n
        r["entries_after_refresh"] = count(u); out["rounds"].append(r)
else:
    B = "http://127.0.0.1:8080"
    def call(method, path, user, body=None, ctype="application/json"):
        h = {"Authorization": "Basic " + base64.b64encode(f"{user}:{PW}".encode()).decode(), "content-type": ctype}
        resp = urllib.request.urlopen(urllib.request.Request(B + path, data=body, headers=h, method=method), timeout=120); d = resp.read()
        return json.loads(d) if d and resp.headers.get("content-type", "").startswith("application/json") else d
    opml = open("/home/lab/feeds/feeds.opml", "rb").read()
    for k in (1, 2, 3):
        u = f"u{k}"; r = {"user": u}
        call("POST", "/v1/users", "admin", json.dumps({"username": u, "password": PW}).encode())
        t = time.perf_counter(); resp = call("POST", "/v1/import", u, opml, "application/xml"); r["import_s"] = round(time.perf_counter() - t, 2); r["import_out"] = str(resp)[:200]
        r["feeds_after_import"] = len(call("GET", "/v1/feeds", u)); r["entries_after_import"] = call("GET", "/v1/entries?limit=1", u)["total"]
        with Peak() as pk:
            t = time.perf_counter(); call("PUT", "/v1/feeds/refresh", u)
            while True:
                n = call("GET", "/v1/entries?limit=1", u)["total"]
                if n >= 1000 or time.perf_counter() - t > 300: break
                time.sleep(0.2)
            r["refresh_s"] = round(time.perf_counter() - t, 2)
        r["peak_mem_mib_sampled"] = round(pk.peak, 1); r["peak_samples"] = pk.n
        r["entries_after_refresh"] = n; out["rounds"].append(r)
print(json.dumps(out, indent=1))
lab/tests/feeds/genfeeds.py
Show the full file
#!/usr/bin/env python3
"""Generates 50 Atom feeds x 20 entries (1000 entries, ~1.2 KB of text each) plus feeds.opml in the given directory. Deterministic."""
import os, random, sys
d = sys.argv[1]; os.makedirs(d, exist_ok=True); r = random.Random(7)
words = "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu".split()
opml = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0"><head><title>lab feeds</title></head><body>']
for f in range(1, 51):
    ents = []
    for e in range(20):
        body = " ".join(r.choice(words) for _ in range(180))
        ents.append(f'<entry><title>Feed {f} entry {e}: {r.choice(words)} {r.choice(words)}</title><id>urn:lab:feed{f}:entry{e}</id><link href="http://feeds/f{f}/e{e}.html"/><updated>2026-10-{1 + e:02d}T12:00:00Z</updated><content type="html">&lt;p&gt;{body}&lt;/p&gt;</content></entry>')
    open(f"{d}/f{f:02d}.xml", "w").write(f'<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Lab feed {f}</title><id>urn:lab:feed{f}</id><link href="http://feeds/f{f:02d}.xml" rel="self"/><updated>2026-10-30T12:00:00Z</updated>' + "".join(ents) + "</feed>")
    opml.append(f'<outline type="rss" text="Lab feed {f}" title="Lab feed {f}" xmlUrl="http://feeds/f{f:02d}.xml" htmlUrl="http://feeds/f{f}/"/>')
opml.append("</body></opml>")
open(f"{d}/feeds.opml", "w").write("\n".join(opml))
lab/tests/feeds/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# feeds: FreshRSS 1.30.4 vs Miniflux 2.3.4 with 50 generated Atom feeds (1000 entries) served by a local nginx.
# Per tool: 3 rounds (fresh user each): OPML import, refresh until 1000 entries. Then RAM and database size.
set -uo pipefail
source "$(dirname "$0")/../_dlib.sh"
T=feeds; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; D="$ROOT/lab/tests/$T"
TOOLS=("$@"); [ ${#TOOLS[@]} -eq 0 ] && TOOLS=(freshrss miniflux)
[ $# -eq 0 ] && rm -rf "$OUT"; mkdir -p "$RAW"   # with explicit tool names, only those tools are re-run
one() {
  local tool="$1" p; p=$([ $tool = freshrss ] && echo fr || echo mf)
  start_vm d-$T-$tool
  gx 'mkdir -p ~/feeds' ; vm scp-to "$D/files/$tool.yml" compose.yml >/dev/null
  vm scp-to "$D/genfeeds.py" genfeeds.py >/dev/null; vm scp-to "$D/feedtest.py" feedtest.py >/dev/null; vm scp-to "$ROOT/lab/tests/_dsample.py" sample.py >/dev/null
  gx "python3 genfeeds.py ~/feeds && ls ~/feeds | wc -l && du -sb ~/feeds && cat ~/feeds/*.xml | grep -o '<entry>' | wc -l" > "$RAW/feeds-generated.txt"
  cpull "~" $p
  gx "docker compose -p $p up -d 2>&1 | tail -1"
  if [ $tool = freshrss ]; then
    until gx "curl -sf -o /dev/null http://127.0.0.1:8080/"; do sleep 1; done
    gx "docker exec -u www-data -w /var/www/FreshRSS $p-freshrss-1 php ./cli/do-install.php --default-user admin --auth-type form --environment production --base-url http://127.0.0.1:8080 --language en --db-type sqlite" > "$RAW/freshrss-install.txt" 2>&1
    gx "docker exec -u www-data -w /var/www/FreshRSS $p-freshrss-1 php ./cli/create-user.php --user admin --password lab-only-not-a-secret --no-default-feeds" >> "$RAW/freshrss-install.txt" 2>&1
  else
    until gx "curl -sf -o /dev/null http://127.0.0.1:8080/healthcheck"; do sleep 1; done
    gx "docker exec mf-db-1 psql -U miniflux -tAc \"select pg_database_size('miniflux')\"" > "$RAW/miniflux-db-bytes-empty.txt"
  fi
  sleep 90
  gx "python3 sample.py $p-$tool-1 $([ $tool = miniflux ] && echo mf-db-1)" > "$RAW/$tool-ram-before.json"
  gx "python3 feedtest.py $tool" > "$RAW/$tool-rounds.json" 2> "$RAW/$tool-rounds.err"
  sleep 60
  gx "python3 sample.py $p-$tool-1 $([ $tool = miniflux ] && echo mf-db-1)" > "$RAW/$tool-ram-after.json"
  if [ $tool = freshrss ]; then
    gx "sudo du -sb /var/lib/docker/volumes/fr_freshrss_data/_data" > "$RAW/freshrss-disk.txt" 2>&1
  else
    gx "docker exec mf-db-1 psql -U miniflux -tAc \"select pg_database_size('miniflux')\"; sudo du -sb /var/lib/docker/volumes/mf_miniflux-db/_data" > "$RAW/miniflux-disk.txt" 2>&1
  fi
  gx 'docker inspect -f "{{.Name}} restarts={{.RestartCount}}" $(docker ps -aq)' > "$RAW/$tool-restarts.txt"
  local S="$ROOT/public/shots/$T"
  if [ $tool = freshrss ]; then
    shot 8080 /i/ "$S/freshrss.png" '[{"fill":"input[name=username]","value":"u3"},{"fill":"input[type=password]","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":5000}]'
  else
    shot 8080 / "$S/miniflux.png" '[{"fill":"input[name=username]","value":"u3"},{"fill":"input[name=password]","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":4000}]'
  fi
  mkdir -p "$RAW/$tool-env"; collect_env "$RAW/$tool-env"
}
for t in "${TOOLS[@]}"; do ( one "$t" ); done
cp "$D/files/"*.yml "$OUT/"
python3 "$D/summarize.py" "$RAW" "$OUT"
lab/tests/feeds/private-check.sh
Show the full file
#!/usr/bin/env bash
# feeds / private-network check: starts each reader WITHOUT the private-network setting the benchmark needs, imports the same OPML and refreshes.
# Saves what each reader reports to evidence/tests/feeds/raw/private-<tool>.json. Also records FreshRSS's default feed for a new user.
set -uo pipefail
source "$(dirname "$0")/../_dlib.sh"
T=feeds; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; D="$ROOT/lab/tests/$T"
mkdir -p "$RAW"
one() {
  local tool="$1" p; p=$([ $tool = freshrss ] && echo fr || echo mf)
  start_vm d-$T-priv-$tool
  gx 'mkdir -p ~/feeds'
  sed -e '/INTERNAL_HOST_ALLOWLIST/d' -e '/FETCHER_ALLOW_PRIVATE_NETWORKS/d' "$D/files/$tool.yml" > /tmp/priv-$tool.yml
  vm scp-to /tmp/priv-$tool.yml compose.yml >/dev/null
  vm scp-to "$D/genfeeds.py" genfeeds.py >/dev/null; vm scp-to "$D/private-check.py" private-check.py >/dev/null
  gx "python3 genfeeds.py ~/feeds >/dev/null"
  cpull "~" $p
  gx "docker compose -p $p up -d 2>&1 | tail -1"
  if [ $tool = freshrss ]; then
    until gx "curl -sf -o /dev/null http://127.0.0.1:8080/"; do sleep 1; done
    gx "docker exec -u www-data -w /var/www/FreshRSS $p-freshrss-1 php ./cli/do-install.php --default-user admin --auth-type form --environment production --base-url http://127.0.0.1:8080 --language en --db-type sqlite" >/dev/null 2>&1
  else
    until gx "curl -sf -o /dev/null http://127.0.0.1:8080/healthcheck"; do sleep 1; done
  fi
  gx "python3 private-check.py $tool" > "$RAW/private-$tool.json" 2> "$RAW/private-$tool.err"
  gx "grep -c INTERNAL_HOST_ALLOWLIST\\|FETCHER_ALLOW compose.yml; true" > "$RAW/private-$tool-setting-lines.txt" 2>&1
  rm -f /tmp/priv-$tool.yml
}
for t in ${@:-freshrss miniflux}; do ( one "$t" ); done
lab/tests/feeds/private-check.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: private-check.py freshrss|miniflux. Compose was started WITHOUT the private-network setting (see private-check.sh).
Creates a user (FreshRSS: with its default feeds), imports the 50-feed OPML, refreshes, and prints what each reader reports. Prints JSON."""
import base64, json, subprocess, sys, time, urllib.request
tool = sys.argv[1]; PW = "lab-only-not-a-secret"
def sh(c): p = subprocess.run(c, shell=True, capture_output=True, text=True); return (p.stdout + p.stderr).strip()
out = {"tool": tool}
if tool == "freshrss":
    X = "docker exec -u www-data -w /var/www/FreshRSS fr-freshrss-1 php"
    out["create_user"] = sh(f"{X} ./cli/create-user.php --user d1 --password {PW}")[:600]
    q = lambda t: sh(f"docker exec fr-freshrss-1 php -r '$p=new PDO(\"sqlite:/var/www/FreshRSS/data/users/d1/db.sqlite\");foreach($p->query(\"{t}\") as $r) echo json_encode($r).\"\\n\";'")
    out["feeds_after_create_user"] = q("select url from feed")
    out["import"] = sh(f"{X} ./cli/import-for-user.php --user d1 --filename /feeds/feeds.opml")[:1500]
    out["actualize"] = sh(f"{X} ./cli/actualize-user.php --user d1")[:3000]
    out["entries_total"] = q("select count(*) as n from entry")
    out["entries_from_lab_feeds"] = q("select count(*) as n from entry where instr(link, char(102,101,101,100,115,47)) > 0")
    out["feeds_total"] = q("select count(*) as n from feed")
    out["log_lines"] = sh("docker logs fr-freshrss-1 2>&1 | grep -i 'not allowed\\|blocked\\|internal' | sort | uniq -c | head -5")
else:
    B = "http://127.0.0.1:8080"; PW = "lab-only-not-a-secret"
    def call(method, path, user, body=None, ctype="application/json"):
        h = {"Authorization": "Basic " + base64.b64encode(f"{user}:{PW}".encode()).decode(), "content-type": ctype}
        r = urllib.request.urlopen(urllib.request.Request(B + path, data=body, headers=h, method=method), timeout=120); d = r.read()
        return json.loads(d) if d and r.headers.get("content-type", "").startswith("application/json") else d
    call("POST", "/v1/users", "admin", json.dumps({"username": "d1", "password": PW}).encode())
    out["import"] = str(call("POST", "/v1/import", "d1", open("/home/lab/feeds/feeds.opml", "rb").read(), "application/xml"))
    call("PUT", "/v1/feeds/refresh", "d1"); time.sleep(30)
    fs = call("GET", "/v1/feeds", "d1")
    out["feeds_total"] = len(fs); out["feeds_with_parsing_error"] = sum(1 for f in fs if f.get("parsing_error_count"))
    out["first_error_message"] = next((f["parsing_error_message"] for f in fs if f.get("parsing_error_message")), "")
    out["entries_total"] = call("GET", "/v1/entries?limit=1", "d1")["total"]
print(json.dumps(out, indent=1))

What went wrong

The first attempt produced no entries for either reader (private-network refusal, above) and added a default GitHub feed to every FreshRSS user; the separate check above reproduced both. The second, complete run measured everything except the per-refresh memory peak and its FreshRSS screenshot failed to log in because of a wrong selector. The final run added the peak sampling and fixed the selector. An earlier version of all these runs had the guest drop its page cache every 3 s, which inflated RAM readings and slowed disk-bound steps; it was removed and the whole test rerun. Only the final run is reported (raw files dated 2026-11-02, 06:16 to 06:31 UTC, with host load average below 1 at the end of each run). Its disk-usage step also had a failing glob for per-user FreshRSS database files, so only the whole data directory is reported.

What this test did not cover

Sources and how this was made

How this page was made: the commands above were run by an AI agent on the test box on November 2, 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.