Node.js NotesRohit’s interview study guide
Chapter 07

Express

Routing, the middleware chain, responses and centralised error handling in Express 5.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#A minimal Express app

Node's built-in http module gives you one callback for every request. Inside it you get a raw request (a readable stream plus a method, a URL string and headers) and a raw response (a writable stream). Everything else is your job: matching methods and paths, splitting off the query string, reading and parsing the body, choosing status codes and headers, and sending a 404 when nothing matches.

Express is a function that you hand to that HTTP server. It has the same (req, res) shape as the callback you'd write yourself, but inside it keeps an ordered list of layers — middleware and routes — and walks that list for each request. On top of that it adds routing by method and path with named parameters, helpers on req and res (req.params, req.query, req.get(), res.status(), res.json()), and a central place for errors.

A good mental model is a conveyor belt. Every request is put on the belt at one end. Each station (middleware or route) looks at it and either finishes it (sends the response), passes it on (next()), or throws it onto a separate "rejects" belt that only error handlers stand next to (next(err)).

Here's the plain-Node version first, so you can see what Express replaces:

plain-http.cjs
const http = require('node:http');

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('Hello, World!');
    return;
  }
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end('Not found');
});

server.listen(3000, () => console.log('Listening on 3000'));

// $ curl http://localhost:3000/        -> Hello, World!
// $ curl http://localhost:3000/x       -> Not found
// $ curl 'http://localhost:3000/?a=1'  -> Not found   (!)
What’s happening
  1. http.createServer(callback) creates a server object and registers callback as the listener for its 'request' event. Nothing is listening on a port yet.
  2. server.listen(3000, …) asks the OS to bind port 3000. When that succeeds the server emits 'listening' and the callback logs Listening on 3000.
  3. For each request Node parses the HTTP headers and calls your callback with req (an http.IncomingMessage) and res (an http.ServerResponse). The same function handles every method and every path.
  4. You compare req.method and req.url by hand. req.url is the raw path including the query string, so /?a=1 isn't equal to '/' and falls through to the 404 — the kind of detail Express handles for you.
  5. res.writeHead sets the status and headers; res.end writes the body and finishes the response. Because no Content-Length was set, Node sent the body with Transfer-Encoding: chunked.
AdvancedThe Express version

The Express version of the same thing:

hello.cjs
const express = require('express');

const app = express();

app.get('/', (req, res) => {
  res.send('Hello, World!');
});

const server = app.listen(3000, (err) => {
  if (err) throw err;
  console.log('Server is running on port 3000');
});
AdvancedOutput: the curl -i response
curl -i http://localhost:3000/
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 13
ETag: W/"d-CgqfKmdylCVXq1NV12r0Qvj2XgE"
Date: Wed, 07 Oct 2026 18:50:16 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Hello, World!
What’s happening
  1. express() returns app, which is itself a function with the signature (req, res, next) (typeof app === 'function', app.length === 3). It also carries methods like get, use and listen.
  2. app.get('/', handler) only registers a layer: "for GET requests whose path is /, call this handler". The handler doesn't run at startup — it runs later, once per matching request.
  3. app.listen(3000, …) creates an http.Server with app as its request listener and starts listening (the next topic looks at this in detail).
  4. When GET / arrives, Express strips the query string, walks its layers, finds the match and calls the handler. res.send('Hello, World!') sees a string, so it sets Content-Type: text/html; charset=utf-8, computes Content-Length: 13 (13 bytes), and generates a weak ETag (d is 13 in hex, followed by a hash of the body). X-Powered-By: Express is added by default.
  5. A request for /nope matches nothing. When Express runs out of layers, its final handler sends a 404 with an HTML body Cannot GET /nope and a Content-Security-Policy: default-src 'none' header. You never wrote that code.
AdvancedHardening: hiding the framework header

#What app.listen returns

My notes had a heading "Why express listen method above has no callback, finally checked". The answer is short once you see the source. This is the whole of app.listen in Express 5.2.1 (lib/application.js):

express/lib/application.js (Express 5.2.1)
app.listen = function listen() {
  var server = http.createServer(this)
  var args = slice.call(arguments)
  if (typeof args[args.length - 1] === 'function') {
    var done = args[args.length - 1] = once(args[args.length - 1])
    server.once('error', done)
  }
  return server.listen.apply(server, args)
}
What’s happening
  1. http.createServer(this) — this is the app function, so Express becomes the server's 'request' listener. That's the entire integration between Express and Node's HTTP server.
  2. If your last argument is a function, Express wraps it in once(...) so it can only ever run one time, and registers it twice: as server.once('error', done) and (by passing it on to server.listen) as the 'listening' callback.
  3. server.listen(...) returns the server itself, so app.listen returns the http.Server — not the app, not a promise.
  4. Listening is asynchronous. When app.listen returns, the OS hasn't bound the port yet (server.listening is false). Your callback runs later, either when the port is bound (called with no arguments) or when binding fails (called with the error).
  5. So the callback is optional and it runs once for the server's lifecycle, never per request. Per-request code goes in routes and middleware.
AdvancedPort already in use: EADDRINUSE

Here's what happens when the port is already taken:

listen.cjs
const http = require('node:http');
const express = require('express');

const app = express();
app.get('/', (req, res) => res.send('Hello, World!'));

const first = app.listen(0, '127.0.0.1', () => {
  const { port } = first.address();
  console.log('first is listening on', port);
  console.log('first instanceof http.Server:', first instanceof http.Server);

  // Try to take the same port a second time.
  const second = app.listen(port, '127.0.0.1', (err) => {
    if (err) {
      console.log('second callback got an error:', err.code);
    } else {
      console.log('second is listening');
    }
    first.close();
    second.close();
  });
  console.log('listening yet?', second.listening);
});

// first is listening on 51596
// first instanceof http.Server: true
// listening yet? false
// second callback got an error: EADDRINUSE
What’s happening
  1. Port 0 asks the OS for any free port, so the demo never collides with something real. first.address().port tells you which one it got (51596 on this run).
  2. first instanceof http.Server is true — app.listen really returns Node's server object.
  3. The second app.listen on the same port returns immediately. second.listening is false at that moment: the bind attempt hasn't completed.
  4. The bind fails with EADDRINUSE. The server emits 'error', and because Express registered your callback as a one-time 'error' listener, the callback runs with err.code === 'EADDRINUSE'. The process doesn't crash, because an 'error' listener exists.
  5. Both servers are closed so the process can exit — an open server keeps the event loop alive forever.
AdvancedTwo ways to get listen errors wrong

Now the two ways to get this wrong:

listen2.cjs
const express = require('express');
const app = express();

const first = app.listen(0, '127.0.0.1', () => {
  const { port } = first.address();
  // 1. A callback that ignores its argument
  const second = app.listen(port, '127.0.0.1', () => {
    console.log(`Server is running on port ${port}`); // lies
    console.log('second.listening =', second.listening);
    // 2. No callback at all
    const third = app.listen(port, '127.0.0.1');
    setTimeout(() => first.close(), 100);
  });
});

// Server is running on port 51599
// second.listening = false
// node:events:505
//     throw er; // Unhandled 'error' event
//     ^
// Error: listen EADDRINUSE: address already in use 127.0.0.1:51599
// ...
// Node.js v26.7.0          (exit code 1)
What’s happening
  1. The second server's callback ignores its argument. It's called because of the 'error' event, but it prints Server is running on port 51599 anyway — while second.listening is false. Nothing is listening; the log is simply wrong.
  2. The third app.listen has no callback, so Express registers no 'error' listener. An EventEmitter that emits 'error' with no listener throws, so the process crashes with Unhandled 'error' event and exits with code 1.
  3. Crashing is actually the better of the two outcomes: a process manager or Kubernetes sees the exit and restarts or reports it. The lying callback leaves a process that looks healthy in the logs but serves nothing.
AdvancedCorrecting my notes: the Express 5 listen callback
AdvancedWhy keep the returned server

You keep the returned server because a lot of production code needs it:

  • Graceful shutdown — server.close() stops accepting connections and lets in-flight requests finish (see Graceful shutdown).
  • Timeouts — server.requestTimeout, server.headersTimeout and server.keepAliveTimeout live on the server, not the app.
  • Sharing the port — WebSocket libraries attach to the http.Server (new WebSocketServer({ server })).
  • HTTPS or HTTP/2 — app.listen always makes a plain HTTP/1.1 server. For TLS you skip it and write https.createServer({ key, cert }, app).listen(443).
Is it listening yet?
JavaScript
const server = app.listen(3000);
console.log(server.listening);
Show answer

It prints false.

What’s happening
  1. app.listen creates the server and calls server.listen(3000), which starts binding the port and returns at once.
  2. The bind completes in a later turn of the event loop; only then does the server emit 'listening' and set listening to true.
  3. console.log runs synchronously before that, so it sees false. If you need to know the port is bound (in tests, or before logging "ready"), wait for the callback or the 'listening' event.

#Routes, params, query and body

A route is a method, a path pattern and one or more handlers. Express compares each incoming request against its layers in registration order, and the first matching handler that sends a response wins. The pattern can contain named parameters (:id), and everything the client controls arrives on req:

Where it comes fromExampleExpress gives you
Path segments named in the route/users/:id with /users/42req.params.id === '42'
The query string?sort=name&tag=a&tag=breq.query → { sort: 'name', tag: ['a', 'b'] }
HeadersX-Request-Id: abcreq.get('x-request-id') or req.headers['x-request-id']
The bodyJSON or form datareq.body, only after a body parser has run
routes.cjs
const express = require('express');
const request = require('supertest');

const app = express();
app.use(express.json());

app.get('/users/:id', (req, res) => {
  res.json({ params: req.params, query: req.query, type: typeof req.params.id });
});
app.get('/files/*path', (req, res) => res.json({ path: req.params.path }));
app.get('/posts{/:slug}', (req, res) => res.json({ slug: req.params.slug ?? null }));
app.post('/users', (req, res) => res.status(201).json({ body: req.body, requestId: req.get('x-request-id') }));

(async () => {
  let r = await request(app).get('/users/42?sort=name&tag=a&tag=b&filter[age]=30');
  console.log(r.status, r.body);
  r = await request(app).get('/files/a/b/c.txt');
  console.log(r.status, r.body);
  r = await request(app).get('/posts');
  console.log(r.status, r.body);
  r = await request(app).get('/posts/hello');
  console.log(r.status, r.body);
  r = await request(app).post('/users').set('X-Request-Id', 'abc').send({ name: 'Rohit' });
  console.log(r.status, r.body);
})();

// 200 {
//   params: { id: '42' },
//   query: { sort: 'name', tag: [ 'a', 'b' ], 'filter[age]': '30' },
//   type: 'string'
// }
// 200 { path: [ 'a', 'b', 'c.txt' ] }
// 200 { slug: null }
// 200 { slug: 'hello' }
// 201 { body: { name: 'Rohit' }, requestId: 'abc' }
What’s happening
  1. GET /users/42?… — Express separates the path /users/42 from the query string, and /users/:id captures 42 as req.params.id. It's the string '42', not a number: params are always strings, so req.params.id === 42 is false.
  2. The query string is parsed into req.query. tag appears twice, so it becomes an array. filter[age]=30 stays a flat key named 'filter[age]', because Express 5's default query parser is 'simple' (Node's querystring), which doesn't build nested objects.
  3. /files/*path is Express 5's named wildcard: it matches one or more segments and gives them to you as an array, ['a', 'b', 'c.txt']. It does not match /files on its own; /files{/*path} would.
  4. /posts{/:slug} — braces mark an optional part. /posts matches with no slug (so null after ??), and /posts/hello matches with slug: 'hello'.
  5. POST /users — express.json() ran first and turned the JSON body into req.body. req.get('x-request-id') reads a header case-insensitively (Node lower-cases header names in req.headers).
AdvancedExpress 5 path syntax

Express 5 uses path-to-regexp v8, which removed the regex-like features of string paths. Old patterns throw at registration time, so you find out at startup, not on the first request:

oldpaths.cjs
const express = require('express');
for (const p of ['/files/*', '/users/:id?', '/ab+cd', '/(.*)']) {
  try {
    express().get(p, () => {});
    console.log('ok', p);
  } catch (e) {
    console.log(p, '->', e.constructor.name + ':', e.message);
  }
}

// /files/* -> PathError: Missing parameter name at index 8: /files/*; visit https://git.new/pathToRegexpError for info
// /users/:id? -> PathError: Unexpected ? at index 10: /users/:id?; visit https://git.new/pathToRegexpError for info
// /ab+cd -> PathError: Unexpected + at index 3: /ab+cd; visit https://git.new/pathToRegexpError for info
// /(.*) -> PathError: Unexpected ( at index 1: /(.*); visit https://git.new/pathToRegexpError for info
What’s happening
  1. /files/* — a bare * is no longer allowed; every wildcard needs a name. Write /files/*splat (or any name), and read it from req.params.splat.
  2. /users/:id? — the ? optional modifier is gone. Write /users{/:id} instead.
  3. /ab+cd and /(.*) — +, ( and ) are reserved characters now, not regex operators. If you really need a regular expression, pass a RegExp object as the path.
  4. Each one throws a PathError when app.get is called, which is why upgrading from Express 4 tends to fail loudly at boot rather than silently at runtime.
AdvancedRouters with express.Router()

Routers

express.Router() is a mini-app with its own middleware and routes. You mount it under a prefix with app.use, and inside it paths are relative to that prefix:

router.cjs
const express = require('express');
const request = require('supertest');

const users = express.Router();

users.get('/', (req, res) => res.json({ route: 'list', baseUrl: req.baseUrl, path: req.path }));
users.get('/me', (req, res) => res.json({ route: 'me' }));
users.get('/:id', (req, res) => res.json({ route: 'one', id: req.params.id, baseUrl: req.baseUrl, path: req.path, originalUrl: req.originalUrl }));

const app = express();
app.use('/users', users);

(async () => {
  for (const p of ['/users', '/users/me', '/users/42?x=1']) {
    const r = await request(app).get(p);
    console.log(p.padEnd(14), JSON.stringify(r.body));
  }
})();

// /users         {"route":"list","baseUrl":"/users","path":"/"}
// /users/me      {"route":"me"}
// /users/42?x=1  {"route":"one","id":"42","baseUrl":"/users","path":"/42","originalUrl":"/users/42?x=1"}
What’s happening
  1. app.use('/users', users) mounts the router. For any request whose path starts with /users, Express strips that prefix and hands the request to the router.
  2. Inside the router, req.baseUrl is the mount point (/users), req.path is the remainder (/ or /42), and req.originalUrl is the untouched URL including the query string — the one to log.
  3. /users/me matches users.get('/me') because it was registered before /:id. Routes are tried in order.
  4. If you changed the order and registered /:id first, GET /users/me would return {"route":"one","id":"me"} — :id happily matches the string me. Put specific paths before parameterised ones.

#Middleware and the request pipeline

From my notes: middleware is a function that runs between the request arriving and the response being sent — logging, parsing, authentication, authorisation, rate limiting, CORS, compression. Its signature is (req, res, next) and it must do exactly one of three things:

  1. Respond — res.send(...), res.json(...), res.end(). The request is finished.
  2. Pass it on — next(). Express moves to the next matching layer.
  3. Fail — next(err), or throw, or (Express 5) return a rejected promise. Express skips to the next error handler.

If it does none of these, the request hangs until the client or a proxy gives up. And next() is a function call, not a return: the rest of your function still runs after it, and everything downstream runs inside that call. Picture an onion — each middleware wraps everything registered after it.

Middleware can be global (app.use(fn)), mounted on a path (app.use('/api', fn)) or attached to a single route, and you can chain several on one route (app.get('/admin', authenticate, requireAdmin, handler)).

middleware.cjs
const express = require('express');
const request = require('supertest');

const app = express();

app.use((req, res, next) => {
  console.log('1 logger:', req.method, req.url);
  next();
  console.log('1 logger: after next()');
});

function authenticate(req, res, next) {
  console.log('2 authenticate');
  if (req.get('authorization') !== 'Bearer good') {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  req.user = { id: 7, role: 'admin' };
  next();
}

function requireAdmin(req, res, next) {
  console.log('3 requireAdmin');
  if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
  next();
}

app.get('/public', (req, res) => {
  console.log('route /public');
  res.send('anyone');
});

app.get('/admin', authenticate, requireAdmin, (req, res) => {
  console.log('4 route /admin');
  res.json({ hello: req.user.id });
});

(async () => {
  for (const [path, auth] of [['/public'], ['/admin'], ['/admin', 'Bearer good']]) {
    const t = request(app).get(path);
    if (auth) t.set('Authorization', auth);
    const r = await t;
    console.log('=>', r.status, r.text, '\n');
  }
})();

// 1 logger: GET /public
// route /public
// 1 logger: after next()
// => 200 anyone
//
// 1 logger: GET /admin
// 2 authenticate
// 1 logger: after next()
// => 401 {"error":"Not authenticated"}
//
// 1 logger: GET /admin
// 2 authenticate
// 3 requireAdmin
// 4 route /admin
// 1 logger: after next()
// => 200 {"hello":7}
What’s happening
  1. GET /public: the global logger runs first because it was registered first. It calls next(), and inside that call Express runs the /public route, which sends anyone. Only when the route function returns does next() return, so 1 logger: after next() prints after route /public.
  2. GET /admin with no token: logger → next() → authenticate. The header doesn't match, so it sends 401 and returns without calling next(). requireAdmin and the route never run. Control unwinds back into the logger, which prints its "after" line.
  3. GET /admin with Bearer good: authenticate attaches req.user = { id: 7, role: 'admin' } and calls next(). Attaching data to req is how middleware passes results down the chain — the same req object travels through every layer.
  4. requireAdmin reads req.user.role, calls next(), and the route handler sends {"hello":7}. All four functions are on the call stack at that moment, nested inside each other's next() calls.
  5. The return in return res.status(401)… matters: without it, authenticate would carry on to set req.user and call next(), and the route would try to send a second response (see Sending responses).
AdvancedClassic mistakes: missing next(), late guard

Two classic mistakes — forgetting next(), and registering a guard too late:

hang.cjs
const express = require('express');
const request = require('supertest');

const app = express();

app.use('/stuck', (req, res, next) => {
  console.log('middleware ran, but forgot next()');
});

app.get('/open', (req, res) => res.send('open data'));
app.use((req, res, next) => {          // registered AFTER /open
  console.log('auth check for', req.path);
  if (!req.get('authorization')) return res.status(401).send('login first');
  next();
});
app.get('/secret', (req, res) => res.send('secret data'));

(async () => {
  try {
    await request(app).get('/stuck').timeout(500);
  } catch (err) {
    console.log('/stuck ->', err.code, err.message);
  }
  let r = await request(app).get('/open');
  console.log('/open ->', r.status, r.text);
  r = await request(app).get('/secret');
  console.log('/secret ->', r.status, r.text);
})();

// middleware ran, but forgot next()
// /stuck -> ECONNABORTED Timeout of 500ms exceeded
// /open -> 200 open data
// auth check for /secret
// /secret -> 401 login first
What’s happening
  1. GET /stuck reaches the middleware, which logs and then simply returns. It didn't respond and didn't call next(), so Express has nothing more to do: the socket stays open and the client waits. supertest gives up after the 500 ms timeout with ECONNABORTED. In production this shows up as requests that hang until a load balancer's timeout (often 60 s) — and each one holds a socket.
  2. GET /open matches the /open route, which was registered before the auth middleware. It responds, so the auth middleware never sees this request: open data with a 200.
  3. GET /secret passes /open (no match), reaches the auth middleware, has no Authorization header, and gets 401.
  4. The lesson: Express doesn't know which middleware is "security". Order of registration is the order of execution, so guards must be registered before the routes they protect.
AdvancedTiming middleware with async handlers

Because downstream code runs inside next(), the "after" half of a middleware only sees a finished response when everything downstream is synchronous. With an async handler, next() returns as soon as the handler hits its first await. To measure the real duration, listen for the response's 'finish' event:

timing.cjs
const express = require('express');
const request = require('supertest');
const { setTimeout: sleep } = require('node:timers/promises');

const app = express();

app.use((req, res, next) => {
  const start = performance.now();
  res.on('finish', () => {
    const ms = (performance.now() - start).toFixed(0);
    console.log(`finish: ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
  });
  next();
  console.log('after next(): handler has not responded yet');
});

app.get('/slow', async (req, res) => {
  await sleep(100);                 // pretend to query a database
  res.json({ ok: true });
});

request(app).get('/slow').then((r) => console.log('client got', r.status));

// after next(): handler has not responded yet
// finish: GET /slow 200 104ms       (roughly 100 ms; varies by a few ms)
// client got 200
What’s happening
  1. The logger records start, subscribes to res's 'finish' event, and calls next().
  2. Express calls the async route handler. It runs until await sleep(100), then returns a pending promise. Express keeps that promise (to catch a rejection) and next() returns.
  3. So after next() prints immediately — the response hasn't been sent. A logger that measured time here would report about 0 ms for every async route.
  4. 100 ms later the timer fires, the handler resumes and calls res.json. When the last byte is handed to the OS, res emits 'finish' and the real duration (about 100 ms) is logged. Request loggers like morgan and pino-http use this same event.
AdvancedSkipping handlers: next('route') and next('router')

#Parsing request bodies

A request body isn't on req when your handler starts. It's still arriving: req is a readable stream and the body is bytes that may come in several TCP chunks. A body parser is middleware that reads that stream to the end, decodes it, and stores the result on req.body. Until one runs, there is no req.body.

From my notes, the two you use most:

  • express.json() — for Content-Type: application/json. Reads up to limit bytes (default '100kb'), then JSON.parse.
  • express.urlencoded({ extended: true }) — for HTML form posts (application/x-www-form-urlencoded, e.g. name=Rohit&age=30). extended: true uses the qs library, which supports nested keys and arrays; false uses Node's querystring.

Each parser only acts when the request's Content-Type matches; otherwise it calls next() and leaves the body alone.

body.cjs
const express = require('express');
const request = require('supertest');

const app = express();
app.post('/raw', (req, res) => res.json({ body: req.body === undefined ? 'undefined' : req.body }));

app.use(express.json({ limit: '100b' }));
app.use(express.urlencoded({ extended: true }));

app.post('/submit', (req, res) => {
  res.json({ body: req.body === undefined ? 'undefined' : req.body });
});
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ status: err.status, type: err.type, message: err.message });
});

(async () => {
  const show = (label, r) => console.log(label.padEnd(20), r.status, JSON.stringify(r.body));
  show('no parser yet', await request(app).post('/raw').send({ a: 1 }));
  show('json', await request(app).post('/submit').send({ name: 'Rohit', age: 30 }));
  show('form', await request(app).post('/submit').type('form').send('name=Rohit&age=30&tags[]=a&tags[]=b'));
  show('wrong content-type', await request(app).post('/submit').set('Content-Type', 'text/plain').send('{"name":"Rohit"}'));
  show('bad json', await request(app).post('/submit').set('Content-Type', 'application/json').send('{"name":'));
  show('too big', await request(app).post('/submit').send({ blob: 'x'.repeat(200) }));
  show('no body at all', await request(app).post('/submit'));
})();

// no parser yet        200 {"body":"undefined"}
// json                 200 {"body":{"name":"Rohit","age":30}}
// form                 200 {"body":{"name":"Rohit","age":"30","tags":["a","b"]}}
// wrong content-type   200 {"body":"undefined"}
// bad json             400 {"status":400,"type":"entity.parse.failed","message":"Unexpected end of JSON input"}
// too big              413 {"status":413,"type":"entity.too.large","message":"request entity too large"}
// no body at all       200 {"body":"undefined"}
What’s happening
  1. /raw is registered before the parsers, so even a perfectly good JSON body leaves req.body undefined. Parsers only help routes registered after them.
  2. json: supertest sends Content-Type: application/json. express.json() reads the stream, parses it, and req.body is { name: 'Rohit', age: 30 } — age is a real number because JSON has numbers.
  3. form: express.json() sees a non-JSON type and calls next(); express.urlencoded takes it. Form values are always strings (age: '30'), and qs turns tags[]=a&tags[]=b into an array.
  4. wrong content-type: the body is valid JSON text, but the header says text/plain, so neither parser touches it and req.body stays undefined. Parsers trust the header, not the bytes.
  5. bad json: JSON.parse throws, and the parser calls next(err) with an error whose status is 400 and type is 'entity.parse.failed'. That's why the error handler — not the route — answered. Without your own handler, Express's default one would still send a 400.
  6. too big: 200 characters of x exceed the deliberately tiny limit: '100b', so the parser stops reading and fails with 413 and 'entity.too.large'. The limit is a cheap defence against clients that send huge bodies to exhaust memory.
  7. no body at all: nothing to parse, so req.body is undefined rather than {}. In Express 5, req.body only exists after a parser actually parsed something.
AdvancedPitfall: req.body is undefined
AdvancedOther body parsers and file uploads

Other parsers:

  • express.text() and express.raw() give you a string or a Buffer — express.raw({ type: 'application/json' }) is what you want for webhook signature checks, because you must verify the signature over the exact bytes, before any re-serialisation.
  • Multipart (multipart/form-data, i.e. file uploads) isn't handled by Express at all. Use multer or busboy, which stream the file parts to disk or storage instead of buffering them in memory (see What a stream is).

#Sending responses

Every request gets exactly one response, and a response is written in a fixed order: the status line and headers first, then the body. Once the headers have gone out to the socket they can't be changed, which is what the famous "Cannot set headers after they are sent" error is about.

My notes asked about res.send vs res.json. The short answer is that res.send picks a format based on the type of value you give it, while res.json always serialises as JSON. In fact res.send(object) just calls res.json(object). Here's every case, run against Express 5.2.1:

send.cjs
const express = require('express');
const request = require('supertest');

const app = express();
app.get('/text', (req, res) => res.send('Hello'));
app.get('/obj-send', (req, res) => res.send({ ok: true }));
app.get('/obj-json', (req, res) => res.json({ ok: true }));
app.get('/str-json', (req, res) => res.json('Hello'));
app.get('/null-json', (req, res) => res.json(null));
app.get('/num-send', (req, res) => res.send(42));
app.get('/buf', (req, res) => res.send(Buffer.from('bytes')));
app.get('/status', (req, res) => res.sendStatus(204));
app.get('/created', (req, res) => res.status(201).location('/users/7').json({ id: 7 }));

(async () => {
  for (const p of ['/text', '/obj-send', '/obj-json', '/str-json', '/null-json', '/num-send', '/buf', '/status', '/created']) {
    const r = await request(app).get(p);
    console.log(p.padEnd(10), r.status, String(r.headers['content-type']).padEnd(32), JSON.stringify(r.text), r.headers.location ?? '');
  }
})();

// /text      200 text/html; charset=utf-8         "Hello"
// /obj-send  200 application/json; charset=utf-8  "{\"ok\":true}"
// /obj-json  200 application/json; charset=utf-8  "{\"ok\":true}"
// /str-json  200 application/json; charset=utf-8  "\"Hello\""
// /null-json 200 application/json; charset=utf-8  "null"
// /num-send  200 application/json; charset=utf-8  "42"
// /buf       200 application/octet-stream         undefined
// /status    204 undefined                        ""
// /created   201 application/json; charset=utf-8  "{\"id\":7}" /users/7
What’s happening
  1. res.send('Hello') — a string with no Content-Type set yet defaults to text/html. That default is why sending user-supplied strings with res.send can be an XSS risk (see Cross-site scripting (XSS)).
  2. res.send({ ok: true }) and res.json({ ok: true }) produce identical responses: inside res.send, objects, numbers and booleans are handed to res.json.
  3. res.json('Hello') and res.json(null) show the real difference: res.json always runs JSON.stringify, so a string becomes the JSON string "Hello" and null becomes null. (res.send(null) would send an empty body instead.) res.json also honours the json spaces and json replacer app settings.
  4. res.send(42) sends the JSON body 42 with status 200. In Express 4 a number meant "status code" (deprecated); Express 5 removed that, along with the two-argument forms. Old code like res.json(404, { x: 1 }) now quietly sends a 200 whose body is 404 — the second argument is ignored. Always use res.status(404).json(...).
  5. A Buffer is sent as raw bytes with application/octet-stream (supertest doesn't decode it as text, hence undefined). res.sendStatus(204) sets the status and, for 204, strips the body and content headers.
  6. res.status(201) and res.location(...) set things and return res, which is what makes chaining work. .json(...) at the end of the chain is the call that actually sends. From my notes, the usual API shape is return res.status(200).json({ success: true, data: users }).
AdvancedSending twice: ERR_HTTP_HEADERS_SENT

res.json() does not stop your function. If you forget the return, execution continues into the next send:

headers-sent.cjs
const express = require('express');
const request = require('supertest');

const app = express();

app.get('/users/:id', (req, res) => {
  if (req.params.id !== '7') {
    res.status(404).json({ error: 'User not found' }); // no return!
  }
  res.json({ id: 7, name: 'Rohit' });
});

app.use((err, req, res, next) => {
  console.log('error handler:', err.code, '|', err.message, '| headersSent =', res.headersSent);
  next(err); // let Express close the connection
});

(async () => {
  const r = await request(app).get('/users/99');
  console.log('client got:', r.status, r.text);
})();

// error handler: ERR_HTTP_HEADERS_SENT | Cannot set headers after they are sent to the client | headersSent = true
// client got: 404 {"error":"User not found"}
// Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
//     at ServerResponse.setHeader …   (stderr, from Express's default handler)
What’s happening
  1. GET /users/99 — the if is true, so res.status(404).json(...) writes the headers and the body and ends the response. res.headersSent is now true.
  2. There's no return, so the function carries on to res.json({ id: 7, … }). That tries to set Content-Type again, and Node's setHeader throws ERR_HTTP_HEADERS_SENT.
  3. Express catches the synchronous throw and forwards it to the error handler. The handler sees headersSent === true, so it can't send a 500 — the client already has a complete 404. It calls next(err), and Express's default handler just logs the error and closes the connection.
  4. The client is fine (it got the 404); the bug only shows in your logs. If the second send happened inside a callback outside Express's reach (a setTimeout, an event listener), the throw would be an uncaught exception and crash the process.
  5. The fix is one word: return res.status(404).json(...). Treat every send as the last line of that code path.

#Error-handling middleware

An error handler is middleware with four parameters: (err, req, res, next). Express tells it apart from normal middleware purely by the function's declared parameter count (fn.length === 4). Once an error enters the chain, Express skips every ordinary middleware and route and jumps to the next error handler — the "rejects belt" from the first topic.

From my notes: an error handler is defined at the end of the stack, and next(error) sends the error to it. An error gets onto that belt in four ways in Express 5:

  • calling next(err);
  • throwing synchronously in any handler or middleware (Express wraps each call in try/catch);
  • returning a rejected promise — i.e. an async handler that throws or awaits something that rejects. Express 5 attaches a rejection handler to whatever your function returns;
  • a built-in middleware failing (the 400 and 413 from the body parser above).
errors.cjs
const express = require('express');
const request = require('supertest');

const app = express();

app.get('/sync', (req, res) => {
  throw new Error('sync boom');
});

app.get('/next', (req, res, next) => {
  next(new Error('passed to next'));
});

app.get('/async', async (req, res) => {
  await Promise.resolve();
  throw new Error('async boom');           // the returned promise rejects
});

app.get('/missing', async (req, res) => {
  const err = new Error('User 99 not found');
  err.status = 404;
  throw err;
});

app.use((req, res, next) => {
  console.log('  (ordinary middleware: only runs for unmatched routes)');
  next();
});

app.use((err, req, res, next) => {
  console.log('  error handler got:', err.message);
  if (res.headersSent) return next(err);
  const status = err.status || 500;
  res.status(status).json({ error: status === 500 ? 'Internal server error' : err.message });
});

(async () => {
  for (const p of ['/sync', '/next', '/async', '/missing']) {
    console.log('GET', p);
    const r = await request(app).get(p);
    console.log('  =>', r.status, r.text);
  }
})();

// GET /sync
//   error handler got: sync boom
//   => 500 {"error":"Internal server error"}
// GET /next
//   error handler got: passed to next
//   => 500 {"error":"Internal server error"}
// GET /async
//   error handler got: async boom
//   => 500 {"error":"Internal server error"}
// GET /missing
//   error handler got: User 99 not found
//   => 404 {"error":"User 99 not found"}
What’s happening
  1. /sync throws during the handler call. Express's try/catch around the call catches it and starts looking for an error handler. The ordinary middleware registered between the routes and the error handler is skipped — its log line never prints in any of the four runs.
  2. /next hands the error over explicitly. Same result. This is the form you need inside callback-style code.
  3. /async throws after an await, so the throw happens in a later microtask, long after Express's try/catch has finished. It still works because the async function returns a promise, and Express 5 calls .then(undefined, next) on it — the rejection becomes next(err).
  4. /missing sets err.status = 404. The handler uses it for the status code and, because it's a client error, sends the message. For 500s it sends a generic message and keeps the details for the logs, so stack traces and SQL never reach the client.
  5. if (res.headersSent) return next(err) — if the error happened after the response started streaming, you can't send a new status. Delegating to Express's default handler makes it close the connection, so the client sees a broken response instead of a truncated one that looks complete.
AdvancedOlder behavior: async errors in Express 4
AdvancedExpress's default error handler

If no error handler of yours responds, Express's built-in one (from the finalhandler package) does. It uses err.status or err.statusCode if it's a 4xx/5xx, and what it reveals depends on NODE_ENV:

default-handler.cjs
const express = require('express');
const request = require('supertest');

const app = express();
app.get('/boom', (req, res) => {
  const err = new Error('db password is hunter2');
  err.status = 503;
  throw err;
});

request(app).get('/boom').then((r) => {
  console.log(r.status, r.headers['content-type']);
  console.log(r.text);
});

// $ node default-handler.cjs
// 503 text/html; charset=utf-8
// … <pre>Error: db password is hunter2<br> &nbsp; &nbsp;at /…/default-handler.cjs:6:15<br> … (full stack trace)</pre> …
//
// $ NODE_ENV=production node default-handler.cjs
// 503 text/html; charset=utf-8
// … <pre>Service Unavailable</pre> …
What’s happening
  1. The thrown error has status = 503, and there's no custom handler, so finalhandler responds with status 503 and an HTML page.
  2. Without NODE_ENV=production, the page contains the full stack trace and the error message — file paths, line numbers and, here, the "password". Fine on your laptop, a data leak in production.
  3. With NODE_ENV=production, the body is just the standard status text, Service Unavailable. In both modes the stack is also printed to stderr (Express skips that only when NODE_ENV=test).
  4. This is one reason production containers set NODE_ENV=production — and the stronger reason to always register your own final error handler that returns a consistent JSON shape.
Three parameters
JavaScript
app.get('/boom', () => { throw new Error('boom'); });
app.use((err, req, res) => {
  res.status(500).send('handled');
});

What does GET /boom return?

Show answer

Not handled: you get Express's default HTML error page with a 500.

What’s happening
  1. The "error handler" declares only three parameters, so fn.length is 3 and Express registers it as ordinary middleware, treating the parameters as (req, res, next).
  2. The route throws, and Express looks for the next layer with arity 4. This function isn't one, so it's skipped like any other ordinary middleware.
  3. No error handler is found, so finalhandler sends the default 500 page. Keep all four parameters even when you don't use next — linters that flag unused parameters need an exception for this.

#err.stack and err.cause

err.message says what went wrong. err.stack says where the error object was created: it's a string captured by V8 when new Error(...) runs, starting with Error: <message> and followed by up to 10 frames (Error.stackTraceLimit). Note created, not thrown — if you build an error in one place and throw it somewhere else, the stack points at the construction site.

err.cause (ES2022, Node 16.9+) solves a different problem. When a low-level error bubbles up through layers, each layer wants to add context — "unable to load profile for user 7" — without throwing away the original "connect ETIMEDOUT 10.0.0.5:5432". Before cause you either lost the original or glued strings together. Now you wrap: new Error('higher-level message', { cause: originalError }). (The basics are on the JS site: Error cause.)

From my notes, the real example where cause helps is exactly this one: a route that fails because a database call failed.

cause.cjs
const express = require('express');
const request = require('supertest');

// data layer: the low-level failure
async function queryProfileRow(userId) {
  const err = new Error('connect ETIMEDOUT 10.0.0.5:5432');
  err.code = 'ETIMEDOUT';
  throw err;
}

// service layer: adds context, keeps the original
async function loadProfile(userId) {
  try {
    return await queryProfileRow(userId);
  } catch (err) {
    throw new Error(`Unable to load profile for user ${userId}`, { cause: err });
  }
}

const app = express();
app.get('/profile/:id', async (req, res) => {
  res.json(await loadProfile(req.params.id));
});

app.use((err, req, res, next) => {
  // walk the cause chain for the log
  let depth = 0;
  for (let e = err; e; e = e.cause) {
    console.log(`${'  '.repeat(depth++)}${e.message}${e.code ? ` [${e.code}]` : ''}`);
  }
  console.log('top stack, first two lines:\n' + err.stack.split('\n').slice(0, 2).join('\n'));
  res.status(500).json({ error: 'Internal server error' });
});

request(app).get('/profile/7').then((r) => console.log('client sees:', r.status, r.text));

// Unable to load profile for user 7
//   connect ETIMEDOUT 10.0.0.5:5432 [ETIMEDOUT]
// top stack, first two lines:
// Error: Unable to load profile for user 7
//     at loadProfile (/…/cause.cjs:16:11)
// client sees: 500 {"error":"Internal server error"}
What’s happening
  1. queryProfileRow throws an error with a low-level message and a machine-readable code — the kind of error a database driver gives you. On its own it doesn't say which feature or user was affected.
  2. loadProfile catches it and throws a new error with a message that makes sense at the service level, passing the original as { cause: err }. The original is now newErr.cause, untouched, with its own stack and code.
  3. The async route awaits loadProfile, its promise rejects, and Express 5 forwards the error to the error handler — no try/catch in the route.
  4. The handler walks e = e.cause until it runs out, printing each level indented: first the high-level context, then the root cause with [ETIMEDOUT]. That's the log line an on-call engineer actually needs.
  5. err.stack of the top error starts at loadProfile line 16, where new Error ran — not at the route and not at the database. Each error in the chain has its own stack.
  6. The client gets a generic 500. The chain is for your logs; never send stack, cause or internal messages to the client.
AdvancedLogging an error chain with cause

How the chain looks when you log it:

inspect-cause.cjs
const low = new Error('connect ETIMEDOUT 10.0.0.5:5432');
low.code = 'ETIMEDOUT';
const high = new Error('Unable to load profile for user 7', { cause: low });

console.log(high);
console.log(JSON.stringify(high), Object.keys(high), high.propertyIsEnumerable('cause'));

// Error: Unable to load profile for user 7
//     at Object.<anonymous> (/…/inspect-cause.cjs:3:14)
//     ... 5 lines matching cause stack trace ...
//     at node:internal/main/run_main_module:33:47 {
//   [cause]: Error: connect ETIMEDOUT 10.0.0.5:5432
//       at Object.<anonymous> (/…/inspect-cause.cjs:1:13)
//       ...
//     code: 'ETIMEDOUT'
//   }
// }
// {} [] false
What’s happening
  1. console.log(high) uses util.inspect, which knows about cause: it prints the top error's stack, then [cause]: with the nested error's stack and its extra properties (code: 'ETIMEDOUT'). It even collapses the frames the two stacks share ("5 lines matching cause stack trace").
  2. JSON.stringify(high) is {}. message, stack and cause are own but non-enumerable properties, and JSON.stringify only serialises enumerable ones. Object.keys(high) is [] for the same reason.
  3. That matters for structured logging: logger.info({ err }) with a naive JSON logger records "err": {}. Use your logger's error serializer (pino and winston have them) or build the object yourself: { message: e.message, stack: e.stack, code: e.code, cause: … }.

#Writing a rate-limiter middleware

My notes had the heading "Creating rate limiter middleware in express nodejs" with no content, so here it is from scratch. A rate limiter counts requests per client and rejects the excess with 429 Too Many Requests, ideally with a Retry-After header saying how many seconds to wait. It belongs early in the chain, before expensive work like body parsing, database calls or password hashing.

The simplest algorithm is a fixed window: each client gets a counter and a reset time. The first request starts a window of windowMs; every request in that window increments the counter; once the counter passes the limit, requests are rejected until the window ends and a fresh one starts. Other algorithms are compared in Rate-limiting algorithms.

rate-limit.cjs
const express = require('express');
const request = require('supertest');

function rateLimit({ windowMs, limit, now = Date.now }) {
  const hits = new Map(); // key -> { count, resetAt }

  // Drop finished windows so the Map can't grow forever.
  const sweeper = setInterval(() => {
    const t = now();
    for (const [key, entry] of hits) if (entry.resetAt <= t) hits.delete(key);
  }, windowMs);
  sweeper.unref(); // don't keep the process alive just for this

  return function rateLimiter(req, res, next) {
    const key = req.ip;
    const t = now();
    let entry = hits.get(key);

    if (!entry || entry.resetAt <= t) {
      entry = { count: 0, resetAt: t + windowMs }; // start a new window
      hits.set(key, entry);
    }
    entry.count++;

    const remaining = Math.max(0, limit - entry.count);
    const resetSeconds = Math.ceil((entry.resetAt - t) / 1000);
    res.set('RateLimit-Limit', String(limit));
    res.set('RateLimit-Remaining', String(remaining));
    res.set('RateLimit-Reset', String(resetSeconds));

    if (entry.count > limit) {
      res.set('Retry-After', String(resetSeconds));
      return res.status(429).json({ error: 'Too many requests' });
    }
    next();
  };
}

// A fake clock so the demo doesn't have to wait a minute.
let fakeNow = 1_000_000;
const app = express();
app.use('/api', rateLimit({ windowMs: 60_000, limit: 3, now: () => fakeNow }));
let handled = 0;
app.get('/api/products', (req, res) => {
  handled++;
  res.json([{ id: 42, name: 'Keyboard' }]);
});

(async () => {
  const hit = async (label) => {
    const r = await request(app).get('/api/products');
    console.log(label.padEnd(8), r.status, 'remaining', r.headers['ratelimit-remaining'],
      'reset', r.headers['ratelimit-reset'], 'retry-after', r.headers['retry-after'] ?? '-', 'handled', handled);
  };
  await hit('t=0s');
  await hit('t=0s');
  fakeNow += 20_000;
  await hit('t=20s');
  await hit('t=20s');
  fakeNow += 41_000;
  await hit('t=61s');
})();

// t=0s     200 remaining 2 reset 60 retry-after - handled 1
// t=0s     200 remaining 1 reset 60 retry-after - handled 2
// t=20s    200 remaining 0 reset 40 retry-after - handled 3
// t=20s    429 remaining 0 reset 40 retry-after 40 handled 3
// t=61s    200 remaining 2 reset 60 retry-after - handled 4
What’s happening
  1. rateLimit({...}) is a factory: it runs once at startup, creates the hits Map in a closure, and returns the actual middleware. Every request shares that one Map. The key is req.ip — 127.0.0.1 under supertest.
  2. Request 1 at t=0: no entry, so a window starts with resetAt = t + 60 000 and count goes 0 → 1. Remaining is 3 - 1 = 2, reset is 60 s, and next() lets it reach the route (handled 1). Request 2: count 2, remaining 1.
  3. The fake clock jumps 20 s. Request 3: same window (resetAt is still 40 s away), count 3, remaining 0, reset 40 — still allowed because 3 > 3 is false.
  4. Request 4: count 4 is over the limit. The middleware sets Retry-After: 40 and returns a 429 without calling next(), so the route never runs — handled stays at 3. That's the point: rejected requests cost almost nothing.
  5. The clock moves to t=61 s, past resetAt. The old entry is replaced by a fresh window, count is 1 again and the request is allowed (handled 4).
  6. The setInterval sweeper deletes expired entries, otherwise every IP that ever visited would stay in the Map forever — a slow memory leak (see Common causes of memory leaks). unref() stops that timer from keeping the process alive on shutdown.
AdvancedLimits of an in-memory rate limiter

Why you'd normally use express-rate-limit with a shared store instead of this (see Rate limiting):

  • The counter lives in one process. With 4 cluster workers or 10 pods behind a load balancer, each has its own Map, so a client gets up to 4× or 10× the limit, and a deploy or restart resets every counter.
  • The key must be the real client. Behind a proxy or load balancer, req.ip is the proxy's address unless trust proxy is configured correctly — then every user shares one counter. Set it too loosely and clients can forge X-Forwarded-For (see HTTPS, proxies and trust proxy).
  • Fixed windows allow bursts at the boundary: 3 requests at second 59 plus 3 at second 61 is 6 in two seconds.
  • The check-and-increment must be atomic once the state is in Redis — a GET then SET from two servers loses updates.

#Testing an Express app with supertest

Added

You don't need to start a server on a real port to test an Express app. supertest takes your app, wraps it in http.createServer, listens on an ephemeral port on 127.0.0.1, sends a real HTTP request, and closes the server when the request finishes. Your middleware, routing, body parsing and error handlers all run exactly as in production, but there are no fixed ports to clash and no servers left running. Every Express example in this chapter was run this way.

The one structural change it asks for: export the app from one module and call listen in another. Tests import the app; only the entry point opens a port.

app.js
const express = require('express');

const users = new Map([['7', { id: 7, name: 'Rohit' }]]);

const app = express();
app.use(express.json());

app.get('/users/:id', (req, res) => {
  const user = users.get(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

app.post('/users', (req, res) => {
  if (typeof req.body?.name !== 'string') {
    return res.status(400).json({ error: 'name is required' });
  }
  const user = { id: users.size + 7, name: req.body.name };
  users.set(String(user.id), user);
  res.status(201).location(`/users/${user.id}`).json(user);
});

module.exports = app;
Advancedserver.js: listening outside the app
server.js
const app = require('./app');
const port = Number(process.env.PORT) || 3000;
app.listen(port, (err) => {
  if (err) throw err;
  console.log(`Listening on ${port}`);
});
Advancedapp.test.js: supertest against the app
app.test.js
const request = require('supertest');
const app = require('./app');

describe('users API', () => {
  test('GET /users/7 returns the user', async () => {
    const res = await request(app)
      .get('/users/7')
      .expect('Content-Type', /json/)
      .expect(200);

    expect(res.body).toEqual({ id: 7, name: 'Rohit' });
  });

  test('GET /users/99 is a 404', async () => {
    const res = await request(app).get('/users/99');
    expect(res.status).toBe(404);
    expect(res.body.error).toBe('User not found');
  });

  test('POST /users validates and creates', async () => {
    await request(app).post('/users').send({}).expect(400);

    const res = await request(app)
      .post('/users')
      .send({ name: 'Neha' })
      .expect(201);

    expect(res.headers.location).toBe(`/users/${res.body.id}`);
    expect(res.body.name).toBe('Neha');
  });
});
AdvancedOutput: the Jest run
npx jest (Jest 30.5, supertest 7.3)
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.156 s
Ran all test suites.
What’s happening
  1. require('./app') loads the app without listening, because only server.js calls listen. If app.js called listen itself, every test file that imported it would try to bind port 3000, and Jest would hang at the end with an open handle.
  2. request(app).get('/users/7') builds a request. Nothing is sent until it's awaited (or .end() is called): then supertest creates an http.Server around app, listens on 127.0.0.1 port 0 (the OS picks a free one), and sends a real HTTP request to it.
  3. .expect('Content-Type', /json/) and .expect(200) are supertest assertions on the response; if one fails, the awaited promise rejects and the test fails with a message showing expected vs actual. After the response, supertest closes its server.
  4. res.body is the parsed JSON, so toEqual compares objects. res.status, res.headers (lower-cased names) and res.text (the raw body) are there for anything else.
  5. The POST test sends {} first and expects 400 — testing the validation branch — and then a valid body, checking the 201 and the Location header the route set.
  6. If you break the second test to expect 400, Jest prints Expected: 400 / Received: 404 with the failing line, and the summary becomes 1 failed, 2 passed.
AdvancedPersisting cookies with request.agent
AdvancedRelated topics: endpoint tests and mocking

More on test structure, mocking dependencies and coverage is in Testing HTTP endpoints and Mocking modules and dependencies.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.