What is a monolith separate worker process, and why split web from jobs?
A monolith with a separate worker process is one codebase, built once into a single artifact (a container image, a release tarball, a compiled binary), that you start in different roles. The same code runs as a web process that serves HTTP, a worker process that pulls jobs from a queue, and often a scheduler that enqueues periodic work. Only the entrypoint command differs. The Procfile-style model of web and worker dynos is the best-known form of this: each line names a process type and the command that starts it, and every type runs the same release.
Teams split these roles because request handling and job processing need different resources. A web process should respond in milliseconds, keep memory predictable, and hold many short-lived connections. Jobs like report generation, image processing, bulk email, and data imports can run for minutes, allocate large amounts of memory, or saturate CPU. When both share a process, one heavy job can stall the event loop or tie up a thread pool. Request latency then spikes, load balancer timeouts fire, and an out-of-memory kill takes in-flight requests down with the job. Separate processes give you job process isolation. Each role gets its own memory limits, concurrency settings, and replica count, so a backlog of jobs no longer degrades the user-facing path.
This pattern suits backend and platform teams that run a monolith with a growing amount of background work. It is usually the first step before extracting services: you get operational separation without splitting the codebase, the data model, or the deploy pipeline.
How does single codebase deployment with multiple process roles work?
The mechanics are simple. CI produces one artifact per commit: a container image, a slug, or a tarball. Every process type runs that same artifact. The only thing that differs is the start command. The same image boots as a web server when started with web, as a queue consumer when started with worker --queues=default,mail, and as a cron-style scheduler when started with scheduler. Platforms that model this as web and worker dynos, or as separate deployments in an orchestrator, are all doing the same thing: one release, many process definitions.
┌──────────────────────────┐
│ build artifact (sha X) │
└────────────┬─────────────┘
┌──────────────┬───────┴───────┬──────────────┐
▼ ▼ ▼ ▼
web (×N) worker:critical worker:bulk scheduler (×1)
HTTP, low small pool, large pool, enqueues only,
latency short timeout long timeout never executes
│ │ │ │
└──────┬───────┴───────┬───────┴──────────────┘
▼ ▼
┌──────────┐ ┌──────────┐
│ database │ │ broker │
└──────────┘ └──────────┘
Selecting the role at start time rather than at build time is what prevents drift. If the worker image were built separately, it could pick up a different dependency lock or a stale model class. A job serialized by web code might then fail to deserialize in the worker. With one artifact, every role always runs identical code and libraries.
Each process type still gets its own limits: replica count, memory and CPU ceilings, database connection pool size, and shutdown timeout. A bulk worker can be allowed 2 GB and a long drain period while web pods stay small and restart quickly.
Run the scheduler as exactly one instance. If you run two, every periodic job fires twice. The scheduler should only enqueue work, so a crash loses at most a tick and never a job that was halfway through running.
┌────────────────────────────────────────┐
│ one artifact per commit (sha X) │
│ image / slug / tarball, one lockfile │
└───────────────────┬────────────────────┘
│ role chosen by start command
┌──────────────────┬────────┴─────────┬──────────────────┐
▼ ▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│web ×N │ │worker:critical│ │worker:bulk ×M │ │scheduler ×1 │
│cmd: web │ │cmd: worker │ │cmd: worker │ │cmd: scheduler │
│HTTP requests │ │queues=critical│ │queues=bulk │ │enqueues only │
│low latency │ │small pool │ │large pool 2 GB│ │never executes │
│short drain │ │short timeout │ │long drain │ │one replica │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │ │
└─────────────┬────┴──────────────────┴────┬─────────────┘
▼ ▼
┌──────────────┐ ┌──────────────┐
│ database │ │ broker │
└──────────────┘ └──────────────┘
One build artifact per commit fans out by start command into web, per-queue-class worker pools, and a single scheduler, each with its own limits but all sharing the same database and broker.
How do you implement web and worker roles from one build artifact?
Declare each role as one line in a Procfile, with every line calling the same entrypoint: web: bin/start web, worker: bin/start worker, scheduler: bin/start scheduler. The entrypoint is a plain shell script that reads $1, sets PROCESS_ROLE, and execs the matching command. Using exec matters because it replaces the shell, so the application becomes the main process and receives SIGTERM directly. An unknown role should exit non-zero rather than fall back to a default. A typo should fail the deploy instead of silently starting a web server.
Keep the boot path split in two. Shared code loads config, configures logging, and builds the database pool. It never starts anything. Role-specific code then starts exactly one thing. Web binds the HTTP listener. Worker starts consumers for the queues named in its config. Scheduler starts the timer loop. If importing a module opens a socket or registers a consumer, a worker will end up listening on a port, so make that a bug that tests catch.
Size the pool per role and check the total against the database limit. Illustrative estimate, with assumed inputs: 4 web instances × 10 connections, 6 workers × 5, and 1 scheduler × 2 gives 72. Against an assumed limit of 100 with 10 reserved for admin and migrations, that leaves 18 for rolling deploys, which briefly run old and new instances side by side.
Tag every log line, metric, and trace span with process_role. In workers, also tag queue_name. Without these tags, a latency spike can't be traced to a specific queue.
#!/bin/sh
# bin/start: single entrypoint for every role in the same build artifact.
# Procfile:
# web: bin/start web
# worker: bin/start worker
# scheduler: bin/start scheduler
set -eu
role="${1:-}"
# Shared boot code reads PROCESS_ROLE to tag logs, metrics, and traces.
export PROCESS_ROLE="$role"
# Pool sizes are per role. Illustrative check with assumed inputs:
# 4 web x 10 + 6 worker x 5 + 1 scheduler x 2 = 72 connections.
# The DB limit is 100, with 10 reserved, which leaves 18 for rolling deploys.
case "$role" in
web)
export DB_POOL_SIZE="${DB_POOL_SIZE:-10}"
exec python -m app.web # binds the HTTP listener; starts no consumers
;;
worker)
export DB_POOL_SIZE="${DB_POOL_SIZE:-5}"
export WORKER_QUEUES="${WORKER_QUEUES:-default,mailers}" # each name also becomes a queue_name tag
exec python -m app.worker # starts consumers; never opens a port
;;
scheduler)
export DB_POOL_SIZE="${DB_POOL_SIZE:-2}"
exec python -m app.scheduler # runs the timer loop only
;;
*)
# Fail the deploy on a typo instead of defaulting to web.
echo "bin/start: unknown role '$role' (expected web|worker|scheduler)" >&2
exit 64
;;
esac
A POSIX shell entrypoint that every Procfile line calls: it sets PROCESS_ROLE, assigns a per-role database pool size, uses exec so the app receives SIGTERM directly, and exits non-zero on an unknown role.
How should you isolate queues and scale web and worker processes independently?
Split queues by workload class, not by job type. A critical queue for password resets and webhooks and a bulk queue for exports and reindexing is usually enough. Give each class its own worker pool with its own concurrency and memory limits. If one pool drains both queues, a burst of slow exports occupies every slot and resets wait behind them.
Scale each role on its own signal. Web should follow request concurrency or p95 latency. Workers should follow queue depth and the age of the oldest job. Job age matters more, because it shows how long users are actually waiting.
Illustrative estimate. Assume 50 jobs/s arrive and each takes 0.4 s on average. By Little's law, that keeps 20 jobs in flight. At 5 concurrent jobs per worker, you need 4 workers. Add 50% headroom for bursts and you get 6.
There are three caveats:
- Lag. Depth-based autoscaling reacts only after a backlog has formed, and it can oscillate. Use cooldowns and scale-in delays to damp it.
- Moved bottlenecks. More workers can shift the bottleneck to the database or to downstream API rate limits.
- Fragmented capacity. Per-class pools sit idle while another pool is saturated.
| Web | Worker | |
|---|---|---|
| Scaling signal | Request concurrency, p95 latency | Queue depth, oldest-job age |
| Resource profile | Short, latency-bound | Long, CPU/IO-heavy, spiky memory |
| Pool sizing | Threads/connections per instance | Concurrency per queue class |
| Key metrics | Error rate, saturation | Retry rate, dead-letter volume, throughput |
# compose.yaml: one image, one codebase, three process roles.
# Each role is its own service, so it scales and fails independently.
services:
web:
image: myapp:latest
# Short, latency-bound requests. Size by threads/connections per instance.
command: gunicorn myapp.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 4
ports:
- "8000:8000"
mem_limit: 512m
worker-critical:
image: myapp:latest
# Consumes ONLY the critical queue (password resets, webhooks),
# so a burst of exports can never occupy these slots.
command: >
celery -A myapp worker -Q critical -n critical@%h
--concurrency=8 --prefetch-multiplier=1
mem_limit: 512m
worker-bulk:
image: myapp:latest
# Exports and reindexing: fewer slots, more memory per slot.
# Recycle a child process after it exceeds ~400 MB (value in KiB)
# to contain spiky memory from large jobs.
command: >
celery -A myapp worker -Q bulk -n bulk@%h
--concurrency=4 --prefetch-multiplier=1
--max-memory-per-child=400000
mem_limit: 2g
# Scale roles separately, e.g. for a backlog on the bulk queue:
# docker compose up -d --scale worker-bulk=6
# Keep total worker slots below what the database pool and
# downstream API rate limits can absorb.
A single-image Compose setup that runs web, critical-queue, and bulk-queue workers as separate services with their own concurrency and memory limits, so each pool can be scaled on its own signal without one queue starving another.
How do you deploy safely: graceful shutdown, idempotency, and mixed-version compatibility?
The shutdown contract
Most platforms send SIGTERM, wait a grace period, then send SIGKILL. A worker that ignores SIGTERM loses whatever it was doing. On SIGTERM, the worker should stop fetching new jobs, let in-flight jobs finish if they can complete within the grace period, and explicitly release or requeue any job that cannot. Set the grace period to cover your longest normal job. Break up any job that runs longer than the grace period.
Assume every job runs twice
Most queues deliver at least once. A kill after the work is done but before the acknowledgement means the job will be redelivered, so jobs must be idempotent. A common pattern is to derive an idempotency key from the business operation, such as invoice-42-send. In the same transaction as the side effect, insert that key into a table with a unique constraint. A duplicate run hits the constraint and exits cleanly.
The mixed-version window
During a rolling deploy, new web code can enqueue jobs that old workers pick up, and old web code can enqueue jobs that new workers pick up. Payloads and schema must stay compatible in both directions for at least one release. Use expand/contract:
- Expand. Add a nullable column, and have workers accept both the old and new payload fields.
- Migrate. Write to both fields, then backfill existing rows.
- Contract. Remove the old field only after no job in flight or in a retry queue can still reference it.
Scheduled work
If you put a cron loop in every worker replica, scaling to five replicas sends five emails. Run periodic jobs from one dedicated scheduler process. If you can't do that, have each scheduled run acquire a lock with an expiry, such as a database advisory lock or a lock row, so only one replica executes it.
import signal
import threading
stopping = threading.Event()
def on_sigterm(signum, frame):
# Stop taking new jobs; the current job may finish within the grace period.
stopping.set()
signal.signal(signal.SIGTERM, on_sigterm)
def send_invoice(conn, payload):
# Expand phase: accept both the new and the old payload field names.
invoice_id = payload.get("invoice_id") or payload.get("id")
key = f"invoice-{invoice_id}-send"
with conn: # psycopg2: one transaction, commit on success, rollback on error
with conn.cursor() as cur:
cur.execute(
"INSERT INTO idempotency_keys (key) VALUES (%s) "
"ON CONFLICT (key) DO NOTHING RETURNING key", (key,))
if cur.fetchone() is None:
return # an earlier delivery already did this work
# Side effect in the same transaction: write an outbox row
# instead of calling the mail API directly.
cur.execute(
"INSERT INTO outbox (kind, ref_id) VALUES ('invoice_email', %s)",
(invoice_id,))
def run(queue, conn):
# `queue` stands in for your queue client (fetch/ack/nack).
while not stopping.is_set():
job = queue.fetch(timeout=5) # short poll so SIGTERM is noticed quickly
if job is None:
continue
try:
send_invoice(conn, job.payload)
queue.ack(job)
except Exception:
queue.nack(job) # release it so another worker can retry
# Loop exits after the in-flight job; the process ends before SIGKILL.
A worker loop that stops fetching on SIGTERM, accepts both old and new payload fields, and records an idempotency key in the same transaction as its side effect so a redelivered job exits without repeating the work.
What does this pattern cost, and when should you avoid it?
Running one codebase as two roles has real costs. Each one buys a specific saving.
| Cost you pay | Engineering time it saves |
|---|---|
| Workers ship every web dependency, which means a larger image, a higher memory baseline and more attack surface | One build pipeline and one artifact to scan and sign |
| Coupled releases, so a web-only change still drains and restarts workers | No cross-service contracts or versioned internal APIs |
| Each process type needs its own scaling policy, dashboards, alerts and connection budget | Domain models and validation are shared directly, not duplicated |
| Idempotency and shutdown handling are real engineering work | Local development runs one repo with two commands |
The connection budget is easy to underestimate. Every role opens its own pool, and the database limit is shared across all of them.
Illustrative estimate (assumed numbers, not a benchmark): total connections = (web instances × web pool size) + (worker instances × worker concurrency) Assume 6 web instances with a pool of 10, and 4 workers with 15 concurrent jobs each: total = (6 × 10) + (4 × 15) = 120. A deploy that briefly runs old and new instances side by side can come close to doubling that figure.
Separate processes do not give you fault isolation for shared state. A bad migration, a database outage or a broken shared library takes down both roles at the same time.
Avoid the pattern in two cases:
- The background work is trivial. If occasional work like sending one email after signup can be lost on restart without harm, an in-process async task is enough.
- The team cannot build idempotency and graceful shutdown. Without them, a separate worker process turns every deploy into a source of duplicated or dropped jobs.
What are the alternatives, and when should a worker role become its own service?
The four options sit on a spectrum. Each step right buys isolation and costs coordination.
| Approach | Isolation | Deploy coupling | Operational overhead | Team ownership |
|---|---|---|---|---|
| In-process threads or async tasks | None: shares memory, CPU and crashes with requests | Total | Lowest | One team |
| Separate worker processes, one codebase | Process-level: own resources and scaling | Same artifact, same release | Low: one build, extra process type | One team, shared modules |
| Separate artifact, shared repo | Process plus dependency set | Independent builds, shared code review | Moderate: two pipelines | Can split by directory |
| Extracted service | Full, including data ownership | None, governed by API or message contract | Highest: contracts, versioning, on-call | Separate team |
Signals the worker has outgrown the pattern
- Divergent dependencies or runtime. The worker needs a heavy native library, a GPU base image or a different language runtime. Shipping that to every web node bloats the image and widens its attack surface.
- Independent release cadence. Job changes wait on web release trains, or web deploys keep interrupting long-running jobs.
- Distinct scaling or security needs. The worker requires network access, credentials or compliance boundaries that the web tier should never hold.
- A team boundary. A different team carries the pager for the jobs and keeps getting blocked on shared code review.
Decision checklist
- Can the worker still run on the web tier's dependency set? Keep one artifact.
- Do releases conflict more than once in a while? Split the build.
- Must credentials or network access differ? Split the build at minimum.
- Does a separate team own it end to end? Extract the service.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith separate worker process, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.