You've written fetch(). You've set up an Express route. It works.
But do you actually know what happened in between? Most tutorials show you the pieces separately. Nobody walks you through the full conversation — browser to server to database and back — in one go.
Let's fix that.
The analogy that makes it click
Before we touch code, here's a mental model you'll never forget.
Think of it like ordering food at a restaurant:
- You (the customer) = the browser
- The waiter = the Express server / API
- The kitchen = your controller + business logic
- The pantry = the database
You don't walk into the kitchen and grab food yourself. You tell the waiter. The waiter goes to the kitchen. The kitchen checks the pantry, does its thing, and sends the food back through the waiter to your table.
That's a web request. Now let's trace it step by step.
Step 1 — User clicks a button (Browser)
Everything starts with an event on the frontend. The user clicks "Add Job" and a JavaScript event listener fires.
button.addEventListener('click', async () => {
const response = await fetch('http://localhost:5000/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company: 'Stripe', role: 'Frontend Intern' })
});
const data = await response.json();
console.log(data);
});
Three things are happening inside that fetch() call:
-
methodtells the server what kind of action you want — GET to read, POST to create, PUT to update, DELETE to remove -
headerstell the server what format your data is in — here we're saying "I'm sending JSON" -
bodyis your actual data, converted from a JS object into a JSON string usingJSON.stringify()
Step 2 — The HTTP request travels to the server
Once fetch() fires, the browser packages your data into an HTTP request and sends it over the network. In production this goes over the internet. In development it stays on your machine via localhost.
Under the hood, the raw request looks like this:
POST /api/jobs HTTP/1.1
Host: localhost:5000
Content-Type: application/json
{"company":"Stripe","role":"Frontend Intern"}
Just structured text. That's genuinely all an HTTP request is — a text message with a specific format that both the browser and server agree to understand.
Step 3 — Express receives it and matches a route
Your Express server is sitting there, listening on port 5000, waiting for incoming requests.
import express from 'express';
const app = express();
app.use(express.json()); // parses the request body into req.body
app.post('/api/jobs', addJob); // POST + /api/jobs → runs addJob
app.listen(5000);
When a POST request comes in for /api/jobs, Express checks it against every registered route. Path matches? Method matches? It hands the request off to the addJob function.
But before addJob runs — middleware steps in.
Step 4 — Middleware runs first
Middleware is code that runs between the request arriving and your controller handling it. Think of it as a series of checkpoints your request must pass through.
// Logging — runs on every request
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next(); // pass the request forward
});
// Auth — stops requests without a valid token
app.use((req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
next();
});
Each middleware either:
- Calls
next()→ request moves forward to the next middleware or controller - Sends a response directly → request stops right there
express.json() is itself middleware — it reads the raw request body and converts it into a JS object on req.body. Without it, req.body is undefined and you'll spend 30 confused minutes wondering why.
Step 5 — Controller runs the business logic
Now your request finally reaches the controller — the function that handles the actual work.
export const addJob = async (req, res) => {
const { company, role } = req.body;
try {
const result = await pool.query(
'INSERT INTO jobs (company, role) VALUES ($1, $2) RETURNING *',
[company, role]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Something went wrong' });
}
};
The controller pulls data from req.body, talks to the database, and sends back a response. Its only job is to connect the HTTP world to your database world.
Step 6 — The database stores the data
The line pool.query(...) sends a SQL command from your Node.js server to PostgreSQL:
INSERT INTO jobs (company, role)
VALUES ('Stripe', 'Frontend Intern')
RETURNING *;
What is pool and why not just a direct connection?
A connection pool keeps a set of database connections open and ready to reuse — instead of opening and closing a fresh connection for every single request, which is slow and expensive.
// db.js — PostgreSQL
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
export default pool;
For SQLite, it looks like this instead:
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
const db = await open({
filename: './database.db',
driver: sqlite3.Database
});
Different databases, same idea — your server needs a stable, efficient way to talk to them.
PostgreSQL runs the query, stores the row, and returns the newly created record because of RETURNING *. That result comes back as result.rows[0] in your controller.
Step 7 — The response travels back to the browser
Your controller sends:
res.status(201).json(result.rows[0]);
This builds an HTTP response and sends it back to the browser:
HTTP/1.1 201 Created
Content-Type: application/json
{"id":1,"company":"Stripe","role":"Frontend Intern"}
Back in the browser, your await fetch(...) resolves. response.json() parses that JSON string back into a JavaScript object. You now have the saved data on the frontend:
const data = await response.json();
setJobs(prev => [...prev, data]); // new job appears in the UI instantly
No page reload. Just a clean round trip.
Common mistakes to avoid
1. Forgetting express.json() middleware
req.body will be undefined. Always add app.use(express.json()) before your routes — not after.
2. Not awaiting response.json()
// Wrong — this is a Promise, not your data
const data = response.json();
// Right
const data = await response.json();
3. Sending a response twice in Express
// Wrong — both lines run, server crashes
if (!user) res.status(404).json({ error: 'Not found' });
res.json(user);
// Right — use return to stop execution
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
4. Confusing status codes with response data
Status 200 OK means the request completed — not that your data is correct. Always check both response.status and response.json().
When something breaks, trace it step by step
Now you have a map. Use it:
- Is the request leaving the browser? → Check the Network tab in DevTools
- Is it hitting the server? → Check your server terminal logs
- Is the route matching? → Check your Express route definitions
- Is the DB query working? → Test the query directly in your DB client
- Is the response making it back? → Check
response.statusin the browser
Wrapping up
One click. Seven steps. Every time.
The click-to-database flow isn't magic — it's a structured conversation between your browser, your server, and your database. Each layer has one job. Together they make your app feel instant and alive.
Next time you write a fetch() call, you'll know exactly what's happening on the other side.
Top comments (0)