Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Tracing Data and Control Flow in Node.js Integration Services

Hello! Last time, you established how to start an unfamiliar Node.js service and verify what its health endpoint actually proves. Now the service is running, the next question is more useful for backend work: when a request arrives, which code runs, where does each value come from, and what determines the response?

This lesson gives you a repeatable tracing method, then applies it to a small integration service. You will follow both a successful request and a simulated downstream failure without needing real credentials or an external system.


Start with the HTTP boundary, not a filename guess

A request trace begins with a specific method and URL. Searching a repository for a business term can find useful code, but it cannot tell you whether that code runs for the request you care about. First locate the startup entry point you identified from package.json. In the Express app, look for registrations such as app.use(...), app.get(...), and app.post(...). Then follow any router imported from another file.

Express evaluates matching middleware and route handlers in registration order. A handler may send a response, pass control with next(), or pass an error to error-handling middleware. Those choices determine which code is reachable.

Learn Express Middleware In 14 Minutes

Watch “Learn Express Middleware In 14 Minutes” from Web Dev Simplified for a visual account of how a request moves between handlers.

Watch the definition to establish what counts as middleware. Then watch the logging example, focusing on registration order and what next() does. Finish with the return pitfall: calling next() does not, by itself, stop the current function from executing later statements.

The video's later example treats a query parameter as an authentication signal. That is useful for demonstrating a control-flow branch, not a secure authentication pattern: a client can choose its own query parameters.

In a larger service, routes often live in a separate express.Router() module. The mount path in the app and the path in the router must both be considered. If an app mounts a router at /api and that router registers POST /jobs/:jobId/sync, the request path is /api/jobs/:jobId/sync.

Express routing

Read the Express guide’s “express.Router” subsection to see how a route can be defined in one file but reached through a path registered in another.

In “express.Router,” begin where the guide says it creates a router as a module. Read the router example, including the CommonJS birds.cjs and index.cjs examples. Focus on the exported router, its app.use() mount, and the resulting request paths; you do not need the TypeScript variant.

For each candidate route, keep two questions separate: Does this request match its method and path? If so, does an earlier handler end or redirect processing before this one runs? A nearby function in the editor is not necessarily a function on the request’s path.


Track values and control as separate threads

Once you have found the route, record the origin of each value it reads:

  • req.params holds segments captured by the route path. For /jobs/:jobId/sync, a request to /jobs/J17/sync supplies req.params.jobId as "J17".
  • req.query holds URL query values, such as tag=manual after a ? in the URL. The query string is not part of the route path.
  • req.body holds parsed request content if appropriate parsing middleware ran first. For a JSON request in Express, express.json() commonly supplies this parsing.

These are sources, not guarantees that values are valid. When tracing unfamiliar code, mark the first place each value is checked, normalized, renamed, or passed to another function. If you find no such check, record that observation rather than assuming one exists elsewhere.

Control needs its own notes. next() passes control to the next applicable handler; res.json(...) sends a response; next(error) passes an error to error handling. Middleware that neither responds nor calls next() can leave a request pending. An error handler has four parameters, distinguishing it from ordinary middleware.

Using middleware

Read the Express guide’s middleware overview and “Error-handling middleware” subsection to pin down these control-flow exits.

In the overview, read the request-cycle explanation. Then go to “Error-handling middleware” near the bottom and read the handler signature and the JavaScript example immediately below it. Notice that its response ends the error path.

A practical trace therefore has two columns in your mind: data (the value before and after each transformation) and control (the condition or call that makes the next function run). This distinction matters especially around await: the next line in the current function waits for a result, while a rejection may select an entirely different response path.


Trace a small integration service

Use the scratch service from the previous lesson. Keep its existing package.json and .env; the example below still requires DOWNSTREAM_BASE_URL at startup, but does not contact that URL. Replace src/server.js and add the four files shown. Create the directories src/routes, src/controllers, src/services, and src/adapters if needed.

The names describe where to look, but do not rely on the names to infer behavior. Read the registrations and calls.

src/server.js:

require("dotenv").config();
const express = require("express");
const jobRouter = require("./routes/jobs");

const port = Number(process.env.PORT || 3000);
const downstreamBaseUrl = process.env.DOWNSTREAM_BASE_URL;

if (!downstreamBaseUrl) {
  throw new Error("DOWNSTREAM_BASE_URL is required");
}

const app = express();

app.use(express.json());

app.use((req, res, next) => {
  console.log("incoming", req.method, req.originalUrl);
  next();
});

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.use("/api", jobRouter);

app.use((error, req, res, next) => {
  console.error("request failed", error.message);

  const status = error.status === 502 ? 502 : 500;
  res.status(status).json({
    error: status === 502 ? "DOWNSTREAM_FAILED" : "INTERNAL_ERROR"
  });
});

app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

src/routes/jobs.js:

const express = require("express");
const { createSync } = require("../controllers/jobController");

const router = express.Router();

router.post("/jobs/:jobId/sync", (req, res, next) => {
  req.traceTag = req.query.tag || "none";
  next();
}, createSync);

module.exports = router;

src/controllers/jobController.js:

const { syncJob } = require("../services/syncJob");

async function createSync(req, res, next) {
  try {
    const result = await syncJob({
      jobId: req.params.jobId,
      recordId: req.body.recordId,
      traceTag: req.traceTag
    });

    res.status(200).json(result);
  } catch (error) {
    next(error);
  }
}

module.exports = { createSync };

src/services/syncJob.js:

const { sendToDownstream } = require("../adapters/downstream");

async function syncJob({ jobId, recordId, traceTag }) {
  const outbound = {
    externalJobId: jobId,
    recordId: recordId.trim()
  };

  const downstream = await sendToDownstream(outbound);

  return {
    jobId,
    downstreamId: downstream.downstreamId,
    accepted: downstream.accepted,
    traceTag
  };
}

module.exports = { syncJob };

src/adapters/downstream.js:

async function sendToDownstream(payload) {
  if (payload.recordId === "fail") {
    const error = new Error("simulated downstream failure");
    error.status = 502;
    throw error;
  }

  return {
    downstreamId: `ds-${payload.recordId}`,
    accepted: true
  };
}

module.exports = { sendToDownstream };

Start the service with npm run dev. Send this request using an HTTP client; if your shell supports the command below, it provides a reproducible example:

curl -i -X POST 'http://localhost:3000/api/jobs/J17/sync?tag=manual' \
  -H 'Content-Type: application/json' \
  --data '{"recordId":"  C42  "}'

Use your configured port if it is not 3000. Before interpreting the response, trace the request through the files:

  1. In server.js, express.json() runs before the router and makes the JSON body available. The logging middleware records the method and original URL, then calls next(). The /health route does not match this request.
  2. The /api mount leads into jobs.js. There, the method and remaining path match router.post("/jobs/:jobId/sync", ...). The path supplies "J17"; the query supplies "manual".
  3. The route middleware stores the query value on req.traceTag and calls next(), allowing createSync to run.
  4. The controller collects the path value, body value, and stored tag into an argument for syncJob. The service trims " C42 " to "C42" and renames jobId to externalJobId in its outbound object.
  5. The adapter returns a simulated downstream result. The service selects fields for its result, and the controller sends HTTP 200 with that result as JSON.

The response should contain jobId: "J17", downstreamId: "ds-C42", accepted: true, and traceTag: "manual". Notice that the outbound object and the HTTP response have different shapes. Tracing only the incoming body or only the final response would miss where that change happened.

Now send the same request with {"recordId":"fail"}. The adapter throws instead of returning a result. Because the controller awaits the service inside try, its catch passes the error to next(error). The final error-handling middleware sends HTTP 502 with {"error":"DOWNSTREAM_FAILED"}. Neither the service’s success return nor the controller’s HTTP 200 response runs on this path.

This project uses Express 4, as declared in the previous lesson’s manifest. The controller’s explicit try/catch is important here: do not assume an asynchronously rejected route handler will automatically reach Express 4 error middleware. The example also deliberately lacks payload validation; recordId.trim() assumes a string. For this lesson, identify that assumption, rather than expanding the lab into a validation implementation.


Make the trace useful on an unfamiliar repository

In a real integration service, the files may not be named controller, service, or adapter. Work from evidence instead of directory conventions. Start with the observed request and the configured entry point; follow router mounts and imported handlers; then follow function calls until you find both the external operation and the code that sends the response. Repeat for a failure branch.

A compact trace note makes your reasoning reviewable:

Point in this request Evidence worth recording
Route match POST, /api mount, /jobs/:jobId/sync router path
Incoming values jobId from path, tag from query, recordId from parsed JSON
Transformation recordId.trim(); jobId renamed externalJobId for the adapter
External boundary sendToDownstream is a local simulation, not a real network call
Success exit Controller sends HTTP 200 after the awaited result
Failure exit Rejection reaches next(error); error middleware sends HTTP 502

When the code is too large to hold in your head, inspect one value at a time with “Find References” or a temporary breakpoint. Compare its observed value before and after a call, and avoid logging credentials or full customer payloads. A runtime observation can confirm a proposed path; it does not prove that every other branch behaves the same way. Keep the distinction from the previous lesson: a running server, a responding route, and a successful downstream integration are separate claims.


You can now trace a request by locating its route, identifying each input’s source, following transformations and awaited calls, and marking the exact success and failure exits. The simulated adapter made both branches visible without suggesting that an external system was reached. Next, you will use this kind of trace to turn a backend change request into testable acceptance criteria and an implementation plan.

Can't find a good explanation? Sign up and we'll make it for you

Sign up