Running Long-Running Tasks
A practical guide to moving heavy work out of request handlers without overengineering it on day one.
Long-running work usually does not belong inside a request handler.
If a task takes seconds or minutes because of heavy I/O, large batch processing, or slow external services, holding the HTTP request open is usually the wrong contract. It ties up server capacity, makes timeouts more likely, and forces the user to wait for work that often does not need to finish synchronously.
That is the real smell.
The question is not "How do I make this request survive longer?" It is usually "Why is this still part of the request at all?"
The first distinction that matters
Not all long-running work is the same.
Some work should happen immediately, just not inline with the request. Some work should happen later on a schedule. Some work needs retries, persistence, and visibility.
Those are different problems, which means they deserve different tools.
Event emitter: fine for simple in-process handoff
For lightweight cases inside a single Node process, EventEmitter can be enough.
import { EventEmitter } from "node:events";
const eventEmitter = new EventEmitter();
app.post("/some-route", (req, res) => {
// validate request and do synchronous business logic
eventEmitter.emit("start-heavy-work");
res.sendStatus(200);
});
eventEmitter.on("start-heavy-work", () => {
// do the heavy work
});This is useful when you want a cheap handoff and you control the whole process.
But the limitation is important: if the process crashes, restarts, or scales horizontally, that event is gone. There is no persistence, retry story, or shared queue between instances.
That makes this a convenience pattern, not a durable job system.
Cron jobs and schedulers: good when time is the trigger
If the work should happen on a schedule, a cron job may be all you need.
* * * * * /usr/bin/node /path/to/your/job.jsThis is a good fit when you can mark records in the database and let a scheduled job sweep through them later.
Libraries like node-schedule, toad-scheduler, bree, or node-cron give you a similar model from inside Node:
import schedule from "node-schedule";
schedule.scheduleJob("42 * * * *", () => {
// run scheduled work
});The tradeoff is that scheduled jobs solve "when should this run?" They do not automatically solve reliability, retries, observability, or per-request job tracking.
Queue: the stronger default once the work matters
If the work needs to survive process restarts, be retried, or run separately from the web server, a queue is usually the right tool.
A queue changes the shape of the problem:
- the request enqueues a job
- the server responds quickly
- a worker processes the job asynchronously
That is the right model for email sending, report generation, media processing, third-party sync jobs, and most other background work that actually matters.
Tools like BullMQ are a good place to start because they give you persistence, retries, and worker separation without forcing a huge platform decision on day one.
The common mistake
The most common mistake is picking the tool based on what sounds sophisticated instead of what failure mode you need to handle.
If the task is small and losing one run is acceptable, an in-process handoff might be fine.
If the task must happen eventually, be retried, or be visible to operations, skip the cute solutions and use a queue.
A useful rule of thumb
When a controller starts feeling like a worker, split the work.
When the background work starts feeling important, persist it.
That is usually enough to choose the right next step.

