Skip to content

Technology · 11 September 2026

Two processes, one system: async architecture with a background worker

Almost nothing that matters in an email-first ATS should happen inside a web request. We split the system into a web tier and a background worker with a durable queue between them, so the interface stays fast no matter what the outside world is doing.

Written by

Pancham Narang

6 min read

Polling mailboxes, parsing PDFs, calling a model and sending email are all slow, external and failure-prone, so none of them belongs in a web request. We split the system into a web tier and a background worker joined by a durable Postgres-backed queue, so the interface stays fast whatever the outside world is doing.

The web request is the wrong place for the real work

Think about what an email-first ATS actually does: it polls mail servers, downloads and parses PDFs, calls a large language model, and sends email over SMTP. Every one of those operations is slow, dependent on someone else's infrastructure, and prone to failure and rate limiting. None of them belongs inside the request that renders a page.

If a recruiter loads their dashboard and that request has to wait on a candidate's mail server, the product feels broken through no fault of its own. Do the parsing or the model call in-request and you get timeouts, half-finished work when a request dies partway, and an interface whose responsiveness is hostage to third parties. The first architectural decision made itself: the slow, external, failure-prone work has to happen somewhere other than the web request.

Two processes sharing one database

So the system runs as two processes over one shared database. A web tier serves the interface and, when something slow needs doing, enqueues a job rather than doing it inline. A separate worker process consumes those jobs and does the heavy, external, retry-prone work on its own time.

The web tier's job is to be fast and always responsive; the worker's job is to be patient and durable. They never block each other because they only communicate through the queue. The part people touch stays quick, while the part that talks to the world stays reliable.

A queue that lives in the database we already run

The obvious way to build that seam is to add a dedicated message broker. We chose not to. Instead, the queue lives in the same Postgres database that backs the rest of the application, as a Postgres-backed job queue. That choice bought three things we valued.

First, transactional enqueue: because the queue is in the same database, a row change and the follow-up job it schedules can commit together, so we never end up with a job for a write that did not happen or a write with no job to follow it. Second, one datastore to operate, back up and reason about, instead of two. Third, no additional piece of infrastructure to run, secure and keep alive. For our scale and reliability profile, a broker would have been complexity without a matching payoff; we can outgrow that decision later rather than pre-paying for it now.

The jobs, and the failures that will happen

The work divides cleanly into a handful of job types. Mailbox polling wakes on an interval, checks each connected inbox and turns new mail into applications. Email sending delivers outbound replies over each tenant's own SMTP. Screening runs extract a CV's text, call the model and record the structured result. Each is an independent unit of work with its own handler, so each can fail, retry and scale on its own terms without dragging the others down.

When your work depends on other people's servers, failure is not an edge case, it is Tuesday. A mail server is briefly down; an SMTP relay rate-limits you; the model times out. So the queue owns retries and backoff, and the handlers own idempotency where it counts. Ingestion is keyed on the message's identifier, so re-running a poll after a partial failure does not create the same application twice; sends are recorded so a retry does not email a candidate twice. The queue decides when to try again, and the handlers make sure trying again is safe.

Where the ecosystem bites, and what the split buys

Async, external-facing code is exactly where the messiness of a modern dependency tree shows up, and we would be dishonest to pretend otherwise. Getting this tier stable meant pinning the queue library to a major version whose module format matched our runtime, sidestepping a PDF parser that quietly pulled in a browser build that broke under the server, and resolving a mail library whose peer requirements collided with our authentication library. None of these were deep problems, but together they are a reminder that the boundary where your system meets the outside world is also where you spend your integration budget.

The cost of the split is real but small: two processes to deploy and monitor instead of one, and a little operational discipline. In return, the interface never waits on the outside world, so the dashboard is fast whether or not a candidate's mail server is having a good day. Long-running work survives crashes and restarts because it lives in a durable queue rather than in memory, and the worker can be scaled independently of the web tier as hiring volume grows.