Home / Articles

From Working Script to Production Node.js API: Event Loop, Worker Threads, Logging

How the event loop, blocking work, worker threads, timeouts, concurrency, structured logs and database access shape a Node.js API that holds up in production.

Publisher: REACTAPP.TOP11 min read2621 words

This article is published in English.

Sources: The Node.js Runtime Is the Easy Part

Getting a Node.js API to respond is rarely the hard part. The endpoint returns data, the frontend renders it, the test suite is green. The hard part begins in production, when thousands of requests arrive at once, a database slows down, a third-party API stops answering, memory creeps upward, or a request that normally takes 200 milliseconds suddenly takes 12 seconds. There is also a quieter question: will another developer understand this code six months from now?

Those questions get answered by how the runtime behaves and by a handful of design habits. Below you'll see where the Node.js execution model shines, where it falls over, and which patterns keep a service responsive once real traffic shows up: moving CPU work to worker threads, timeouts, concurrent network calls, structured logging and careful queries.

What the runtime is actually good at

The standard pitch is that Node.js lets JavaScript run outside the browser. True, but it misses the interesting part. What Node.js really offers is an event-driven runtime with asynchronous I/O and a very large ecosystem around it. That design pays off whenever a server spends most of its time waiting on something else:

  • queries to a database
  • outbound HTTP calls
  • reading and writing files
  • network traffic in general
  • message queues
  • long-lived WebSocket connections

Trace a single request through a typical backend:

Client
  ↓
API Server
  ↓
Database
  ↓
External API
  ↓
Response

At almost no point in that chain is the server busy computing. It is mostly waiting for the database, then for the external API. The Node.js model is built to use that idle time to serve other requests, and understanding this is more valuable than any particular API.

How the event loop changes your mental model

JavaScript in a Node.js application normally runs on one main thread, while the event loop coordinates asynchronous operations around it. A simplified picture of a request's life:

Incoming Request
       ↓
   JavaScript
       ↓
Start I/O operation
       ↓
Don't block execution
       ↓
   Event Loop
       ↓
I/O completes
       ↓
Callback / Promise
       ↓
Continue execution

Here is what that looks like in ordinary code:

const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
  user,
  orders
};

The await pauses this function, not the process. While the database works on the first query, the runtime is free to handle other callbacks and other requests. That is why a single Node.js process can juggle a large number of requests that mostly wait on the network.

One nuance worth noticing: the two queries above run one after the other, because the second await only starts after the first resolves. We will come back to that in the section on concurrency.

Asynchronous is not the same as fast

The model has a hard limit. If your own JavaScript performs a long computation, nothing else on that thread can run until it finishes. A loop like this one keeps the thread occupied for its entire duration:

function expensiveOperation() {
  let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
    result += i;
  }
  return result;
}

While it runs, incoming requests are not being accepted, promises are not resolving, and timers are not firing on schedule. There is no scheduler that pre-empts the loop to give other requests a turn. This is the core reason why knowing how the runtime behaves matters more than memorizing its APIs.

The blocking handler problem

Now put that kind of work inside an HTTP handler. Image resizing, big data transformations or heavy calculations performed directly in the request path look harmless in code:

app.post("/process", async (req, res) => {
  const result = expensiveComputation(req.body);
res.json(result);
});

The trouble only appears under load. One request kicks off the expensive function and monopolizes the event loop. Every other request queues behind it. Latency climbs across the board, not just for the heavy endpoint, and the application process itself becomes the bottleneck. Marking the handler async does not help, because the computation inside is still synchronous.

A sturdier design moves CPU-bound work out of the request path entirely:

Client
  ↓
Node.js API
  ↓
Queue
  ↓
Worker
  ↓
Heavy computation
  ↓
Database / Storage

The API accepts the job, places it on a queue and responds quickly, while dedicated workers process jobs at their own pace and write results to a database or object storage. The API stays responsive, and you can scale workers independently of the web tier. This shift, from writing handlers to designing the flow of work, is much of what separates familiarity with Node.js from building systems with it.

When not to reach for a queue

A queue adds infrastructure, retries and eventual consistency to reason about. For short CPU tasks that must return a result synchronously, Node's worker threads keep the work in-process but off the main thread. For tasks that take milliseconds, doing them inline is often fine. The decision depends on how long the work takes and whether the client must wait for the answer.

Express is a framework, not the runtime

Many developers blur the line between Node.js and Express. Node.js is the runtime. Express is a web framework built on top of it. You can serve HTTP with nothing but the built-in http module:

const http = require("http");
const server = http.createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "application/json"
  });
  res.end(JSON.stringify({
    message: "Hello"
  }));
});
server.listen(3000);

That works, but routing, middleware, body parsing, validation and error handling all become your job as the application grows. Frameworks exist to take that burden off you. The same idea in Express looks like this:

import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
});
app.listen(3000);

Express does not replace anything in Node.js; it offers a more convenient abstraction over the same primitives. Keeping that distinction clear helps when you debug: a slow endpoint might be a framework concern, a runtime concern or neither.

Production code is mostly about boundaries

A tiny service can live in a single file. A production system cannot, because different responsibilities need clear seams between them. One reasonable layout:

src/
├── api/
│   ├── routes/
│   ├── controllers/
│   └── middleware/
│
├── services/
│   └── userService.js
│
├── repositories/
│   └── userRepository.js
│
├── config/
│   └── environment.js
│
├── jobs/
│   └── emailWorker.js
│
└── app.js

The specific folder names are not the point. What matters is that responsibilities stay separated:

  • A controller should not accumulate hundreds of lines of business rules.
  • A repository should know nothing about how HTTP responses are shaped.
  • A service should not take Express req or res objects as inputs.

These seams are what make code testable in isolation, replaceable piece by piece and understandable by the next person who reads it. For a deeper treatment of this structure, see our guide to layered Node.js API design.

Designing for failure in async code

Every Node.js developer learns this line early:

const data = await fetchData();

Production forces harder questions about it. What if fetchData() takes 30 seconds? What if it throws? What if it throws three times in a row? What if the upstream service is completely down? What if a database write succeeds but the next step fails, leaving the system half-updated?

Dependable backend code defines behaviour for those cases instead of only describing the happy path. A small example that makes failure explicit:

async function getUserProfile(userId) {
  try {
    const user = await userService.getUser(userId);
if (!user) {
      throw new Error("User not found");
    }
    return user;
  } catch (error) {
    logger.error({
      userId,
      error: error.message
    });
    throw error;
  }
}

The function turns a missing user into an error, logs failures with the context needed to investigate them, and rethrows so the caller can decide how to respond. The underlying principle is that failure handling is a design decision, not something added after an incident.

A practical caveat: if every layer logs and rethrows, one failure produces several log lines. Decide where errors are logged (often once, at the boundary that handles them) and let inner layers attach context instead.

Never wait forever: timeouts

Here is a call to another service with no protection at all:

const response = await fetch("https://example.com/api");

If the remote server accepts the connection but never replies, this await may hang far longer than any user will tolerate, holding resources the whole time. A production system needs an explicit upper bound. With the standard AbortController, available in modern Node.js, you can enforce one:

const controller = new AbortController();
const timeout = setTimeout(() => {
  controller.abort();
}, 5000);
try {
  const response = await fetch(
    "https://example.com/api",
    {
      signal: controller.signal
    }
  );
  return await response.json();
} finally {
  clearTimeout(timeout);
}

The timer aborts the request after five seconds, and the finally block clears it so it does not fire after a successful response. Newer runtimes also provide AbortSignal.timeout(ms), which expresses the same idea in a single expression.

Five seconds is not a magic number and may be far too generous for a user-facing endpoint. Choose a value from the endpoint's latency budget. The non-negotiable part is that an unbounded wait is not an acceptable way to fail.

Concurrency without parallelism

When two operations do not depend on each other, awaiting them one after the other wastes time:

const user = await getUser();
const orders = await getOrders();

The second call does not even start until the first finishes, so total latency is the sum of both. Starting them together and waiting for both is usually better:

const [user, orders] = await Promise.all([
  getUser(),
  getOrders()
]);

Both requests are now in flight simultaneously, and total latency is roughly that of the slower one. This is concurrency, not parallel computation: your JavaScript still runs on one thread, but the waiting overlaps. The difference matters most in endpoints that fan out to several downstream services.

Two edge cases to keep in mind. Promise.all rejects as soon as any input rejects, so use Promise.allSettled when partial results are acceptable. And launching hundreds of calls at once can overwhelm a database pool or a rate-limited API, so cap concurrency when the list is large.

Performance lives in the architecture

It is common to see teams debate micro-level choices, such as a classic

for

loop compared with

forEach

while the same endpoint fires five unnecessary database queries. Or to see a small helper tuned carefully while the API returns enormous JSON payloads, or while every request waits 800 milliseconds on a slow query.

In real services, response times are usually dominated by:

  • how the database is accessed
  • network latency
  • inefficient or unindexed queries
  • excessive serialization
  • dependence on external APIs
  • work that does not need to happen at all
  • memory pressure
  • missing or poorly designed caching

The largest wins tend to come from architectural changes, not syntax. If you want a structured way to prioritize, see our priority-ordered framework for Node.js API performance.

Observability is part of the product

A server that cannot tell you what it did is very hard to operate. Logging is the first layer, but a line like this is nearly useless in production:

console.log("Something went wrong");

Structured logging records an event name and the identifiers you would need to investigate:

logger.error({
  event: "payment_failed",
  orderId,
  userId,
  error: error.message
});

Because each entry is an object rather than free text, logs can be filtered, aggregated and queried: every payment_failed event for one user, for example.

Logs alone still leave gaps. Production systems generally rely on three complementary signals:

Logs
  ↓
What happened?

Metrics
  ↓
How often / how much?

Traces
  ↓
Where did the request spend its time?

Logs tell you what happened, metrics tell you how often and how much, and traces show where a request spent its time across services. When someone reports that "the application is slow", these signals let you answer with evidence instead of guesses.

The runtime does not design your data access

Picking Node.js does not dictate a database. It works well with PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch and many others. What matters far more is how the code talks to whichever store you pick. This endpoint looks perfectly reasonable:

app.get("/products", async (req, res) => {
  const products = await db.query(
    "SELECT * FROM products"
  );
res.json(products);
});

In production it raises a string of questions immediately:

  • How many rows does products hold today, and how many next year?
  • Is there any pagination?
  • Is the query supported by an index?
  • Could this response grow to 50 MB?
  • How does it behave under concurrent traffic?
  • Is the connection pool sized with sensible limits?
  • Could a raw database error leak to the client?

Node.js will not answer any of these for you. Those decisions belong to whoever designs the service.

Node.js as one component among many

Node.js is widespread in cloud environments partly because its lightweight process model fits containers and service-oriented designs well. In such systems it is typically one piece among several: an API tier alongside queues, workers, caches, databases and other services, each with its own responsibility. It does not have to be the whole system, and a real backend is rarely just "an Express API".

Where Node.js needs help

No runtime fits every workload. Node.js is strongest when work is dominated by I/O, and CPU-heavy tasks call for more deliberate design. Examples:

  • image processing at scale
  • video encoding
  • intensive scientific computing
  • some machine learning workloads
  • heavy cryptographic operations
  • very large data transformations

None of this rules Node.js out of such systems. It means the process handling HTTP requests should not be the one doing the heavy lifting. The options include:

  • worker threads within the same process
  • separate background worker processes
  • queues between the API and workers
  • dedicated services for the expensive part
  • other languages or runtimes better suited to the computation

The aim is not to force every problem into Node.js, but to place it where its execution model fits.

Asking "why", not just "how"

Early on, the question is how to build an API with Node.js. With experience, it becomes why the API should be built a particular way, which leads to a different set of questions:

  • Should this operation be synchronous, or go through a queue?
  • Should the response be cached?
  • Does this service own this data?
  • Should this endpoint depend on three other services, and what happens when one fails?
  • Where do retries belong?
  • What should be logged and what should be measured?
  • How will this behave under load?

None of these are specific to Node.js. They are software engineering questions, and Node.js supplies the tools to implement the answers.

The production checklist in short

  • The event loop makes Node.js excellent at overlapping network and disk waits, but any synchronous CPU work stalls every request on that thread.
  • Move heavy computation off the request path with worker threads, queues and workers, or separate services.
  • Treat failure paths and explicit timeouts as part of the design; an unbounded wait is a bug.
  • Run independent downstream calls concurrently with Promise.all, and bound concurrency when fanning out widely.
  • Performance problems usually live in queries, payload sizes and network hops, not loop syntax.
  • Structured logs, metrics and traces turn production complaints into evidence.
  • Frameworks and runtimes are tools; architecture, boundaries and data access remain the developer's responsibility.