How to Trace a Request Through a Web App, Route to Database

Following one HTTP request end to end is the fastest way to understand a web backend. Here is the path every request takes and how to trace each hop.

By The OpenVisio Team · · 12 min read

If you could only do one thing to understand a web application, it should be this: pick a single HTTP request and follow it all the way from the browser to the database and back. One request crosses almost every layer in the system. It passes through the network edge, the router, middleware, a handler, business logic, data access and the database, and on the way back through serialization and response middleware. Once you have walked one request, the rest of the codebase stops being a pile of folders and becomes a set of layers you have already visited.

This tutorial describes the hops a typical request takes, how to find each one in code, and how to confirm what you read by watching the request run. Examples use a Node/TypeScript stack (Express and Prisma), with notes for Django, Rails, Spring and Go where the details differ. For locating the starting point in the first place, see finding the entry point of any repository.

Key takeaways

  • Pick a real request from the browser's network tab or logs: method, path, body and response. That is your thread.
  • Requests usually pass through the same layers: edge, router, middleware, handler, service, data access, database, then back out.
  • Middleware is where hidden behavior lives: auth, tenancy, rate limits, body parsing, transactions. List it before reading the handler.
  • Follow data, not just calls. Track the request body's fields as they become a validated object, a query and a row.
  • Watch the request run: debugger breakpoints, query logging and request IDs confirm what static reading suggests.
  • Note every async hop (queue publish, event emit, webhook). The request may finish before the work does.

Step 0: Capture a real request

Do not start from code. Start from an actual request. Open the browser's developer tools, go to the Network tab, perform the action you care about, and pick the request that matters. Record:

Method:   POST
URL:      /api/projects/42/invites
Body:     {"email":"sam@example.com","role":"editor"}
Status:   201
Response: {"id":"inv_9f2","status":"pending"}
Headers:  Cookie: session=..., x-request-id: 7c1e...

If the request comes from another service or a mobile client, find it in server logs instead. Having the exact path and payload turns the trace into a series of concrete searches instead of guesses.

The layers a request passes through

Most web backends, whatever the framework, have a variant of this stack:

LayerJobWhere to find it
EdgeTLS, CDN, load balancer, reverse proxy rewritesNginx config, CDN rules, vercel.json, ingress manifests
RouterMatch method and path to a handlerRoute files, decorators, file-based routes
MiddlewareCross-cutting work before and after the handlerapp.use, Django MIDDLEWARE, Rails before_action, Spring filters
Handler / controllerParse input, call business logic, shape responseController or handler files
Service / domainBusiness rules, orchestrationservices/, domain/, model methods
Data accessBuild queries, map rows to objectsORM models, repositories, query builders
DatabaseStore and return data, enforce constraintsSchema, migrations, triggers
Async side effectsWork that continues after the responseQueue publishes, event emitters, webhooks

You will not always find every layer. Small apps put business logic in handlers; some put it in the database. Knowing the generic stack tells you what to look for and notice when it is missing.

Step 1: The edge

Before the request reaches application code, something may rewrite it. A reverse proxy might strip /api, a CDN might serve a cached response, a platform might route /api/* to a different deployment. If the path you captured does not match any route in the code, the edge is the first suspect.

rg -n "location /api|proxy_pass|rewrite" --glob '*.conf'
rg -n "rewrites|redirects" next.config.* vercel.json

Step 2: The router

Find the route that matches. Search for a distinctive segment of the path:

rg -n "invites" --type ts | rg -i "router|app\.(post|get)|route"

Routes are often mounted under prefixes, so /api/projects/42/invites may be defined as router.post('/:projectId/invites', ...) inside a router mounted at /api/projects. Follow the mount chain:

// src/app.ts
app.use('/api/projects', requireSession, projectsRouter)

// src/routes/projects.ts
projectsRouter.post('/:projectId/invites', requireRole('admin'), createInvite)

Two things just happened that matter: requireSession runs for every project route, and requireRole('admin') runs for this one. Write both down.

Framework notes: in Django, follow ROOT_URLCONF in settings to urls.py and its include() calls. In Rails, bin/rails routes | grep invites prints the controller and action. In Spring, search @PostMapping with the path segment and remember a class-level @RequestMapping may add a prefix. In Go, find the router setup in main or a routes.go and look for groups like r.Route("/api/projects", ...).

Step 3: Middleware

Middleware is where the most surprising behavior hides, because it runs without being called from the handler. List every piece that applies to your route, in order:

# Global middleware in Express
rg -n "app\.use\(" src/app.ts

Typical middleware and what to note:

  • Body parsing: is the body JSON, form data, or raw? Size limits?
  • Authentication: how is the session or token read, and what does it attach to the request (req.user, request.user, current_user)?
  • Tenancy: is there a tenant or organization resolved from the subdomain, header or token, and does it scope every query?
  • Authorization: role or permission checks, like requireRole('admin') above.
  • Rate limiting and CSRF: can block requests before the handler.
  • Transactions: some stacks open a database transaction per request in middleware, which changes how errors roll back.
  • Error handling: the error middleware, usually registered last, decides what status code a thrown error becomes.

For each piece, write what it reads and what it adds to the request. When the handler later uses req.user.orgId, you will know where it came from.

Step 4: The handler

Now read the handler. Handlers usually do three things: parse and validate input, call business logic, and shape the response.

// src/handlers/invites.ts
export async function createInvite(req: Request, res: Response) {
  const input = InviteSchema.parse(req.body)            // validation
  const invite = await inviteService.create({            // business logic
    projectId: req.params.projectId,
    invitedBy: req.user.id,
    ...input,
  })
  res.status(201).json(toInviteDto(invite))             // response shape
}

Note the validation schema (it tells you the allowed input), what is pulled from the request context (req.user.id, from middleware), and the response mapper (toInviteDto, which may hide fields). Follow the service call next.

Step 5: The service and business rules

The service is where the rules live. Read it carefully and track the data:

// src/services/invites.ts
async create({ projectId, invitedBy, email, role }) {
  const project = await projects.findById(projectId)
  if (!project) throw new NotFound('project')
  if (await members.exists(projectId, email)) throw new Conflict('already a member')
  const invite = await db.invite.create({
    data: { projectId, email, role, invitedBy, token: randomToken() },
  })
  await queue.add('send-invite-email', { inviteId: invite.id })
  return invite
}

Every throw is a rule and a possible response. Every write is a side effect. The queue.add is an async hop: the response returns before the email is sent, in a different process. Mark it and decide whether to follow it now or later.

Step 6: Data access and the database

ORMs hide the actual SQL. Make it visible:

// Prisma: log every query
const db = new PrismaClient({ log: ['query'] })

Other stacks: Django's connection.queries with DEBUG = True or the Django Debug Toolbar; Rails logs SQL in development by default; Hibernate with spring.jpa.show-sql=true; in Postgres directly, SET log_statement = 'all' in a local database or check pg_stat_statements.

Then check the schema for what the query relies on: unique constraints (maybe members.exists is backed by a unique index too), foreign keys, defaults, and triggers. Database constraints are business rules that never appear in application code. Read the latest migration or schema file for each table you touched.

Step 7: The way back

The response path is shorter but has its own logic: the DTO mapper decides which fields leave the server, response middleware may add headers or compress, and the error middleware turns exceptions into status codes. Find where NotFound and Conflict become 404 and 409:

rg -n "instanceof (NotFound|Conflict)|statusCode" src/middleware

Step 8: Follow the async hops

For each queue publish or event emitted during the request, find the consumer:

rg -n "send-invite-email" --type ts

You will find a worker, often in a separate entry point, that processes the job. Treat it as its own small trace: what it reads, what it writes, what it calls externally, and what happens on failure (retries, dead-letter queue). Many production bugs live here because the request succeeded and nobody watched the job.

Confirm by watching it run

Static reading gives you a hypothesis. Confirm it:

  • Debugger: put a breakpoint in the first middleware and step through. For Node, node --inspect and attach your editor; for Python, breakpoint() in the view; for Go, dlv debug.
  • Request IDs: if the app logs a request ID, filter logs by it to see every log line the request produced, across services.
  • Distributed tracing: if the system uses OpenTelemetry or a tracing vendor, a single trace shows every span, including database queries and downstream calls, with timings. This is the closest thing to a ground-truth trace you will get.
  • Tests: the integration test for the endpoint shows the setup it needs and the responses it expects.

Write the trace down

End with a compact written trace. It is useful to you next week, to the next new teammate, and as a PR description when you change the flow:

POST /api/projects/:id/invites
edge:       /api passed through unchanged
middleware: json body (1mb) -> requireSession (sets req.user) -> requireRole('admin')
handler:    handlers/invites.ts:3 createInvite, InviteSchema(email, role)
service:    services/invites.ts:2 create
            404 if no project, 409 if already member
            INSERT invite (unique: project_id+email)
            queue send-invite-email  -> workers/email.ts:18 (retries 5, backoff)
response:   201 toInviteDto (hides token)

Tools that shorten the trace

On a large or unfamiliar codebase, the slow part is finding each hop. Call graphs and code search help: following "who calls this" and "what does this call" from the handler outward gives you most of the static trace. Coding agents can do the same over MCP with tools such as trace_calls, covered in how AI coding agents understand a codebase. In OpenVisio, asking Ask a question like "what happens when a project invite is created?" returns a numbered walk through the steps with the relevant code beside each one, which you can then confirm with the debugger and query log as described above.

The method stays the same with or without tools: capture a real request, walk every layer, follow the data, watch it run, and write the trace down.

request tracingweb backenddebuggingreading codeobservability

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading