Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

20 chapters
⌕
Beginner15›
00Scouting reportR1Screening callR2Machine codingR3JavaScript & TSR3·TSTypeScriptR4React & Next.jsR5Node & NestJSR6Databases & RedisR7DSA roundR8System designR9AWS, Docker, CI/CDR10Resume grillingR11BehaviouralR12HR & the number✓The week before
Advanced5›
50LWhat changes at ₹50L50LHard DSA50LDistributed systems50LRuntime internals50LStaff behavioural
/ search[ ] chaptert top

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
R5

Node, NestJS & API design

Length45–60 min
WhoBackend lead or architect
DecidesWhether "full stack" is half true
Fail modeFramework syntax without runtime understanding
serviceproductsaasagency

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.

5.1
Node is single-threaded. How does it serve thousands of concurrent requests?
What they are really testingThe foundational Node question. They want the I/O offload and, crucially, what breaks 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 request

Detail 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.

They will push with
  • 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?
5.2
Walk me through the layers of a NestJS request.
What they are really testingWhether your architecture claims come with knowing where things go.

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:

LayerWhat belongs there
MiddlewareRequest id generation, raw body capture for webhook signatures, correlation headers.
GuardsAuthentication and authorisation — anything that answers "may this request proceed?". Has access to the execution context, so it can read roles from a decorator.
InterceptorsCross-cutting concerns around the handler: logging, timing, response shaping, caching, timeouts. They can transform the return value.
PipesValidation and transformation of inputs. ValidationPipe, ParseUUIDPipe.
FiltersTurning 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.

They will push with
  • Where would you put rate limiting?
  • How does a guard read a @Roles() decorator?
  • What is an execution context?
5.3
You mention typed DTO validation at every boundary. Show me.
What they are really testingWhether the phrase on your resume corresponds to a configuration you can reproduce.

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) }
the global pipe — this is where the marks are
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 },
}))
2026 note

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.

They will push with
  • 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?
5.4
Six services and no message broker. Why not?
What they are really testingThe most likely architectural challenge in your entire loop. They read "6 domain services" and want to know if you understand what you built.

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.

Say it like this

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.

They will push with
  • What happens if the queue worker crashes mid-job?
  • What is a dead letter queue?
  • How do you make a job idempotent?
5.5
Design a REST API for booking a slot. Talk me through the contract.
What they are really testingAPI design is on your resume as a standard you authored. This is the audit.

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 /bookings takes an Idempotency-Key header 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 100000 makes the database count through everything.
  • One error envelope everywhere, so clients write error handling once.
  • Versioning via /v1 or 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
the error envelope worth having an opinion about
{
  "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
  }
}
They will push with
  • 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.)
5.6
Two users book the last seat at the same moment. What happens?
What they are really testingConcurrency. This is where most full-stack candidates fall apart, and it is directly on your booking-marketplace resume.

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:

1. atomic conditional update — simplest correct fix
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 trip
2. database constraint — the one that survives a logic bug
CREATE UNIQUE INDEX ON bookings (slot_id, seat_no);
-- catch the violation, return 409. Holds even if the app is wrong.
3. pessimistic lock
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 deadlocks
4. optimistic lock — best under low contention
UPDATE slots SET remaining = remaining - 1, version = version + 1
 WHERE id = $1 AND version = $2;
-- 0 rows → someone else committed first → re-read and retry

Close 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.

They will push with
  • 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?
5.7
How do you handle authentication and authorisation?
What they are really testingWhether you know the failure modes of JWTs, not just how to sign 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:

Say it like this

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.

They will push with
  • 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"?
5.8
Rate limiting — how would you build it?
What they are really testingYour resume lists Redis for rate limiting, so this is an audit question.

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, then ZCARD. 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.
fixed window, done atomically
-- 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 n

Return 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.

They will push with
  • 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?
5.9
What is idempotency, and which HTTP methods are idempotent?
What they are really testingWhether you design for retries, which is what distributed systems actually are.

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 key

Also 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.

They will push with
  • How long do you keep idempotency keys?
  • What if the same key arrives with a different body?
  • Is a webhook handler idempotent?
5.10
When have you needed a stream?
What they are really testingMemory awareness. Candidates who have never processed a large file give themselves away here.

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 handles

Backpressure 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.

They will push with
  • Why pipeline over pipe?
  • How would you stream a CSV export to a browser?
  • What is an object-mode stream?
5.11
How do you handle file uploads at scale?
What they are really testingWhether you would route hundreds of megabytes through your API process.

You do not accept the bytes. Issue a presigned S3 URL so the browser uploads directly to storage, and your API only handles metadata:

  1. Client asks your API for an upload URL, sending filename, content type and size.
  2. API authorises, generates a presigned PUT URL scoped to one key, with a short expiry and a content-length limit, and records a pending row.
  3. Browser uploads straight to S3.
  4. 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.

They will push with
  • 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?
5.12
What has to happen on SIGTERM?
What they are really testingDeployment maturity. A candidate who has never thought about this drops requests on every deploy.

Graceful shutdown, in this order:

  1. 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.
  2. Finish in-flight requests, with a timeout — typically 10–30 seconds.
  3. Drain background workers: stop pulling new jobs, let current ones finish or return them to the queue.
  4. Close resources: database pool, Redis, open file handles.
  5. 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)
})
2026 note

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.

They will push with
  • What is the difference between a liveness and a readiness probe?
  • What happens to a job that was half-processed?
5.13
Rapid-fire backend
What they are really testingBreadth across the runtime and the framework.
  • 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. REQUEST scope instantiates per request — convenient for request context, but it bubbles up the whole injection chain and costs real throughput. Use an AsyncLocalStorage-based context instead where you can.
  • Circular dependencies. forwardRef works, 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 OPTIONS fires 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.createTestingModule with mocked providers. E2E: Supertest against the real app with a test database, ideally in a container.
  • Config across five environments. ConfigModule with 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.
←previousReact & Next.js↑ CovernextDatabases & Redis→