Node, NestJS & API design
Your resume claims six domain services and ownership of the API standards. That claim raises the bar in this round rather than lowering it — they will not ask what a controller is, they will ask why you did not put a queue behind it.
Your JavaScript runs on one thread, but I/O does not. Network and file operations are handed to the operating system (via epoll/kqueue) or to libuv's thread pool, and your callback is queued when they complete. So thousands of connections can be in flight while the single thread sits idle waiting.
The corollary is the actual point, and it is what they are listening for: CPU work blocks everything. A synchronous parse of a 50MB JSON payload, a bcrypt round, an image resize, a big JSON.stringify — each one stops every other request on that process for its whole duration.
// this stalls every concurrent request for ~200ms
app.get('/hash', (req, res) => {
const h = bcrypt.hashSync(req.body.pw, 12) // sync = blocking
res.json({ h })
})
// fixes, in order of preference:
// 1. use the async API — bcrypt.hash offloads to the libuv thread pool
// 2. worker_threads for genuine CPU work you own
// 3. push it to a queue and answer 202 Accepted
// 4. cluster / PM2 to use all cores — helps throughput, not one slow requestDetail that shows depth: the libuv thread pool defaults to four threads and is shared by file I/O, DNS lookups, zlib and crypto. Four concurrent bcrypt calls saturate it, and the fifth waits — a real and frequently misdiagnosed production stall. UV_THREADPOOL_SIZE raises it.
- How would you detect that in production? (Event loop lag metric.)
- What does worker_threads share with the main thread?
- Does clustering help a single slow request?
In order: middleware → guards → interceptors (before) → pipes → handler → interceptors (after) → exception filters wrapping the whole thing.
Reciting the order is half the marks. The other half is naming what you actually put in each:
| Layer | What belongs there |
|---|---|
| Middleware | Request id generation, raw body capture for webhook signatures, correlation headers. |
| Guards | Authentication and authorisation — anything that answers "may this request proceed?". Has access to the execution context, so it can read roles from a decorator. |
| Interceptors | Cross-cutting concerns around the handler: logging, timing, response shaping, caching, timeouts. They can transform the return value. |
| Pipes | Validation and transformation of inputs. ValidationPipe, ParseUUIDPipe. |
| Filters | Turning thrown domain exceptions into HTTP responses with a consistent shape. |
The distinction they probe: a guard decides, an interceptor wraps, a pipe transforms. If you can only put one thing in the wrong place it is usually auth in middleware — which works, but loses you the execution context and the decorator metadata.
- Where would you put rate limiting?
- How does a guard read a @Roles() decorator?
- What is an execution context?
The security value is whitelist, not the type safety. Without it, a client can post { seats: 2, isAdmin: true } and that extra property travels into your service and potentially into an ORM create — this is mass assignment, and it is the actual reason the flag exists. Say that; most candidates only mention type safety.
transform: true matters too: without it your "DTO" is a plain object that merely satisfies the shape, so instanceof checks and class methods silently do not work.
export class CreateBookingDto {
@IsUUID() campId: string
@IsISO8601() startsAt: string
@IsInt() @Min(1) @Max(10) seats: number
}
@Post()
create(@Body() dto: CreateBookingDto) { return this.svc.create(dto) }app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip properties with no decorator
forbidNonWhitelisted: true, // or reject the request outright
transform: true, // give the handler a real class instance
transformOptions: { enableImplicitConversion: true },
}))The 2026 alternative worth naming: many teams have moved to Zod with a custom validation pipe, because it gives one schema that produces both the runtime check and the TypeScript type via z.infer, rather than keeping decorators and types in sync by hand. Mentioning that you know both and why you would choose either is a strong answer.
- How do you validate query params and route params?
- How do you return a useful error shape from a failed validation?
- What is mass assignment?
This turns your biggest architectural gap into evidence of judgement. Learn the shape of it: name the tradeoff honestly → give the specific trigger that would change the decision → name the tool and why that tool.
I want to be precise about the word, because it gets overloaded: they are domain modules inside a deployment that shares infrastructure, not six independently deployed services with independent failure domains. Communication is synchronous, and at our volume that was the right call — a broker is infrastructure you have to run, monitor, secure and reason about, and at five thousand users with sub-two-hundred-millisecond responses it would have bought us nothing measurable.
The specific point at which I would add one is notifications. Today a booking confirmation sends email inline, so a slow SMTP provider adds latency to the user's request, and a failure there can fail a booking that actually succeeded — the write is committed but the user sees an error. That is a queue-shaped problem. I would put BullMQ on Redis in first because we already run Redis, and only reach for Kafka if we needed event replay, ordered partitions, or several independent consumers of the same stream.
Know the difference if they push: a queue (BullMQ, SQS, RabbitMQ) delivers each job to one consumer and is about work distribution. A log (Kafka) retains an ordered stream that many independent consumers read at their own offset, and is about event history. Choosing Kafka for background jobs is over-engineering; choosing a queue when you need replay is under-engineering.
- What happens if the queue worker crashes mid-job?
- What is a dead letter queue?
- How do you make a job idempotent?
Resources as nouns, verbs as HTTP methods, hierarchy where it means containment. That gets you a pass. The senior details are what actually get scored:
- Status codes that mean something. 400 malformed, 401 not authenticated, 403 authenticated but not allowed, 404 not found, 409 the slot is already taken, 422 well-formed but semantically impossible, 429 rate limited.
- Idempotency.
POST /bookingstakes anIdempotency-Keyheader so a client retry after a timeout returns the original booking instead of creating a second one. Store the key with the response for 24 hours. - Cursor pagination, not offset — offset drifts when rows are inserted, and
OFFSET 100000makes the database count through everything. - One error envelope everywhere, so clients write error handling once.
- Versioning via
/v1or a header, decided before the first external consumer exists. - Filtering as query params, not a proliferation of endpoints like
/camps/by-location/:x.
GET /camps?location=&ageMin=&priceMax=&cursor=&limit=
GET /camps/:id
GET /camps/:id/slots?from=&to=
POST /bookings { campId, slotId, seats } → 201 + Location
GET /bookings/:id
DELETE /bookings/:id → 204{
"error": {
"code": "SLOT_UNAVAILABLE", // stable, machine-readable
"message": "That slot is full.", // human, safe to display
"details": [{ "field": "slotId", "issue": "no_capacity" }],
"requestId": "01J8X…" // the thing that makes support possible
}
}- How would you version this without breaking clients?
- Why cursor over offset?
- What is HATEOAS and do you use it? (Honest answer: no, and say why.)
A read-then-write without protection double-books, because both transactions read "one seat left" before either writes. Four defences — name the tradeoffs, do not just list them:
UPDATE slots
SET remaining = remaining - 1
WHERE id = $1 AND remaining > 0;
-- affected rows = 0 means someone beat you → return 409
-- one statement, no lock held across a round tripCREATE UNIQUE INDEX ON bookings (slot_id, seat_no);
-- catch the violation, return 409. Holds even if the app is wrong.BEGIN;
SELECT * FROM slots WHERE id = $1 FOR UPDATE; -- serialises this row
-- ... check and insert ...
COMMIT;
-- correct and simple, but throughput on a hot row drops and
-- inconsistent lock ordering across transactions deadlocksUPDATE slots SET remaining = remaining - 1, version = version + 1
WHERE id = $1 AND version = $2;
-- 0 rows → someone else committed first → re-read and retryClose with the layered answer: "in practice I would use the atomic decrement for the common path and keep the unique constraint as the backstop, because application logic changes and constraints do not." Defence in depth is the senior position.
- What if the seat must be held for 10 minutes during payment?
- How does this change across two services?
- What is a deadlock and how do you avoid one?
Short-lived access JWT (10–15 minutes) plus a long-lived refresh token. The refresh token lives in an httpOnly; Secure; SameSite=Strict cookie, is rotated on every use, and the old one is invalidated — with reuse detection, so if a rotated token is presented again you revoke the whole family, because that means it was stolen.
Access tokens carry the user id and roles; a Nest guard verifies the signature and a @Roles() decorator plus a roles guard does authorisation.
Then the sentence that shows you have thought about it rather than copied it:
The thing to be honest about with JWTs is that they cannot be revoked. Once signed, that token is valid until it expires — so if a user is banned or logs out, a stateless check will still accept their token. You either keep access-token lifetimes very short and accept a small window, or you keep a denylist in Redis, at which point you have reintroduced the state that JWTs were supposed to remove. On our systems we did short lifetimes plus a Redis denylist for explicit logout, because we already ran Redis.
Other details worth having ready: passwords hashed with argon2id or bcrypt (never SHA — it is fast, which is exactly wrong for passwords); localStorage is readable by any XSS so tokens there are a real risk; and if you use cookies you need CSRF protection, which SameSite mostly but not entirely provides.
- Where do you store the access token on the client?
- What is refresh token rotation and reuse detection?
- How would you implement "log out of all devices"?
Counters in Redis keyed by user id or IP. Three algorithms, and knowing why you would move up the list is the answer:
- Fixed window.
INCR key+EXPIRE. Cheap, but allows a burst of double the limit across a window boundary — 100 requests at 11:59:59 and 100 more at 12:00:00. - Sliding window log. A sorted set of timestamps; drop anything older than the window with
ZREMRANGEBYSCORE, thenZCARD. Exact, but memory grows with request volume. - Token bucket. Tokens refill at a fixed rate up to a capacity. Allows a controlled burst, which is usually what you actually want, and needs only two numbers per key.
-- Lua, so the INCR and EXPIRE cannot interleave
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return nReturn 429 with Retry-After, and the RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers so well-behaved clients can back off instead of hammering. And say why it must be in Redis rather than in memory: with more than one instance behind a load balancer, in-process counters give each instance its own limit.
- Where do you put the limiter — app, gateway or CDN?
- How do you rate limit by user when they are not logged in?
- What do you do about a distributed attack from many IPs?
An idempotent operation produces the same result whether applied once or many times. GET, PUT and DELETE are idempotent by specification; POST is not.
Why it matters: a client that times out does not know whether the request succeeded. If it retries a POST /payments, you charge twice. The fix is an idempotency key:
// client sends a UUID it generates once per logical operation
Idempotency-Key: 8f14e45f-…
// server: SETNX the key → if it already exists, return the stored response
const first = await redis.set(`idem:${key}`, 'pending', { NX: true, EX: 86400 })
if (!first) return cachedResponseFor(key)
// ...do the work, then store the response body and status against the keyAlso note: idempotent is not the same as safe. DELETE is idempotent (deleting twice leaves the same state) but not safe (it changes state). GET is both.
- How long do you keep idempotency keys?
- What if the same key arrives with a different body?
- Is a webhook handler idempotent?
Whenever the data is larger than you want in memory, or you want to start producing output before the input is finished. A 500MB CSV read with fs.readFileSync allocates 500MB; streamed, it holds a 64KB chunk.
import { pipeline } from 'node:stream/promises'
await pipeline(
fs.createReadStream('big.csv'),
csvParse(),
transformRows(),
fs.createWriteStream('out.ndjson')
)
// pipeline() propagates errors and destroys every stream on failure —
// .pipe() does neither, which is why it leaks file handlesBackpressure is the concept they are fishing for: if the destination is slower than the source, the buffer grows without bound and the process runs out of memory. pipe/pipeline handle this by pausing the readable when the writable's internal buffer passes its high-water mark. Doing it by hand with 'data' events and ignoring the return value of write() is how people accidentally build a memory leak.
- Why pipeline over pipe?
- How would you stream a CSV export to a browser?
- What is an object-mode stream?
You do not accept the bytes. Issue a presigned S3 URL so the browser uploads directly to storage, and your API only handles metadata:
- Client asks your API for an upload URL, sending filename, content type and size.
- API authorises, generates a presigned
PUTURL scoped to one key, with a short expiry and a content-length limit, and records apendingrow. - Browser uploads straight to S3.
- An S3 event notification triggers a worker that validates, generates thumbnails or extracts text, and flips the row to
ready.
The benefits to name: your API never holds the file, never blocks on the upload, scales independently of file size, and a failed upload leaves no half-written state in your process.
For files over about 100MB, mention multipart upload so a dropped connection resumes rather than restarting. And validate the content type server-side after upload — a client-supplied MIME type is a claim, not a fact.
- How do you stop someone uploading a 10GB file?
- How does the client know when processing is done?
- How do you serve the file back securely?
Graceful shutdown, in this order:
- Stop accepting new work. Fail the readiness probe first so the load balancer stops routing to you, then close the server. Closing first drops requests that are already in flight to you.
- Finish in-flight requests, with a timeout — typically 10–30 seconds.
- Drain background workers: stop pulling new jobs, let current ones finish or return them to the queue.
- Close resources: database pool, Redis, open file handles.
- Exit 0. If the timeout expires, exit anyway — a process that hangs on shutdown gets SIGKILLed and you lose the graceful part entirely.
let shuttingDown = false
app.get('/ready', (_, res) => res.status(shuttingDown ? 503 : 200).end())
process.on('SIGTERM', async () => {
shuttingDown = true // LB stops sending traffic
await sleep(5000) // let it notice
server.close()
await Promise.race([drain(), sleep(20000)])
process.exit(0)
})NestJS has this built in: app.enableShutdownHooks() plus OnModuleDestroy / beforeApplicationShutdown lifecycle hooks. Naming the framework support rather than hand-rolling it is the better answer.
- What is the difference between a liveness and a readiness probe?
- What happens to a job that was half-processed?
- Dependency injection — what does the IoC container buy you? Constructor-injected dependencies you can swap in tests without monkey-patching modules. That testability is the whole point.
- Provider scopes. Default singleton.
REQUESTscope instantiates per request — convenient for request context, but it bubbles up the whole injection chain and costs real throughput. Use anAsyncLocalStorage-based context instead where you can. - Circular dependencies.
forwardRefworks, but it is a smell — usually two modules that should be one, or a shared third module that should own the common code. - CORS. A browser-enforced policy, not a server security feature. A preflight
OPTIONSfires for non-simple requests (custom headers,PUT/DELETE, JSON content type). CORS does not protect your API — anything not a browser ignores it entirely. - Five ways to secure an API. Helmet for headers; validation with whitelisting; rate limiting; parameterised queries or an ORM; secrets out of the repo; dependency audit in CI.
- SQL injection in Node. String-concatenated queries. The fix is parameterised queries — and note that an ORM's
raw()escape hatch reintroduces the risk. - REST vs GraphQL vs gRPC. REST for public and simple APIs. GraphQL when many clients need different shapes of the same data and over-fetching is a real cost — accepting the N+1 and caching complexity it brings. gRPC for internal service-to-service where you want a typed contract and binary efficiency.
- Testing a Nest service. Unit:
Test.createTestingModulewith mocked providers. E2E: Supertest against the real app with a test database, ideally in a container. - Config across five environments.
ConfigModulewith a Joi or Zod validation schema so the process fails at boot on a missing variable rather than at 3am during a request. - Circuit breaker. After N consecutive failures to a dependency, stop calling it and fail fast for a cooldown, then let one probe through. Stops a slow dependency from exhausting your connection pool and taking you down with it.
- WebSockets vs SSE vs polling. SSE for one-directional server→client updates: simpler, plain HTTP, auto-reconnect. WebSockets when the client also pushes. Polling when neither is worth the operational cost.
- Correlation ids. Generate one in middleware, put it in
AsyncLocalStorage, attach it to every log line and pass it downstream. Without it, debugging across services is guesswork. - Node LTS. Node 26 became Active LTS in May 2026; 24 is in its final months of active support; 22 is in maintenance until April 2027. Production runs on an LTS line, never on Current.