Wer auf einem VPS einen Worker betreibt, der ständig eine Queue abfragt, kennt die Frage: Die Aufträge kommen ohnehin aus einer externen Warteschlange, die Ergebnisse landen in einer Datenbank – warum dafür einen eigenen Server rund um die Uhr verwalten? Vercel Daemons klingen wie eine passende Alternative. Man stellt einen Container bereit, legt die Instanzanzahl fest und lässt den Prozess dauerhaft laufen.

Das ist aber noch kein Grund, den VPS abzuschalten. Nach dem Abgleich der Vercel-Ankündigung, der Daemon-Limits und der Betriebsdokumentation würde ich so entscheiden: Für Pull-Worker mit externem Zustand und sicheren Wiederholungen ist Daemons eine Option. Wer eingehende Ports, dauerhafte lokale Dateien oder eine vollständige CI-Umgebung braucht, kann nicht einfach umziehen.

Am 15. Oktober 2026 stellte Vercel Python-Backends als GA sowie Container Functions und Daemons Public Beta vor. Die Plattformgrenzen in diesem Artikel beruhen auf offiziellen Dokumenten und dem JSON-Schema. Zusätzlich zeigt ein lokaler Node.js-Test mit echten `SIGTERM`-Signalen an Kindprozesse, welche Folgen die Reihenfolge von Job-Bestätigung und Seiteneffekt haben kann. Der Test ist keine Messung einer Vercel-Deployment-Umgebung.

HTTP-Anfragen beantworten ist etwas anderes als Jobs abholen

Für öffentliche APIs mit FastAPI, Flask oder Django eignen sich die nun allgemein verfügbaren Python Functions: Sie werden durch Anfragen ausgelöst. Wer eigene OCI-Images oder Systembibliotheken braucht und HTTP-Anfragen verarbeitet, kann Container Functions (Beta) prüfen; diese skalieren anfragebasiert.

Daemons verfolgen ein anderes Modell: Eine feste Anzahl langlebiger Prozesse holt selbst Arbeit aus einer Queue oder Datenbank. Laut Vercel-Übersicht gibt es derzeit keinen eingehenden HTTP- oder TCP-Zugang.

Bisherige AufgabePassendes ModellVor einer Migration prüfen
Öffentliche FastAPI-/Flask-APIPython Functions (GA)Anfrage-Laufzeit, Abhängigkeiten, externe Verbindungen
Eigenes OCI-Backend mit HTTP-EndpunktContainer Functions (Beta)Skalierung durch Anfragen und Function-Abrechnung
Queue-Consumer mit externer DatenbankDaemons (Public Beta)Feste Instanzen, externer Zustand, Retry und Idempotenz
Service mit dauerhaft lokalen Dateien/CacheVPS oder Host mit Persistent VolumeKein persistenter lokaler Datenträger bei Daemons
Dienst mit eingehendem TCP-PortVPS oder geeigneter TCP-HostDaemons öffnen keinen Eingangsport

Bei GitHub Self-hosted Runnern wäre ich besonders vorsichtig. Sie holen ihre Jobs ebenfalls aktiv ab. Diese Jobs können jedoch Container-Builds, Arbeitsverzeichnisse, Caches und weitreichende Deployment-Berechtigungen benötigen. Das gleiche Pull-Muster garantiert weder identische Dateisystemfähigkeiten noch Rechteisolierung. Hier muss man die tatsächlichen Workflows prüfen, statt „Background Worker“ mit „CI-Runner“ gleichzusetzen.

10 vCPU-Stunden pro Monat sind kein kostenloser Dauerbetrieb

Die Beta-Limits sehen für Hobby 10 vCPU-hours pro Team und Monat vor. Eine durchgehend laufende Instanz mit 1 vCPU verbraucht dieses Kontingent in ungefähr zehn Stunden. Danach werden die Daemons des Teams bis zum nächsten Monat oder einem Upgrade gestoppt.

Die Rechnung lautet zugewiesene vCPU-Anzahl × Betriebsstunden, nicht tatsächliche CPU-Auslastung. Auch ein Prozess, der meistens auf neue Nachrichten wartet, ist damit kein kostenloser 24/7-Worker. Das Active-CPU-Abrechnungsmodell von anfragebasierten Container Functions darf man nicht ohne Weiteres auf Daemons übertragen.

Bei Pro beträgt die voreingestellte Grenze für gleichzeitig belegte Ressourcen 16 vCPU und 32 GiB RAM. Das ist kein monatlicher Dollarpreis. Ein seriöser Kostenvergleich zum VPS muss reservierte Ressourcen, Laufzeit, Netzwerk- und Queue-Kosten sowie den Administrationsaufwand berücksichtigen.

Außerdem fehlen in dieser Beta Preview Deployments, Autoscaling und persistente lokale Datenträger. Daemons starten nicht direkt unter `vercel dev`; in der lokalen Entwicklung betreibt man den Container selbst, während produktive Deployments die Daemons starten. Wer bislang mit Preview-Umgebungen oder datenträgerbasierten Caches arbeitet, stößt möglicherweise schon dort auf Probleme. Weitere Einschränkungen zur Projekt-Root-Konfiguration stehen auf der Limits-Seite.

SIGTERM: Wann gilt ein Job als erledigt?

Beim Ausrollen einer neuen Version können laut Verwaltungsdokumentation neue und alte Daemon-Instanzen kurzzeitig parallel laufen. Die alte Instanz erhält `SIGTERM` und standardmäßig zehn Sekunden Zeit zum Beenden. Entscheidend ist nicht allein der saubere Exit, sondern der Zustand eines gerade verarbeiteten Jobs.

Angenommen, ein Worker schreibt eine Bestellung in die Datenbank und wird danach beendet, bevor er der Queue die Verarbeitung bestätigt. Wird die Nachricht erneut zugestellt, kann derselbe Geschäftseffekt doppelt entstehen. Bestätigt er dagegen schon vor dem Datenbank-Update, kann bei einem Abbruch Arbeit verloren gehen, obwohl die Queue sie für erledigt hält.

Für diesen Unterschied habe ich unter Node.js v22.16.0 auf Linux x64 ein kleines, eigenes Offline-Queue-Modell ausgeführt. Eine JSON-Datei hält eine künstliche Aufgabe und simulierte Seiteneffekte fest. Echte lokale Node-Kindprozesse werden gestartet; in drei Unterbrechungsfällen erhalten sie tatsächlich `SIGTERM`. Gemessen wird die Bestätigungsreihenfolge, nicht die Verfügbarkeit oder Neustartzeit von Vercel.

Lokales synthetisches SzenarioVerarbeitungsversucheErfasste EffekteBestätigt?
Effekt schreiben, dann bestätigen11Ja
Nach Effekt abbrechen, beim Retry nach Job-ID deduplizieren21Ja
Nach Effekt abbrechen, Retry ohne Deduplikation22Ja
Vor dem Effekt bestätigen, dann abbrechen10Ja

Alle vier Fälle lieferten die erwarteten Beobachtungen. Gerade die negativen Kontrollen sind aufschlussreich: Ohne Deduplikation wird der Effekt zweimal ausgeführt; eine zu frühe Bestätigung kann die Arbeit vollständig verlieren.

Die zentrale Prüfung umfasst nur wenige Zeilen. `effects` ist eine Liste synthetischer Datensätze:

function recordEffect(s, dedupe) {
  const existing = s.effects.some((effect) => effect.jobId === s.job.id);
  if (dedupe && existing) return 'effect_deduplicated';
  s.effects.push({ jobId: s.job.id, effect: 'synthetic-committed-action' });
  return 'effect_applied';
}

Mit `jobId` als Schlüssel erkennt der zweite Versuch den schon vorhandenen Effekt. Entfernt man diese Prüfung, entsteht ein Duplikat. Bei der frühen Bestätigung sieht der wieder gestartete Worker `acked: true` und überspringt den Job – obwohl noch kein Effekt geschrieben wurde.

Eine echte Queue hat weitere Regeln: Visibility Timeouts, Transaktionen, Zustellgarantien und Redelivery. Das JSON-Modell ersetzt sie nicht. Vor einem Umzug sollte man geschäftliche Eindeutigkeitsschlüssel oder idempotente Schreiboperationen implementieren und die Wiederholung mit der tatsächlich eingesetzten Queue testen.

Die Konfiguration ist kurz, die Grenzen sind es nicht

Die Konfigurationsreferenz zeigt das Format für einen Worker in einer Region. Unter `worker/` müssen ein Dockerfile und ein echter Queue-Consumer liegen:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "daemons": {
    "worker": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "2Gi" } }
    }
  }
}

`instances` bestimmt die feste Zahl der Instanzen pro Region; `iad1` ist die Vercel-Region Washington. Es handelt sich nicht um eine Autoscaling-Regel.

Das Projekt-JSON-Schema akzeptiert das ältere Feld `replicas` noch, kennzeichnet es jedoch als deprecated. Sind beide Felder vorhanden, hat `instances` Vorrang. Aktuelle Konfigurations- und Verwaltungsdokumente verwenden ebenfalls `instances`; für neue Projekte ist das die richtige Wahl.

Die dokumentierte `SIGTERM`-Schonfrist beträgt standardmäßig zehn Sekunden; im Schema ist außerdem `terminationGracePeriodSeconds` enthalten. Mehr Wartezeit heilt weder doppelte Seiteneffekte noch eine falsche Ack-Reihenfolge. Zuerst muss der Job sicher unterbrechbar und wiederholbar sein, danach kann man das Zeitfenster in einer echten Bereitstellung prüfen.

Für kleine Pull-Worker, deren Geschäftsstatus außerhalb des Containers liegt und denen feste Instanzen reichen, würde ich Daemons zunächst an unkritischen Jobs testen. Beim Bedarf an persistenten Arbeitsverzeichnissen, umfangreichen CI-Rechten, eingehenden Ports oder stabilen Preview-Umgebungen bliebe ich vorerst beim VPS. Das Verschieben eines Runners beseitigt schließlich nicht die Sicherheitsgrenze zwischen fremdem Job-Code und Deployment-Geheimnissen.

Wer den Runner-Weg näher betrachten möchte, findet dazu meinen Beitrag über ChatGPT Web, GitHub und einen Self-hosted Runner. Für eine eigene VPS-Hosting-Steuerung gibt es den PanelAlpha-Engine-Test.

Das vollständige Skript unten lässt sich als `queue-interruption-l1.mjs` speichern und mit Node.js 20 oder neuer über `node queue-interruption-l1.mjs` ausführen. Es gibt die vier synthetischen Ergebnisse als JSON aus.

Vollständigen Node.js-Test des synthetischen Workers anzeigen
/**
 * First-party, offline, synthetic queue acknowledgement experiment.
 * Not a Vercel deployment, Vercel Daemon integration, or platform reliability test.
 * Dependencies: Node.js >= 20 standard library, no network, no cloud credentials.
 * Run: node queue-interruption-l1.mjs
 */
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT = fileURLToPath(import.meta.url);
const JOB = 'synthetic-job-001';
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const readState = (file) => JSON.parse(readFileSync(file, 'utf8'));
function writeState(file, state) {
  const temp = `${file}.next`;
  writeFileSync(temp, JSON.stringify(state) + '\n', { mode: 0o600 });
  renameSync(temp, file);
}
function newState(file) {
  writeState(file, { job: { id: JOB, acked: false }, effects: [], attempt: 0 });
}
function recordEffect(s, dedupe) {
  const existing = s.effects.some((effect) => effect.jobId === s.job.id);
  if (dedupe && existing) return 'effect_deduplicated';
  s.effects.push({ jobId: s.job.id, effect: 'synthetic-committed-action' });
  return 'effect_applied';
}

// The worker is our own short-lived child process, not a provider/runtime process.
function worker(mode, stateFile) {
  const state = readState(stateFile);
  if (state.job.acked) {
    process.send({ phase: 'already_acknowledged' }, () => process.exit(0));
    return;
  }
  state.attempt += 1;
  if (mode === 'ack-before-effect') {
    state.job.acked = true;
    writeState(stateFile, state);
    process.send({ phase: 'ack_committed_effect_missing' });
    process.on('message', () => {}); // Keep child alive until real SIGTERM.
    return;
  }
  if (mode === 'effect-before-ack') {
    recordEffect(state, false);
    writeState(stateFile, state);
    process.send({ phase: 'effect_committed_ack_missing' });
    process.on('message', () => {}); // Keep child alive until real SIGTERM.
    return;
  }
  if (mode === 'complete' || mode === 'complete-without-dedupe') {
    const action = recordEffect(state, mode === 'complete');
    state.job.acked = true;
    writeState(stateFile, state);
    process.send({ phase: action }, () => process.exit(0));
    return;
  }
  throw Error(`Unknown first-party worker mode: ${mode}`);
}

async function runChild(mode, stateFile, expectedPhase, interrupt) {
  return await new Promise((resolve, reject) => {
    const child = fork(SCRIPT, ['--worker', mode, stateFile], {
      env: { PATH: process.env.PATH ?? '', NODE_ENV: 'test' },
      stdio: ['ignore', 'ignore', 'pipe', 'ipc']
    });
    let seen = null;
    let settled = false;
    let stderr = '';
    child.stderr.setEncoding('utf8');
    child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1200); });
    const timer = setTimeout(() => {
      child.kill('SIGKILL');
      if (!settled) { settled = true; reject(Error(`Worker timeout: ${mode}`)); }
    }, 7000);
    child.on('error', (error) => {
      clearTimeout(timer);
      if (!settled) { settled = true; reject(error); }
    });
    child.on('message', (event) => {
      if (seen !== null) return;
      seen = event.phase;
      if (interrupt) child.kill('SIGTERM');
    });
    child.on('exit', (code, signal) => {
      clearTimeout(timer);
      if (settled) return;
      settled = true;
      if (seen !== expectedPhase || (interrupt ? signal !== 'SIGTERM' : code !== 0)) {
        reject(Error(`Worker mismatch mode=${mode} seen=${seen} code=${code} signal=${signal}; stderr=${stderr}`));
      } else {
        resolve({ phase: seen, exit_code: code, exit_signal: signal });
      }
    });
  });
}

async function experiment() {
  const started = new Date().toISOString();
  const dir = mkdtempSync(path.join(tmpdir(), 'offline-queue-l1-'));
  const cases = [];
  let cleanup = false;
  try {
    const stateFile = path.join(dir, 'state.json');
    newState(stateFile);
    const normal = await runChild('complete', stateFile, 'effect_applied', false);
    let s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, true);
    cases.push({ id: 'control_effect_then_ack', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, worker: normal });

    newState(stateFile);
    const interruptedSafe = await runChild('effect-before-ack', stateFile, 'effect_committed_ack_missing', true);
    s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, false);
    const replaySafe = await runChild('complete', stateFile, 'effect_deduplicated', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, true);
    assert.equal(s.attempt, 2);
    cases.push({ id: 'sigterm_replay_with_dedupe', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, workers: [interruptedSafe, replaySafe] });

    newState(stateFile);
    const interruptedUnsafe = await runChild('effect-before-ack', stateFile, 'effect_committed_ack_missing', true);
    const replayUnsafe = await runChild('complete-without-dedupe', stateFile, 'effect_applied', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 2); // Deliberate negative control: duplicate side effect.
    assert.equal(s.job.acked, true);
    cases.push({ id: 'sigterm_replay_without_dedupe_negative_control', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, observed_risk: 'duplicate_side_effect', workers: [interruptedUnsafe, replayUnsafe] });

    newState(stateFile);
    const earlyAck = await runChild('ack-before-effect', stateFile, 'ack_committed_effect_missing', true);
    const recovery = await runChild('complete', stateFile, 'already_acknowledged', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 0);
    assert.equal(s.job.acked, true);
    assert.equal(s.attempt, 1);
    cases.push({ id: 'ack_before_effect_negative_control', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, observed_risk: 'lost_work', workers: [earlyAck, recovery] });
  } finally {
    rmSync(dir, { recursive: true, force: true });
    cleanup = !existsSync(dir);
  }
  assert.equal(cases.length, 4);
  assert.ok(cases.every((entry) => entry.passed));
  assert.equal(cleanup, true);
  const receipt = {
    schema_version: 1,
    type: 'FIRST_PARTY_OFFLINE_SYNTHETIC_QUEUE_EXPERIMENT',
    scope: 'LOCAL_NODE_CHILD_PROCESSES_ONLY_NOT_VERCEL',
    source_sha256: sha256(readFileSync(SCRIPT)),
    node: process.version,
    platform: process.platform,
    architecture: process.arch,
    started_at: started,
    ended_at: new Date().toISOString(),
    case_count: cases.length,
    passed: cases.length,
    failed: 0,
    clean_up_verified: cleanup,
    cases,
    limitations: [
      'No Vercel account, Daemon, Vercel Functions, managed queue, container or API was used.',
      'Synthetic queue state is an isolated JSON file and effects are fake records; no external side effects.',
      'Real local child processes received SIGTERM, but signal timing/rollout/delivery semantics of Vercel were not measured.',
      'No performance/cost/availability benchmark; 4 illustrative cases are not a statistical reliability study.'
    ]
  };
  console.log(JSON.stringify(receipt, null, 2));
}

if (process.argv[2] === '--worker') {
  worker(process.argv[3], process.argv[4]);
} else if (process.argv.length === 2) {
  experiment().catch((error) => {
    console.error(`SYNTHETIC_L1_FAIL: ${error.message}`);
    process.exitCode = 1;
  });
} else {
  throw Error('Usage: node queue-interruption-l1.mjs');
}