Backend fundamentals, field-manual style
Everything on the JD checklist — REST, auth, SQL & NoSQL, caching, scaling, Node internals — plus the scenario questions interviewers use to see if you've actually operated a system, not just read about one. Definition first, then the "why", then a concrete example.
Suggested run order for today
HTTP & REST fundamentals
The vocabulary every other answer today builds on. If you can narrate this section from memory, you can improvise through almost anything else.
What happens when a request hits your backend
This is one of the most common "walk me through it" openers. Interviewers use it to check whether you understand the system your code runs inside, not just the code itself.
- DNS resolution — the domain (
api.ionicwealth.com) is resolved to an IP, usually cached by the OS/browser/resolver. - TCP + TLS handshake — a connection is opened and, for HTTPS, encrypted (certificate exchange, key negotiation). HTTP/2 and keep-alive let this be reused across requests instead of paying this cost every time.
- Load balancer — sits in front of your app servers, picks one instance (round-robin, least-connections, etc.), and can also terminate TLS.
- Application server — the framework's router matches method + path to a handler, runs middleware (auth, logging, body parsing, validation), then your business logic.
- Data layer — the handler talks to a database, cache, or another internal service to do the actual work.
- Response — status code + headers + body are serialized and sent back down the same connection.
What makes an API "RESTful"
/orders) manipulated through a small, uniform set of HTTP methods (verbs).Five properties define a REST API in practice:
- Resource-based URLs —
/users/42/orders, not/getUserOrders?id=42. The URL identifies a "thing", the method says what to do to it. - Statelessness — every request carries everything the server needs to understand it (auth token, params). The server keeps no per-client session in memory between requests — this is exactly what lets you run 10 identical app server instances behind a load balancer.
- Uniform interface — the same verbs (GET/POST/PUT/PATCH/DELETE) mean the same thing on every resource.
- Client–server separation — frontend and backend evolve independently as long as the contract (the API shape) holds.
- Cacheable — responses declare whether they can be cached (via headers like
Cache-Control), so intermediaries can skip round trips.
| Good (resource-based) | Avoid (RPC-style) |
|---|---|
GET /orders/91 | GET /getOrder?id=91 |
POST /orders | POST /createOrder |
DELETE /orders/91 | POST /deleteOrder?id=91 |
GET vs POST vs PUT vs PATCH vs DELETE
| Method | Purpose | Body? | Safe? | Idempotent? |
|---|---|---|---|---|
GET | Read a resource | No | Yes | Yes |
POST | Create a resource / trigger an action | Yes | No | No |
PUT | Replace a resource entirely | Yes | No | Yes |
PATCH | Partially update a resource | Yes | No | Usually, but not guaranteed |
DELETE | Remove a resource | Rarely | No | Yes |
Safe means the method doesn't change server state — a GET should never have side effects (that's also why browsers/crawlers/proxies feel free to re-issue GETs). Idempotent means calling it once has the same end-state as calling it 5 times — covered in depth next, because it's asked constantly on its own.
The classic PUT vs PATCH mix-up: PUT /users/42 with a partial body should, strictly, wipe out any fields you didn't send (it's a full replace). PATCH /users/42 with {"name": "Yash"} changes only name and leaves everything else untouched.
// PUT — full replace: any field left out is gone/reset
PUT /users/42
{ "name": "Yash", "email": "y@x.com", "role": "admin" }
// PATCH — partial update: only touches what you send
PATCH /users/42
{ "role": "editor" }
Idempotency
This matters because networks are unreliable. If a client sends a request and the connection drops before the response arrives, the client doesn't know if it succeeded — so it retries. If the operation isn't idempotent, that retry can double-charge a card, create two orders, or send two emails.
GET (just reads), PUT (sets a resource to an exact state — setting it twice = setting it once), DELETE (deleting an already-deleted resource is still "gone").POST — "create a new order" called twice creates two orders. This is the dangerous one, and the one interviewers push on.The fix: idempotency keys. The client generates a unique key (a UUID) per logical operation and sends it in a header. The server stores "key → result" the first time, and on any retry with the same key, returns the stored result instead of re-executing.
POST /payments
Idempotency-Key: 6c1f6f2e-91ab-4e3a-9b7d-3a2e1d0c9f88
{ "amount": 4999, "currency": "INR" }
// server pseudocode
if (store.has(idempotencyKey)) {
return store.get(idempotencyKey); // same response, no double charge
}
const result = await charge(amount, currency);
store.set(idempotencyKey, result, { ttl: '24h' });
return result;
HTTP status codes
| Range | Meaning | Common codes |
|---|---|---|
| 2xx | Success | 200 OK 201 Created 204 No Content |
| 3xx | Redirection | 301 Moved permanently 304 Not modified (cache hit) |
| 4xx | Client error — you did something wrong | 400 Bad request 401 Unauthenticated 403 Forbidden 404 Not found 409 Conflict 422 Unprocessable entity 429 Too many requests |
| 5xx | Server error — we did something wrong | 500 Internal error 502 Bad gateway 503 Service unavailable 504 Gateway timeout |
The one pair that trips people up: 401 Unauthorized vs 403 Forbidden. 401 really means "unauthenticated" — you have no valid identity (missing/expired token). 403 means "I know who you are, and you're not allowed to do this." Say it exactly like that; it's a favorite gotcha.
Also worth having ready: 409 Conflict (the request clashes with current state — e.g. two updates racing, or a duplicate unique key) and 422 Unprocessable Entity (syntactically valid JSON, but semantically invalid — e.g. an email field that isn't an email).
CORS (Cross-Origin Resource Sharing)
CORS is enforced by the browser, not the server — it exists to protect users, not APIs. Without it, any malicious site you visit could silently use your logged-in cookies to call your bank's API from JavaScript running in your tab.
const cors = require('cors');
app.use(cors({
origin: 'https://app.yourfrontend.com', // never use '*' if you send cookies/auth
methods: ['GET','POST','PATCH','DELETE'],
credentials: true
}));
API design practices
Versioning — put it in the URL (/v1/orders) or a header (Accept: application/vnd.ionic.v2+json). URL versioning is more common and easier to debug/cache; header versioning is "more correct" REST but harder to explore in a browser.
Pagination — never return an unbounded list.
- Offset-based:
?page=3&limit=20. Simple, but slow on large tables (the DB still has to skip all prior rows) and unstable if rows are inserted mid-scroll. - Cursor-based:
?after=order_88231&limit=20. Uses an indexed column (often the ID or timestamp) as a bookmark — O(1) instead of scanning, and stable under concurrent writes. What most fintech/feed APIs use in practice.
Filtering & sorting: query params — GET /orders?status=filled&sort=-created_at.
Consistent envelope: a predictable response shape, e.g. { "data": ..., "meta": { "next_cursor": ... } } for lists, and a consistent error shape (see input validation below) for failures.
Rate limiting
Four algorithms come up repeatedly:
| Algorithm | How it works | Trade-off |
|---|---|---|
| Fixed window | Count requests per clock window (e.g. per minute); reset at boundary | Simple, but allows a burst of 2× the limit right at the window edge |
| Sliding window | Weighs the previous window's count into the current one | Smooths the edge-burst problem, still cheap |
| Token bucket | Bucket refills at a fixed rate; each request consumes a token; empty bucket = reject | Allows short bursts up to bucket size, industry default (AWS, Stripe use variants) |
| Leaky bucket | Requests queue and are processed at a constant output rate | Smooths traffic to a steady rate, adds latency under burst |
// pseudocode: allow 100 requests / 60s per user
key = `ratelimit:${userId}`
count = redis.INCR(key)
if (count == 1) redis.EXPIRE(key, 60)
if (count > 100) return res.status(429).send('Too Many Requests')
Input validation
Validate at the edge (middleware, before the handler runs), using a schema library rather than hand-rolled if chains — it's declarative, reusable, and gives you consistent error responses for free.
const schema = z.object({
email: z.string().email(),
amount: z.number().positive().max(1000000),
currency: z.enum(['INR','USD'])
});
app.post('/payments', (req, res) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ error: 'validation_failed', details: parsed.error.issues });
}
// parsed.data is now type-safe and trusted
});
amount field with no upper bound, an array field with no length cap). Never trust that the frontend already validated it — a request can always be crafted by hand.Security & authentication
Ionic Wealth handles HNI financial data — expect this part to get more follow-up questions than any other section.
Authentication vs authorization
In a typical backend, authentication is a middleware that verifies a token/session and attaches req.user. Authorization is a second, separate check — often role-based (RBAC: admin/editor/viewer) or permission-based — run per-route or per-action, using that req.user.
router.delete('/clients/:id/portfolio',
authenticate, // 401 if no valid token → sets req.user
authorize('portfolio:delete'), // 403 if req.user lacks this permission
deletePortfolioHandler
);
How JWT authentication works
header.payload.signature. Anyone can decode and read the payload — never put secrets in it.- Client sends credentials to
POST /login. - Server verifies the password, then signs a JWT with a server-only secret (or private key, for asymmetric signing) containing claims like
{ sub: userId, role, exp }. - Client stores the token and sends it on every request:
Authorization: Bearer <token>. - Server's auth middleware re-computes the signature from the header+payload using its secret and compares it to the token's signature — no DB hit needed. If it matches and
exphasn't passed, the request is authenticated.
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// middleware, on every protected route
const payload = jwt.verify(token, process.env.JWT_SECRET); // throws if tampered/expired
req.user = payload;
- JWTs can't be "revoked" the way a session can — once issued, it's valid until it expires. This is why access tokens are kept short-lived (minutes), not because of paranoia.
- The payload is signed, not encrypted — never put a password or card number in it.
alg: noneis a real historical vulnerability — always pin the expected algorithm when verifying, don't trust the token's own header to say how to verify it.
Access token vs refresh token
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Minutes (e.g. 15m) | Days–weeks |
| Sent | Every API request | Only to the token-refresh endpoint |
| Stored where | Memory / short-lived cookie | httpOnly, Secure cookie (not accessible to JS — mitigates XSS theft) |
| If stolen | Damage window is small (expires fast) | Higher risk — mitigated by rotation + revocation list |
How should passwords be stored
bcrypt, scrypt, or argon2 — never a fast general-purpose hash like MD5/SHA-256.Why not SHA-256? It's designed to be fast — great for checksums, terrible for passwords, because it lets an attacker who steals your DB try billions of guesses per second on cheap GPU hardware. Bcrypt/argon2 are deliberately slow and tunable (a "cost factor"), so cracking scales in your favor as hardware improves — you just raise the cost.
Salting: a random value added per-password before hashing, stored alongside the hash. It defeats rainbow tables (precomputed hash lookup tables) and ensures two users with the same password get different hashes. Bcrypt generates and embeds the salt for you automatically.
// sign-up: hash before storing
const hash = await bcrypt.hash(plainPassword, 12); // 12 = cost factor
await db.users.insert({ email, passwordHash: hash });
// login: compare, never decrypt
const ok = await bcrypt.compare(plainPassword, user.passwordHash);
if (!ok) return res.status(401).send('Invalid credentials');
SQL injection
const q = `SELECT * FROM users WHERE email='${email}'`;
db.query(q);
db.query(
'SELECT * FROM users WHERE email=$1',
[email]
);
If email is set to ' OR '1'='1, the vulnerable query becomes WHERE email='' OR '1'='1' — always true, returning every row (or worse, chained with ; DROP TABLE users;--).
The fix is parameterized queries / prepared statements — the driver sends the SQL template and the values separately, so the database never interprets user data as SQL syntax. It's not "escaping smartly," it's a fundamentally different wire protocol.
Defense in depth (say all of these, not just one):
- Parameterized queries / an ORM that does this for you by default (Prisma, TypeORM, SQLAlchemy).
- Least-privilege DB users — the app's DB role shouldn't be able to
DROP TABLE. - Input validation as an earlier layer of defense (reject obviously malformed input before it's near a query).
- The same class of bug exists for NoSQL (NoSQL injection — e.g. passing a MongoDB operator object like
{ "$gt": "" }in a field expected to be a string) and shell commands (command injection) — same root cause, same fix: never let raw user input become executable syntax.
XSS & CSRF — not in your list, but a near-certain follow-up after SQL injection
httpOnly so JS can't read them even if XSS occurs.SameSite=Lax/Strict cookies, and requiring custom headers (which cross-site forms can't set) for state-changing requests.Scenario — how would you design a login API
- Endpoint & input:
POST /auth/loginwith{ email, password }, validated by schema (§1.9) before anything else touches it. - Rate limit first — cap login attempts per IP and per account (e.g. 5/min) to blunt brute force and credential-stuffing, before you even query the DB.
- Look up the user by email (indexed column). If not found, don't reveal that — proceed to a dummy bcrypt compare anyway so response timing doesn't leak whether the email exists (timing attack defense), then return a generic
401. - Verify password with
bcrypt.compareagainst the stored hash. - Issue tokens: a short-lived JWT access token + a long-lived refresh token (rotated, stored
httpOnly/Secure/SameSite). - Log the auth event (success/failure, IP, user-agent) — needed for audit trails and anomaly detection, especially non-negotiable at a wealth-management company.
- Return the access token (and user profile minus sensitive fields) with
200; set the refresh token as anhttpOnlycookie rather than in the JSON body.
SQL databases
Direct overlap with your PostgreSQL work at Quantegies — this is where you should sound the most senior, so lean on real examples from your own job when you answer.
SQL vs NoSQL — when to choose which
| SQL (Postgres, MySQL) | NoSQL (MongoDB, DynamoDB) | |
|---|---|---|
| Schema | Fixed, enforced at write time | Flexible / schema-less |
| Relationships | First-class — joins across tables | Denormalized / embedded; joins are awkward or app-side |
| Consistency | Strong (ACID transactions) | Often eventual consistency at scale (tunable in some) |
| Scaling | Primarily vertical, or sharded with effort | Built for horizontal scaling / sharding |
| Best for | Money, inventory, anything needing correctness and relationships | High write-throughput logs, catalogs, flexible/evolving data, huge scale |
The honest interview answer is never "NoSQL is faster" in the abstract — it's about what invariant you need. Money movement needs ACID transactions — that's a hard requirement for SQL. A high-volume, loosely-structured event log or a product catalog with wildly varying attributes per item tolerates a looser model, and gains write scalability from it.
Database indexes
Indexes speed up WHERE, JOIN ON, and ORDER BY on the indexed column(s) — but every INSERT/UPDATE/DELETE now also has to update the index, so over-indexing hurts write throughput. Index columns that are frequently filtered/joined on and selective (many distinct values) — indexing a boolean is_active column rarely helps much.
Composite indexes cover multiple columns, but column order matters: an index on (user_id, created_at) speeds up queries filtering on user_id alone or on both, but not on created_at alone (left-prefix rule).
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at);
-- uses the index (left-prefix match)
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC;
-- does NOT use it efficiently — created_at alone skips the prefix
SELECT * FROM orders WHERE created_at > '2026-01-01';
EXPLAIN confirm it's actually being used?" is the single highest-value sentence you can say.Transactions
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE id = 'A';
UPDATE accounts SET balance = balance + 500 WHERE id = 'B';
COMMIT; -- or ROLLBACK if either UPDATE throws
// Node.js, node-postgres — must use the SAME client for all 3 calls
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id=$2', [500,'A']);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id=$2', [500,'B']);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
ACID properties
INNER JOIN vs LEFT JOIN
NULLs where there's no match on the right.-- INNER JOIN: only users who have placed at least one order
SELECT u.name, o.id FROM users u
INNER JOIN orders o ON o.user_id = u.id;
-- LEFT JOIN: every user, orders.id is NULL if they've never ordered
SELECT u.name, o.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
RIGHT JOIN is the mirror of LEFT (rarely used — people just swap table order and use LEFT). FULL OUTER JOIN keeps unmatched rows from both sides. A good one-liner if asked "when would you use LEFT over INNER": "whenever the absence of a match is itself meaningful information" — e.g. "find users who have never placed an order" is a LEFT JOIN with WHERE o.id IS NULL.
Connection pooling
Opening a DB connection is expensive (TCP + auth handshake). Without pooling, every request pays that cost. A pool keeps, say, 10 warm connections open and hands them out and back — a request borrows one, runs its query, returns it.
Sizing it isn't "bigger is always better." Postgres has a hard max_connections limit (default 100), shared across every app instance talking to it. If you run 20 app instances × pool size 20, that's 400 potential connections against a DB that allows 100 — connections start getting refused. This is exactly why tools like PgBouncer exist: a proxy that pools connections at the infrastructure level, in front of many app instances.
const pool = new Pool({ max: 10, idleTimeoutMillis: 30000 });
const result = await pool.query('SELECT * FROM orders WHERE id=$1', [id]);
// connection is auto-returned to the pool after the query
Normalization vs denormalization — bonus, pairs naturally with joins/indexes
A normalized schema stores a user's address once in a users table and references it by user_id everywhere; a denormalized one might copy the shipping address onto every order row so reading an order never needs a join — at the cost of updating N rows if the address changes, and the risk of them drifting out of sync. OLTP systems (transactional, like Postgres for orders) lean normalized; read-heavy analytics/reporting systems lean denormalized on purpose.
The N+1 query problem & EXPLAIN — bonus, this is the real answer to "how would you find a slow query"
const orders = await db.query('SELECT * FROM orders');
for (const o of orders) {
o.user = await db.query(
'SELECT * FROM users WHERE id=$1',[o.user_id]);
}SELECT o.*, u.name FROM orders o
JOIN users u ON u.id = o.user_id;
-- or DataLoader-style batching by user_id IN (...)To actually diagnose a slow query, run EXPLAIN ANALYZE — it shows whether Postgres used an index (Index Scan) or scanned every row (Seq Scan), plus real vs. estimated row counts and time per step. This is the concrete, checkable version of "check for missing indexes."
Optimistic vs pessimistic locking
BEGIN;
SELECT * FROM accounts
WHERE id=1 FOR UPDATE; -- locks row
-- other transactions trying to
-- touch this row now wait
UPDATE accounts SET balance=... WHERE id=1;
COMMIT;
Good when conflicts are likely/costly (financial balances).UPDATE accounts
SET balance = 4500, version = version + 1
WHERE id = 1 AND version = 7;
-- 0 rows affected → someone else
-- updated it first → retry/reject
Good when conflicts are rare (profile edits) — no lock, higher throughput.NoSQL & MongoDB
Likely lighter than Part III in this interview given the JD's stack — but "when to embed vs reference" is a near-guaranteed question if MongoDB comes up at all.
Embedding vs referencing (MongoDB)
{
_id: "order_1",
user: "Yash",
items: [
{ sku: "A1", qty: 2 },
{ sku: "B4", qty: 1 }
]
}
{ _id: "order_1",
user_id: "u_88",
item_ids: ["it_1","it_2"] }
{ _id: "u_88", name: "Yash" }
{ _id: "it_1", sku: "A1" }
| Embed when | Reference when |
|---|---|
| Data is always read together ("give me this order with its items") | Related data is large, grows unbounded, or is shared/reused across many parents |
| The nested data belongs to, and lives/dies with, the parent (order items) | The related entity is updated independently and often (a user's profile referenced from thousands of orders) |
| One read gets you everything — no extra round trip | Avoids duplicating (and having to keep in sync) the same data everywhere it's referenced |
MongoDB aggregation pipeline
GROUP BY / JOIN / HAVING, expressed as a pipeline instead of one declarative statement.db.orders.aggregate([
{ $match: { status: "completed" } }, // filter first — cheap
{ $group: { _id: "$user_id", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } },
{ $limit: 5 }
]);
$lookup is the aggregation stage that performs a left-outer-join-style merge against another collection — the closest MongoDB gets to a SQL JOIN, and worth naming if asked "does MongoDB support joins."
CAP theorem — bonus, underlies every SQL-vs-NoSQL trade-off answer
Postgres (single primary) is effectively CP-leaning for writes. Many NoSQL stores (Cassandra, DynamoDB) default to AP — favoring "always answer, maybe slightly stale" over refusing to answer. This is why "eventual consistency" shows up so often in the NoSQL column of §3.1's table — it's a direct consequence of choosing availability.
Performance & scale
This part is where "how to improve a slow API" and "sudden traffic spike" get their building blocks — read it right before Part VII.
Caching
| Strategy | How | Trade-off |
|---|---|---|
| Cache-aside | App checks cache first; on miss, reads DB and populates cache | Simple, most common; first request after expiry is always slow |
| Write-through | Every write goes to cache and DB together | Cache always fresh; adds latency to writes |
| Write-back | Write hits cache immediately, DB is updated asynchronously later | Fastest writes; risk of data loss if cache dies before flush |
Invalidation is the hard part ("there are only two hard things in computer science: cache invalidation and naming things"). Two common approaches: a TTL (expire after N seconds — simple, tolerates some staleness) and explicit invalidation (delete/update the cache key the moment the underlying data changes — fresher, but easy to miss a code path and leave stale data).
What to cache: read-heavy, rarely-changing, expensive-to-compute data — a user's permission set, a computed dashboard aggregate, a market-data snapshot. Not: data that must always be perfectly fresh for correctness (an account balance mid-transaction).
async function getUser(id) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached); // cache hit
const user = await db.users.findById(id); // cache miss
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300); // TTL 5m
return user;
}
Load balancers
Common distribution strategies: round-robin (cycle through servers), least connections (send to whichever has the fewest active requests — better when requests vary in cost), IP hash (same client always hits the same server — useful for in-memory session affinity, though that itself conflicts with the "stateless" REST principle from §1.2, which is exactly why sessions belong in Redis, not server memory).
Also a health-check mechanism: it pings each instance and stops routing to ones that fail, which is how a rolling deploy or a crashed instance doesn't take the whole service down.
Horizontal vs vertical scaling — bonus
Stateless application servers scale horizontally easily. The database is usually the harder part to scale horizontally — which is exactly why replication and sharding (next) exist.
Sharding & replication — bonus
user_id % 4), so each node holds a different subset. Scales both read and write throughput, but cross-shard queries/joins get expensive and picking a good shard key is genuinely hard to change later.Message queues — Kafka / SQS — bonus, listed as "good to have" on the JD
Use it when a request shouldn't have to wait on slow/non-critical work: sending an email, generating a report, updating a search index. The API handler publishes a message and returns immediately; a worker processes it separately, and can retry on failure without the user ever seeing an error.
Kafka is a distributed log — many consumers can independently read the same stream at their own pace, messages persist for a configurable time, great for high-throughput event streaming (fits Ionic Wealth's market-data-adjacent domain). SQS is a simpler point-to-point queue — a message is typically consumed once and removed, good for task/job queues.
// instead of doing this inline and making the client wait:
app.post('/orders', async (req,res)=>{
const order = await createOrder(req.body);
await kafka.produce('order.created', order); // fire and forget
res.status(201).json(order); // respond immediately
});
// a separate worker consumes 'order.created' and sends the confirmation email,
// updates analytics, notifies the risk engine — independently, with its own retries
Scenario — how would you improve a slow API
Treat this as a checklist question — name the layers in order, from cheapest/most-likely to most invasive:
| Layer | What to check / do |
|---|---|
| 1. Measure first | Profile/log timings per stage — don't guess. Is it the DB, an external API, or CPU-bound app code? |
| 2. Database | EXPLAIN ANALYZE the slow query, check for missing indexes (§3.2), N+1 queries (§3.8), and connection pool exhaustion (§3.6) |
| 3. Caching | Is repeated, mostly-static data being recomputed every request? Add caching (§5.1) |
| 4. Payload | Are you over-fetching — returning fields nobody needs, or an unpaginated list (§1.7)? |
| 5. Concurrency | Are independent operations (e.g. two unrelated API calls) run sequentially with await when they could run in parallel with Promise.all? |
| 6. External dependencies | Is a slow third-party API blocking the response? Add a timeout, or move it off the critical path (queue it — §5.5) |
| 7. Scale out | If the app itself is CPU-bound and optimized, add more instances behind the load balancer (§5.2) |
Node.js internals
The JD asks for Node.js explicitly — and "what happens when a request reaches your Node backend" is really an event-loop question in disguise.
A request inside your Node.js backend
GET /orders/91 hitting an Express app- The OS hands the incoming connection to libuv's event notification system; Node's single JS thread is free to keep handling other requests the whole time.
- Express's router matches the method + path to a handler, running middleware in order (body parser, auth, validation — §2.1, §1.9).
- Your handler calls
await db.query(...). This doesn't block the thread — Node delegates the actual socket I/O to the OS/libuv's thread pool for certain operations, registers a callback, and the JS thread is immediately free to process other incoming requests. - When the DB responds, its callback is queued. The event loop picks it up on its next pass, resumes your
asyncfunction exactly where itawaited. - Your handler finishes, calls
res.json(...), and the response is written back on that same connection.
The event loop
setTimeout(fn, 0).console.log('1 sync');
setTimeout(()=>console.log('2 macrotask'), 0);
Promise.resolve().then(()=>console.log('3 microtask'));
console.log('4 sync');
// output: 1 sync, 4 sync, 3 microtask, 2 macrotask
// all sync code finishes → all microtasks drain → THEN the next phase's macrotasks run
Blocking vs non-blocking I/O
// blocking — freezes the whole process until the file is read
const data = fs.readFileSync('big.json');
// non-blocking — thread is free immediately, callback fires on completion
fs.readFile('big.json', (err, data) => { /* ... */ });
Almost every Node.js standard-library and DB-driver method has a non-blocking (async) form — using the sync form (anything ending in Sync) inside a request handler is a classic performance bug, because it blocks every other in-flight request on the same process while it runs.
Scenario & system-design questions
These are the questions that actually decide the interview — they check whether you can compose everything above under pressure. Practice saying each answer out loud, once, before the call.
An API suddenly starts receiving huge traffic. What would you do?
- Protect first — this is happening now, so stop the bleeding before investigating: confirm rate limiting (§1.8) is active so one client can't starve everyone else, and check auto-scaling / load balancer health so unhealthy instances aren't still taking traffic.
- Diagnose in parallel — is this legitimate traffic (a marketing push, a viral moment) or abuse (a bot, a retry storm from a buggy client, a DDoS)? Check request patterns: one IP/key vs. broad and organic.
- Scale the bottleneck, not everything — check dashboards/APM: is it CPU-bound app servers (scale out horizontally, §5.3), or is the database the real limit (in which case adding app servers alone just moves the queue to the DB connection pool, §3.6)?
- Shed load gracefully if needed — serve cached/stale responses instead of erroring, return
503withRetry-Afterfor non-critical endpoints, prioritize critical paths (checkout) over non-critical ones (recommendations). - Prevent recurrence — if it was legitimate growth, this becomes a capacity-planning/auto-scaling conversation. If it was abuse, tighten rate limits/WAF rules and add alerting so it's caught earlier next time.
Your database query is slow. What do you check?
- Run
EXPLAIN ANALYZEon the exact query — is it doing aSeq Scanwhere anIndex Scanshould be possible? - Check whether an index exists on the columns in
WHERE/JOIN/ORDER BY— and if a composite index exists, whether the query actually matches its left-prefix (§3.2). - Look for an N+1 pattern in the calling code, not just the one query (§3.8) — sometimes "one slow query" is really 200 fast ones.
- Check table statistics are current (
ANALYZE) — Postgres's query planner picks a bad plan if its row-count estimates are stale. - Check for lock contention — is this query waiting on a lock held by another long-running transaction (§3.9)?
- Check data volume — has the table grown 100x since this query was written and the index/query strategy just hasn't kept up?
- If it's a genuinely expensive aggregate that runs often, consider caching the result (§5.1) or moving it to a read-optimized store (ClickHouse, per this JD's stack).
Two users update the same resource at the same time. What can happen?
What can happen — the "lost update" problem: both read the row at version 7. User A writes their change. User B, still holding the stale version 7 in memory, writes their change right after — silently overwriting A's update with no error, no conflict, no trace that A's change ever happened.
- Pessimistic locking (§3.9) —
SELECT ... FOR UPDATEso the second writer physically waits for the first transaction to finish. Right choice when conflicts are frequent and correctness is non-negotiable — a shared financial balance. - Optimistic locking (§3.9) — add a
versioncolumn; each update'sWHEREclause checks the version it read. If 0 rows are affected, someone else won first — return409 Conflictand let the client re-fetch and retry. Right choice when conflicts are rare — most profile/document edits. - Application-level merge — for collaborative editing (think Google Docs), don't just reject conflicts; use operational transforms or CRDTs to merge both changes. Worth mentioning if you've built this — your NoteSync project used OT for exactly this reason.
An external API your backend depends on is slow or unavailable. What would you do?
- Timeouts — never call an external API without one. Without a timeout, a hung dependency hangs your request handler (and, since Node is single-threaded, potentially backs up everything behind it — §6.2).
- Retries with exponential backoff (+ jitter) — for transient failures, retry after 100ms, 200ms, 400ms... with a little randomness so many clients don't all retry in lockstep and hammer the dependency the moment it recovers. Only retry idempotent operations (§1.4) — retrying a non-idempotent POST blindly can duplicate the side effect.
- Circuit breaker — after enough consecutive failures, stop calling the dependency for a cooldown period and fail fast instead (return a cached/default response or a clear error). This protects your service from wasting threads/connections on a dependency that's already down, and gives the dependency room to recover instead of being retried into the ground by everyone at once.
- Fallback / degrade gracefully — serve a cached last-known-good value, queue the work for later (§5.5) if it doesn't need to be synchronous, or clearly surface a partial failure to the user instead of failing the entire request over one non-critical dependency.
60-second cheat sheet
Skim this in the elevator, not before. Every line links back to its full section above.