Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

The MERN stack (MongoDB, Express.js, React, Node.js) is one of the most popular full-stack combinations in the industry, and MERN interviews are uniquely challenging because they test breadth across four distinct technologies plus the integration patterns that connect them. The strongest candidates can trace a user action from a React click through Express middleware, into MongoDB, and back — explaining the trade-offs at each layer. The questions below are organized by technology and progress from fundamentals to production-level scenarios within each section. Each question includes what interviewers are really testing, what separates a textbook answer from a strong production-informed answer, common red flags, and follow-up questions designed to push your understanding deeper. Practice by answering each question out loud before reading the provided answer — the interview is a conversation, not a written exam.

1. MongoDB Fundamentals

MongoDB is a NoSQL document database that stores data in flexible, JSON-like BSON documents rather than rows and columns. The way to think about it: if your data naturally looks like JSON objects with nested fields and arrays, MongoDB lets you store and query it that way without forcing it into rigid tables.Key features:
  • Schema flexibility — documents in the same collection can have different fields, which is powerful for rapidly evolving products (e.g., an e-commerce catalog where shoes have “size” but laptops have “RAM”)
  • Horizontal scalability via sharding — distributes data across machines. MongoDB Atlas can auto-shard, which is how companies like Forbes and Toyota handle billions of documents
  • Rich query language — supports filters, projections, regex, geospatial queries, and full-text search natively
  • Aggregation framework — a pipeline-based data processing engine that can replace many use cases where you would otherwise need a separate analytics tool
  • Replica sets — automatic failover with primary/secondary replication. In production, a 3-node replica set gives you zero-downtime reads even if one node dies
  • Multi-document ACID transactions (since v4.0) — often overlooked, but MongoDB does support transactions when you need them
What interviewers are really testing: Whether you understand when to choose MongoDB over a relational DB, not just what it is. They want to hear you talk about access patterns, not just features.Red flag answer: “MongoDB is a database that stores JSON.” This is too shallow — it misses why you would choose it and what trade-offs exist.Follow-up:
  1. When would you not choose MongoDB over PostgreSQL for a new project?
  2. What happens to write performance as your MongoDB collection grows from 1M to 100M documents without proper indexing?
  3. How does MongoDB’s WiredTiger storage engine handle concurrency at the document level?
BSON (Binary JSON) is MongoDB’s wire format — a binary-encoded serialization of JSON-like documents. The key difference is not just “it’s binary” but why it exists: BSON solves three problems JSON has for a database.Key differences:
AspectJSONBSON
FormatText-based (human-readable)Binary (machine-optimized)
Data typesString, Number, Boolean, Array, Object, nullAll JSON types + Date, ObjectId, Binary, Decimal128, Int32, Int64, Regex
TraversalMust parse entire stringLength-prefixed — can skip fields without parsing them
SizeOften smaller for simple docsSlightly larger due to metadata, but faster to decode
Why BSON matters in practice:
  • ObjectId is a 12-byte BSON type that encodes timestamp + machine ID + process ID + counter, giving you globally unique IDs without coordination — this is why MongoDB can generate IDs client-side without hitting the server
  • Decimal128 prevents floating-point precision bugs in financial data (e.g., 0.1 + 0.2 !== 0.3 in JSON/JS, but Decimal128 handles it correctly)
  • Length-prefixed encoding means MongoDB can scan through documents and skip irrelevant fields without deserializing them — critical for query performance on large documents
What interviewers are really testing: Whether you understand the engineering trade-offs behind format choices, not just definitions. A strong candidate connects BSON to real performance implications.Red flag answer: “BSON is just binary JSON.” This misses the entire point of why it exists.Follow-up:
  1. If BSON documents are larger than JSON, why does MongoDB still use BSON instead of just compressing JSON?
  2. How does the ObjectId structure help with sorting by insertion time without a separate createdAt field?
  3. What happens when you store a JavaScript Date object vs a string date in MongoDB — how does the query engine treat them differently?
Documents are the fundamental unit of data in MongoDB — think of them as self-contained JSON objects that represent a single entity (a user, an order, a product). Unlike SQL rows, a document can contain nested objects and arrays, so you can model an entire entity with its relationships in one place.Collections are groups of documents — analogous to tables, but without enforced schema. Documents in the same collection can have completely different fields.Key nuances most candidates miss:
  • Capped collections — fixed-size collections that overwrite oldest docs when full. Used for logging (e.g., storing the last 10M log entries with automatic rotation). Created with db.createCollection("logs", { capped: true, size: 104857600 })
  • Schema validation — despite being “schemaless,” production MongoDB almost always uses JSON Schema validation rules: db.createCollection("users", { validator: { $jsonSchema: { ... } } }). The flexibility is not about having no schema, but about evolving it without downtime migrations
  • Document size limit — 16MB per document. This sounds large but becomes a real constraint when embedding arrays that grow unboundedly (e.g., embedding all comments inside a blog post document)
  • The _id field is automatically indexed and must be unique within a collection. If you do not provide it, MongoDB generates an ObjectId
What interviewers are really testing: Whether you can translate relational thinking to document modeling. “Collections are like tables” is the starting point, not the answer.Red flag answer: “Collections are tables and documents are rows.” This is technically correct but shows no understanding of the fundamental modeling differences.Follow-up:
  1. When would you split data across two collections vs embedding it in one document?
  2. What happens when a document exceeds the 16MB size limit? How do you redesign to avoid this?
  3. How do you handle schema evolution in MongoDB when a field needs to change type across millions of existing documents?
CRUD operations are the four fundamental data manipulation operations. In MongoDB, each has important nuances beyond the basic syntax:Create:
  • insertOne() — inserts a single document. Returns insertedId
  • insertMany() — inserts an array of documents. By default uses ordered insert (stops on first error). Pass { ordered: false } for bulk imports where you want to continue past failures — critical for ETL pipelines where you might be inserting 100K docs and a few have duplicate _id values
Read:
  • find() — returns a cursor, not an array. This is important: cursors are lazy and stream results, so querying 1M documents does not load them all into memory
  • findOne() — returns a single document or null. Internally adds limit(1) to the query
  • Projections are critical for performance: find({ status: "active" }, { name: 1, email: 1 }) only returns the fields you need, reducing network transfer and memory usage
Update:
  • updateOne() / updateMany() — uses update operators like $set, $inc, $push, $pull. The gotcha: without $set, you will replace the entire document
  • findOneAndUpdate() — atomically finds and updates, returning either the old or new document (controlled by returnDocument: "after"). Essential for race-condition-safe operations like incrementing a counter
  • replaceOne() — replaces the entire document except _id. Different from updateOne with $set
Delete:
  • deleteOne() / deleteMany() — permanent removal
  • Soft deletes are a common production pattern: updateOne({ _id }, { $set: { deletedAt: new Date() } }) instead of actually deleting, allowing recovery and audit trails
What interviewers are really testing: Do you know the difference between updateOne and replaceOne? Do you understand cursors vs arrays? Do you think about atomicity?Red flag answer: Listing the method names without explaining when to use which, or not knowing that find() returns a cursor.Follow-up:
  1. What is the difference between updateOne with $set and replaceOne? When would you use each?
  2. You need to insert 500K documents as fast as possible. How do you approach this?
  3. How do you implement an atomic “transfer funds” operation between two user documents?
The _id field is MongoDB’s mandatory primary key — every document must have one, it must be unique within its collection, and it is automatically indexed. If you do not provide it, MongoDB generates a 12-byte ObjectId.ObjectId structure (12 bytes):
  • Bytes 1-4: Unix timestamp (seconds since epoch)
  • Bytes 5-9: Random value (unique per machine/process)
  • Bytes 10-12: Incrementing counter
Why this matters in practice:
  • Sortable by creation time — since the first 4 bytes are a timestamp, sorting by _id gives you chronological order for free. db.users.find().sort({ _id: 1 }) returns documents in insertion order without needing a createdAt field
  • Globally unique without coordination — unlike auto-incrementing SQL IDs, ObjectIds can be generated on the client or across multiple shards without a central authority. This is what makes MongoDB horizontally scalable
  • You can extract the timestamp: ObjectId("507f1f77bcf86cd799439011").getTimestamp() returns the creation date
Custom _id values: You can use any unique value as _id — strings, numbers, even embedded documents. A common production pattern: using a natural key like { _id: "user_john@example.com" } for collections where you always query by that key, saving an index.What interviewers are really testing: Whether you understand the implications of _id design on sharding, indexing, and query patterns — not just that it exists.Red flag answer: “It’s just an auto-generated unique ID.” This misses the engineering behind ObjectId and the option to customize it.Follow-up:
  1. Why is using an auto-incrementing integer as _id problematic in a sharded MongoDB cluster?
  2. You notice that inserts are slow on a sharded collection. How might the _id (shard key) be causing a hotspot?
  3. Can two documents in different collections have the same _id value? Why or why not?

2. MongoDB Advanced Concepts

An index in MongoDB is a B-tree data structure (WiredTiger uses B+ trees) that stores a sorted subset of document fields, allowing the query engine to find matching documents without scanning every document in the collection (a “collection scan” or COLLSCAN).Index types and when to use each:
TypeSyntaxUse Case
Single field{ email: 1 }Queries filtering on one field
Compound{ status: 1, createdAt: -1 }Queries filtering/sorting on multiple fields
Multikey{ tags: 1 } (on an array field)Querying array contents
Text{ description: "text" }Full-text search
Geospatial{ location: "2dsphere" }Location-based queries
TTL{ expiresAt: 1 }, { expireAfterSeconds: 0 }Auto-deleting expired documents (sessions, OTPs)
Partial{ email: 1 }, { partialFilterExpression: { status: "active" } }Index only documents matching a filter — saves space
The ESR rule (Equality, Sort, Range): When designing compound indexes, put Equality fields first, then Sort fields, then Range fields. For example, for a query { status: "active", createdAt: { $gte: lastWeek } } sorted by { name: 1 }, the optimal index is { status: 1, name: 1, createdAt: 1 }.Production gotchas:
  • Each index costs ~8KB of RAM per 1000 documents. A collection with 50M docs and 10 indexes can consume several GB of RAM just for indexes
  • Index intersection exists but is often slower than a single compound index — do not rely on MongoDB combining two single-field indexes efficiently
  • Use explain("executionStats") to verify your index is actually being used. The IXSCAN stage means index usage; COLLSCAN means full scan
  • Building indexes on large collections blocks writes by default. Use { background: true } (deprecated in v4.2+; now indexes are built with an optimized hybrid approach)
What interviewers are really testing: Can you design the right index for a given query pattern, and do you understand the write-performance trade-off?Red flag answer: “Indexes make queries faster.” Without understanding write overhead, the ESR rule, or how to verify index usage with explain().Follow-up:
  1. You have a compound index { a: 1, b: 1, c: 1 }. Which queries will use this index and which will not?
  2. How would you index a field that contains arrays of objects (e.g., orders[].productId)?
  3. Your production database has 20 indexes on a collection but writes are slow. How do you decide which indexes to remove?
The aggregation framework is MongoDB’s data processing pipeline — it processes documents through sequential stages where each stage transforms the data and passes results to the next. Think of it like Unix pipes: cat file | grep "error" | sort | uniq -c.Core stages you must know:
StagePurposeExample
$matchFilter documents (like find()){ $match: { status: "active" } }
$groupGroup by field and aggregate{ $group: { _id: "$city", total: { $sum: "$amount" } } }
$projectReshape documents (include/exclude/compute fields){ $project: { fullName: { $concat: ["$first", " ", "$last"] } } }
$sortSort results{ $sort: { total: -1 } }
$lookupLeft outer join with another collectionJoins orders with users
$unwindDeconstruct an array field into separate documentsFlatten tags: ["a","b"] into two docs
$facetRun multiple pipelines in parallel on the same inputGet counts AND top results in one query
$bucketGroup documents into rangesRevenue by price brackets
Real-world example — sales dashboard:
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2024-01-01") } } },
  { $group: {
      _id: { month: { $month: "$createdAt" }, product: "$productName" },
      revenue: { $sum: "$amount" },
      count: { $sum: 1 }
  }},
  { $sort: { revenue: -1 } },
  { $limit: 10 }
]);
Performance rules:
  • Put $match first — it filters documents early, reducing work for downstream stages, and can use indexes
  • $match followed by $sort can use a compound index. Once you hit a $group or $project, indexes are no longer used
  • For large datasets, use { allowDiskUse: true } — aggregation has a 100MB RAM limit per stage by default
  • $lookup (joins) can be expensive. If you are doing $lookup on every query, your schema design might need denormalization
What interviewers are really testing: Can you solve a real analytics problem with the aggregation pipeline? Can you reason about stage ordering for performance?Red flag answer: Listing stage names without explaining how stage order impacts performance or when to use aggregation vs application-level processing.Follow-up:
  1. How would you compute a “running total” or “moving average” using the aggregation framework?
  2. When would you choose to process data in the application layer (Node.js) vs the aggregation pipeline?
  3. You have a $lookup that joins 10M orders with 1M users. It is slow. What are your optimization options?
Sharding is MongoDB’s strategy for horizontal scaling — distributing data across multiple machines (shards) so no single server has to hold or process the entire dataset. Each shard holds a subset of the data determined by the shard key.Architecture components:
  • Shards — individual replica sets that each hold a portion of the data
  • Config servers — store metadata about which data lives on which shard (the “chunk map”)
  • mongos router — query router that clients connect to. It reads the config servers to know which shard to send queries to
Shard key selection is the most critical decision:
  • A bad shard key creates hotspots — e.g., sharding by createdAt sends all new writes to the last shard
  • A good shard key has high cardinality (many unique values), even distribution, and query isolation (most queries target one shard, not all)
  • Example: sharding an e-commerce orders collection by { userId: "hashed" } distributes writes evenly but means queries like “all orders this week” must scatter-gather across all shards
  • Example: sharding by { region: 1, userId: 1 } enables zone sharding where US data stays on US servers (data residency compliance)
Production realities:
  • Once you shard, you cannot change the shard key (pre-v5.0; v5.0+ allows resharding but it is an expensive operation)
  • Scatter-gather queries (queries that do not include the shard key) hit all shards and are much slower
  • MongoDB recommends sharding when your dataset exceeds what a single replica set can handle (typically 2-5TB or when write throughput exceeds what one primary can handle)
  • Chunk splitting and balancing happen automatically — MongoDB moves chunks between shards to maintain even distribution, but this migration consumes I/O
What interviewers are really testing: Do you understand shard key design trade-offs? This is a senior-level question that separates candidates who have operated MongoDB at scale from those who have only read the docs.Red flag answer: “Sharding splits data across servers for scalability” without discussing shard key selection, hotspots, or scatter-gather queries.Follow-up:
  1. You chose { email: 1 } as your shard key. A year later, your write throughput is bottlenecked. What went wrong and how do you fix it?
  2. Explain the difference between hashed sharding and ranged sharding. When would you choose each?
  3. How does MongoDB handle a query that does not include the shard key in the filter?
A replica set is a group of MongoDB instances (typically 3 or more) that maintain identical copies of the same data for high availability and data durability. One node is the primary (handles all writes), and the others are secondaries (replicate the primary’s oplog and can serve reads).How replication works internally:
  • The primary writes operations to a capped collection called the oplog (operations log)
  • Secondaries continuously tail the primary’s oplog and apply the same operations locally
  • This is asynchronous replication by default — secondaries may lag behind the primary (replication lag)
Automatic failover:
  • If the primary becomes unreachable, the remaining members hold an election using the Raft consensus algorithm (since v3.2)
  • A secondary with the most up-to-date oplog wins and becomes the new primary
  • Failover typically completes in 10-30 seconds — during this window, writes fail
  • Applications using the MongoDB driver with retryWrites: true automatically retry failed writes after failover
Read preferences (production decision):
Read PreferenceBehaviorTrade-off
primary (default)All reads from primaryConsistent but adds load
primaryPreferredPrimary unless unavailableGood default for most apps
secondaryReads from secondaries onlyMay return stale data
nearestReads from lowest-latency memberBest for geo-distributed deployments
Write concern — the durability knob:
  • w: 1 — acknowledged after primary writes (default)
  • w: "majority" — acknowledged after majority of nodes write. Prevents data loss during failover but adds latency (~2-5ms extra)
  • w: 0 — fire-and-forget. Fast but dangerous
What interviewers are really testing: Whether you understand the CAP theorem trade-offs in practice — consistency vs availability vs latency in replica sets.Red flag answer: “Replica sets copy data for backup.” This confuses replication with backups and misses the HA and consistency implications.Follow-up:
  1. What is replication lag, and how does it affect reads from secondaries in a real-time application?
  2. You have a 3-node replica set and 2 nodes go down. Can the remaining node accept writes? Why or why not?
  3. How does write concern "majority" prevent a specific data loss scenario that w: 1 does not?
This is arguably the most important MongoDB design decision you will make. Choosing wrong leads to either slow reads (too much referencing) or unbounded document growth (too much embedding).Embedding (denormalized): Related data lives inside the same document.
// User with embedded addresses
{
  _id: ObjectId("..."),
  name: "Sarah",
  addresses: [
    { type: "home", city: "Austin", zip: "73301" },
    { type: "work", city: "Dallas", zip: "75001" }
  ]
}
Referencing (normalized): Related data lives in separate collections, linked by _id.
// User document
{ _id: ObjectId("user1"), name: "Sarah" }
// Address documents
{ _id: ObjectId("addr1"), userId: ObjectId("user1"), city: "Austin" }
{ _id: ObjectId("addr2"), userId: ObjectId("user1"), city: "Dallas" }
Decision framework — embed when:
  • Data is always accessed together (e.g., user + their shipping addresses)
  • The embedded array has a bounded, small size (e.g., max 5 addresses)
  • You need atomic updates on both parent and child (single-document atomicity)
  • The relationship is one-to-few
Reference when:
  • Data grows unboundedly (e.g., user + their 50K comments over years)
  • Data is accessed independently (e.g., products and reviews have different read patterns)
  • The embedded document would exceed 16MB
  • The relationship is one-to-many or many-to-many
  • Multiple entities reference the same data (normalization avoids duplication)
Hybrid pattern (the real-world answer): Embed a summary and reference the full data. For example, a blog post embeds the author’s name and avatarUrl (for display) but references authorId (for the full profile). This avoids a $lookup for the common read path.The “subset pattern”: Embed only the most recent N items (e.g., last 10 reviews) and store the full history in a separate collection.What interviewers are really testing: Data modeling judgment. The correct answer is always “it depends on access patterns,” but you must be able to articulate specific criteria for each choice.Red flag answer: Always embedding or always referencing without considering access patterns, document size limits, or write frequency.Follow-up:
  1. You embedded user comments inside blog posts. Six months later, popular posts have 50K comments and the app is slow. How do you redesign?
  2. How does the “bucket pattern” help with time-series data in MongoDB?
  3. When would you use MongoDB’s $lookup for referencing, and what is its performance limitation compared to SQL JOINs?
Since MongoDB 4.0 (replica sets) and 4.2 (sharded clusters), MongoDB supports multi-document ACID transactions — the same guarantees you get from SQL databases.How they work:
const session = client.startSession();
try {
  session.startTransaction();
  await accounts.updateOne(
    { _id: "alice" },
    { $inc: { balance: -100 } },
    { session }
  );
  await accounts.updateOne(
    { _id: "bob" },
    { $inc: { balance: 100 } },
    { session }
  );
  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
} finally {
  session.endSession();
}
When to use transactions:
  • Financial operations (transfers, payments) where partial updates are unacceptable
  • Multi-collection updates that must be atomic (e.g., creating an order AND reducing inventory)
  • Any operation where you need “all or nothing” semantics
When NOT to use them (the experienced take):
  • If you can model the operation as a single-document update, you do not need a transaction — single-document operations are already atomic in MongoDB
  • Transactions add latency (extra round trips, lock contention) and should not be your default tool
  • Most experienced MongoDB developers redesign their schema to avoid needing transactions rather than reaching for them. This is a fundamental difference from SQL-first thinking
  • Transactions have a 60-second default timeout and hold locks, so long-running transactions block other operations
What interviewers are really testing: Whether you default to transactions (SQL mindset) or think about schema design to avoid them (MongoDB mindset). The best answer acknowledges both options.Red flag answer: “MongoDB doesn’t support transactions” (outdated) or “I always use transactions for safety” (misses the performance implications).Follow-up:
  1. How does MongoDB implement snapshot isolation for transactions, and what are the performance implications?
  2. If a transaction fails mid-way on a sharded cluster, how does MongoDB ensure consistency across shards?
  3. Redesign a “transfer funds” operation so it does not require a multi-document transaction.
Change Streams let you subscribe to real-time data changes in a collection, database, or entire cluster. They are built on top of the oplog and provide a reliable, resumable stream of insert/update/delete events.Basic usage:
const changeStream = db.collection("orders").watch();

changeStream.on("change", (change) => {
  console.log("Operation:", change.operationType);
  console.log("Document:", change.fullDocument);
  // operationType: "insert", "update", "replace", "delete"
});
Real-world use cases:
  • Real-time notifications — notify users when their order status changes
  • Cache invalidation — invalidate Redis cache when source data changes
  • Event-driven architectures — trigger downstream services when data changes (e.g., send email when user registers)
  • Data synchronization — keep Elasticsearch or analytics databases in sync with MongoDB
  • Audit logging — record every change for compliance
Production considerations:
  • Change streams require a replica set (even a single-node replica set for development)
  • They support resume tokens — if your listener crashes, you can resume from exactly where you left off without missing events
  • You can filter changes with a pipeline: collection.watch([{ $match: { "fullDocument.status": "shipped" } }])
  • Change streams use the oplog, so they add minimal overhead to the database
What interviewers are really testing: Whether you know MongoDB has real-time capabilities beyond CRUD, and whether you can design event-driven systems.Red flag answer: Not knowing change streams exist, or suggesting polling the database on an interval instead.Follow-up:
  1. How would you handle a scenario where your change stream listener was down for 2 hours? Can you recover all missed events?
  2. Compare MongoDB Change Streams with using a separate message queue like Kafka. When would you choose each?
  3. What happens to change streams when a replica set failover occurs?
Unlike SQL databases with rigid schemas and migration files, MongoDB’s flexible schema means migrations are a different beast entirely. There are two primary strategies:1. Lazy migration (recommended for most cases): Handle old and new schema versions in your application code. Update documents to the new schema only when they are accessed.
// Application code handles both versions
function normalizeUser(doc) {
  if (!doc.fullName && doc.firstName) {
    // Old schema: firstName + lastName
    doc.fullName = `${doc.firstName} ${doc.lastName}`;
  }
  return doc;
}
2. Eager migration (for critical changes): Run a script to update all documents at once. Use bulkWrite for performance.
// Migrate all documents
const cursor = db.users.find({ fullName: { $exists: false } });
const ops = [];
for await (const doc of cursor) {
  ops.push({
    updateOne: {
      filter: { _id: doc._id },
      update: { $set: { fullName: `${doc.firstName} ${doc.lastName}` },
                $unset: { firstName: "", lastName: "" } }
    }
  });
  if (ops.length === 1000) {
    await db.users.bulkWrite(ops);
    ops.length = 0;
  }
}
3. Schema version field pattern: Add a schemaVersion field to every document and handle each version in code:
{ _id: 1, schemaVersion: 2, fullName: "Ali Khan" }
{ _id: 2, schemaVersion: 1, firstName: "Sara", lastName: "Ahmed" }
Tools: migrate-mongo is a popular library that provides a SQL-migration-like workflow for MongoDB.What interviewers are really testing: Whether you understand that schema flexibility is not the same as “no schema management” — and how you handle evolution at scale.Red flag answer: “MongoDB is schemaless so you don’t need migrations.” Every production MongoDB deployment needs a migration strategy.Follow-up:
  1. You need to rename a field used in 50M documents. What is your migration strategy to avoid downtime?
  2. How do you use MongoDB’s $jsonSchema validator to enforce schema rules while still allowing gradual migration?
  3. What are the risks of lazy migration when running analytics queries that span old and new schema versions?

3. Express.js Basics and Middleware

Express.js is a minimal, un-opinionated web framework for Node.js that sits as a thin abstraction over Node’s built-in http module. It adds three things Node’s raw HTTP server does not have: a routing system, a middleware pipeline, and convenience methods for request/response handling.Why Express exists: Node’s native http.createServer() gives you a raw request and response object. You would have to manually parse URLs, handle different HTTP methods, parse request bodies, set headers, and manage error handling yourself. Express does all of this with a clean API.What Express does NOT do (and why this matters):
  • No built-in ORM or database layer — you choose (Mongoose, Prisma, Sequelize)
  • No built-in authentication — you add Passport, JWT, or custom middleware
  • No built-in validation — you add Zod, Joi, or express-validator
  • No built-in view engine — you add EJS, Pug, or just send JSON
This un-opinionated design is why Express dominates the Node.js ecosystem: it does not lock you into decisions. Compare this with NestJS (opinionated, Angular-style) or Fastify (performance-focused with schema validation).What interviewers are really testing: Whether you understand Express’s role in the stack and can articulate why you would (or would not) choose it over alternatives like Fastify, Koa, or NestJS.Red flag answer: “Express is a Node.js framework for building websites.” Too vague — does not mention middleware, routing, or how it relates to Node’s http module.Follow-up:
  1. When would you choose Fastify over Express for a new project, and why?
  2. Express has not had a major release in years. What does that mean for its production readiness?
  3. How does Express compare to Koa in terms of middleware composition?
Middleware is the core architectural pattern of Express. A middleware function receives (req, res, next) and can do one of three things: (1) modify req or res, (2) end the request-response cycle, or (3) call next() to pass control to the next middleware.The mental model: Think of middleware as a pipeline of functions that a request flows through. Each function can inspect, modify, or short-circuit the request. This is the Chain of Responsibility pattern.Execution order matters critically:
// This works: body parser runs BEFORE the route handler
app.use(express.json());
app.post("/users", (req, res) => res.json(req.body));

// This BREAKS: body parser runs AFTER the route
app.post("/users", (req, res) => res.json(req.body)); // req.body is undefined
app.use(express.json());
The four types and when each matters:
  • Application-level (app.use()) — runs for every request. Use for logging, CORS, body parsing
  • Router-level (router.use()) — scoped to a specific router. Use for sub-application middleware like auth on /admin routes
  • Error-handling ((err, req, res, next)) — the 4-parameter signature tells Express this is an error handler. Must be declared last
  • Third-partycors(), helmet(), morgan(). These are just functions that return middleware
What interviewers are really testing: Whether you understand middleware ordering, the difference between next() and next(err), and how the middleware pipeline affects request processing.Red flag answer: Defining middleware without explaining the pipeline concept, execution order, or the importance of calling next().Follow-up:
  1. What happens if a middleware calls both res.send() AND next()? Is that valid?
  2. How does Express know the difference between a regular middleware and an error-handling middleware?
  3. You have 15 middleware functions. Requests are slow. How do you identify which middleware is the bottleneck?
Routing in Express maps HTTP method + URL path combinations to handler functions. Express internally uses the path-to-regexp library to compile route patterns into regular expressions for efficient matching.Route matching rules (order matters):
  • Routes are matched in the order they are defined — first match wins
  • Exact matches take priority over parameterized routes only if defined first
  • app.use("/api") matches /api, /api/users, /api/anything (prefix matching)
  • app.get("/api") matches only /api exactly (exact matching)
Router composition (the production pattern):
// routes/users.js
const router = express.Router();
router.get("/", getAllUsers);
router.get("/:id", getUserById);
router.post("/", createUser);
module.exports = router;

// app.js
app.use("/api/users", require("./routes/users"));
app.use("/api/orders", require("./routes/orders"));
Route methods: app.get(), app.post(), app.put(), app.patch(), app.delete(), app.all() (matches any method), app.route() (chain methods for the same path)What interviewers are really testing: Whether you understand route ordering bugs, router composition for modular apps, and the difference between app.use() prefix matching and app.get() exact matching.Red flag answer: Only describing basic app.get("/path", handler) without mentioning route ordering, Router composition, or prefix vs exact matching.Follow-up:
  1. You have app.get("/users/:id") and app.get("/users/admin"). A request to /users/admin hits the first route with id = "admin". How do you fix this?
  2. How does Express handle a request that matches no routes?
  3. What is the performance difference between having 500 routes on app directly vs using express.Router() to organize them?
Route parameters capture dynamic segments from the URL path, while query strings pass optional key-value pairs. Understanding the distinction matters for API design.Route parameters (req.params): Identify a specific resource.
// /users/42 -> req.params.id = "42"
app.get("/users/:id", (req, res) => {
  // Always a string -- you must parse if you need a number
  const userId = parseInt(req.params.id, 10);
});
Query strings (req.query): Filter, sort, or paginate resources.
// /users?role=admin&page=2 -> req.query = { role: "admin", page: "2" }
app.get("/users", (req, res) => {
  const { role, page = 1, limit = 10 } = req.query;
});
Key design principle: Use route params for required, resource-identifying values. Use query strings for optional, filtering/sorting values. /users/42 identifies user 42. /users?role=admin&sort=name filters and sorts the users collection.Validation is critical: req.params.id is always a string, and it can be anything the client sends — including SQL injection attempts, excessively long strings, or special characters. Always validate and sanitize.What interviewers are really testing: API design instincts and security awareness.Red flag answer: Not mentioning that params are always strings, or not considering validation/sanitization.Follow-up:
  1. When would you use /users/:userId/orders/:orderId vs /orders/:orderId? What are the trade-offs of nested routes?
  2. How do you validate that :id is a valid MongoDB ObjectId before it reaches your database query?
  3. What is the difference between req.params, req.query, and req.body? When should each be used in REST API design?
Handling POST requests requires body parsing middleware because Express does not parse request bodies by default — req.body is undefined unless you add a parser.Essential middleware:
app.use(express.json()); // Parses Content-Type: application/json
app.use(express.urlencoded({ extended: true })); // Parses Content-Type: application/x-www-form-urlencoded
The extended option matters: extended: true uses the qs library (supports nested objects like user[name]=Ali), while extended: false uses querystring (flat key-value pairs only). For APIs receiving form data with nested structures, use true.Production-grade POST handling:
app.use(express.json({ limit: "10kb" })); // Prevent payload bombs

app.post("/users", async (req, res, next) => {
  try {
    // 1. Validate input
    const validated = userSchema.parse(req.body); // Zod validation
    // 2. Business logic
    const user = await userService.create(validated);
    // 3. Respond with 201 + Location header
    res.status(201).location(`/users/${user.id}`).json(user);
  } catch (err) {
    next(err);
  }
});
What most candidates miss:
  • Set a limit on express.json() to prevent denial-of-service via large payloads (default is 100kb, but 10kb is often sufficient for API requests)
  • Return 201 Created (not 200 OK) for successful resource creation
  • For file uploads, express.json() does not work — you need multer or busboy
  • The Content-Type header must match the parser, or req.body will be empty/undefined
What interviewers are really testing: Whether you handle POST requests defensively — validation, size limits, correct status codes, error handling.Red flag answer: Just showing app.post("/path", (req, res) => res.json(req.body)) without validation, error handling, or mentioning the body parser middleware.Follow-up:
  1. A client sends a POST with Content-Type: text/plain. What happens with express.json() middleware? How do you handle it?
  2. How do you handle multipart/form-data (file uploads) in Express?
  3. What is the difference between express.json() and the deprecated body-parser package?
Express.js is a minimal and flexible Node.js web framework that simplifies handling HTTP requests, routing, and middleware integration. It acts as a wrapper around Node’s HTTP module, providing a clean abstraction to build REST APIs and web applications.

Example:

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

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

app.listen(3000, () => console.log('Server running on port 3000'));

Why:

Because Node’s native HTTP module is low-level — you would manually parse URLs, handle routing with if/else chains, parse JSON bodies with Buffer concatenation, and manage error propagation yourself. Express provides clean abstractions for all of this.

When:

Use it when building REST APIs, microservices, or server-rendered web apps. For new projects in 2024+, also consider Fastify (2-3x faster due to schema-based serialization) or Hono (edge-first, works on Cloudflare Workers).

Where:

Express powers approximately 60% of Node.js web applications in production. Companies like Uber, IBM, and Accenture use it. It is the default choice in most MERN stack tutorials and bootcamps, which contributes to its ecosystem dominance.What interviewers are really testing: Do you just know Express, or do you understand why it exists and what alternatives you would consider?Red flag answer: “Express is a framework for making servers.” No mention of middleware architecture, Node.js HTTP module relationship, or when you would choose alternatives.Follow-up:
  1. If you were starting a new high-performance API from scratch today, would you still choose Express? Why or why not?
  2. How does Express’s middleware model differ from Koa’s async/await-based middleware?
  3. Express 5 has been in alpha for years. What problems does it solve that Express 4 does not?
Middleware functions are functions that execute during the request-response cycle. They have access to req, res, and next() — used to modify requests, handle authentication, validation, logging, etc.

Example:

function logger(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next(); // Pass control to next middleware
}

app.use(logger);
app.get('/', (req, res) => res.send('Home'));

Why:

Middleware provides a way to modularize logic — logging, authentication, validation — without polluting route handlers. Without middleware, you would duplicate auth checks, logging, and error handling inside every single route handler.

When:

Use middleware whenever you want to perform an action before reaching your route (or after response, like error logging). A production Express app typically has 5-15 middleware layers before any route handler executes.

Where:

Common production middleware stack (in order):
  • helmet() — security headers (first, because it protects everything below)
  • cors() — CORS handling
  • morgan() or custom logger — request logging
  • express.json() — body parsing
  • Rate limiter — abuse prevention
  • Auth middleware — token verification
  • Route handlers
  • Error handler (always last)
What interviewers are really testing: Whether you understand that middleware order is not arbitrary — it is a carefully designed pipeline where each layer depends on the ones before it.Red flag answer: Describing middleware as “functions that run before routes” without explaining the pipeline, next(), or order significance.Follow-up:
  1. What happens to your app if an early middleware throws an uncaught exception? How does Express handle it?
  2. Can middleware be async? What is the gotcha with async middleware in Express 4?
  3. How would you create a middleware that measures the response time of every request and logs it?
TypeDescriptionExample
Application-levelBound to appapp.use()
Router-levelBound to an Express routerrouter.use()
Built-inProvided by Expressexpress.json(), express.static()
Third-partyInstalled via npmcors, helmet, morgan
Error-handling4 parameters (err, req, res, next)Custom error middleware

Example:

// Error-handling middleware
app.use((err, req, res, next) => {
  console.error(err.message);
  res.status(500).send('Internal Server Error');
});

Why:

To create reusable, layered logic pipelines.

When:

Whenever you need pre/post-processing around route handlers.

Where:

Throughout the Express lifecycle (before/after routes).
next() passes control to the next middleware in the stack. If not called, the request hangs (no response sent).

Example:

app.use((req, res, next) => {
  console.log('Before route');
  next(); // Pass to next middleware or route
});

app.get('/', (req, res) => {
  res.send('After middleware');
});

Why:

To chain multiple middlewares for modular logic.

When:

When you need layered control (logging → validation → controller).

Where:

Always in custom middleware unless it ends the request.
FunctionPurpose
app.use()Registers middleware (runs for all methods unless filtered by path)
app.get()Defines a route handler specifically for GET requests

Example:

app.use('/users', (req, res, next) => {
  console.log('Middleware for /users');
  next();
});

app.get('/users', (req, res) => res.send('User list'));

Why:

use() is for pre-processing, get() is for request handling.

When:

Use use() for shared logic like authentication.

Where:

Across all routes or specific prefixes.
  1. Request enters Express
  2. Passes through middleware stack
  3. If matched, route handler executes
  4. If no match → 404 handler
  5. If error → error-handling middleware

Example Flow:

Incoming Request

app.use(logger)

app.use(auth)

app.get('/users', controller)

app.use(errorHandler)

Response Sent

Why:

Understanding this helps you debug request flow and performance.

When:

When analyzing middleware order or debugging missed routes.

Where:

In Express core — it’s what powers routing and middleware sequencing.
By using a custom error-handling middleware — identified by 4 parameters (err, req, res, next).

Example:

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ message: err.message });
});

// And to trigger it:
app.get('/', (req, res, next) => {
  next(new Error('Something went wrong'));
});

Why:

Centralized error handling avoids repeating try/catch everywhere.

When:

In production APIs, always define a global error middleware.

Where:

Place at the bottom of middleware stack (after routes).
MethodPurpose
res.send()Sends response (auto-detects type)
res.json()Sends JSON response (sets content-type)
res.end()Ends response without data

Example:

res.send('Hello');
res.json({ success: true });
res.end(); // No data, just closes connection

Why:

Each method suits different scenarios (raw text vs structured data).

When:

Use:
  • send() → text or HTML
  • json() → APIs
  • end() → streams or empty responses

Where:

Controllers or route handlers.
Key practices:
  1. Use Helmet → sets HTTP headers
  2. Use Rate-limiter → prevent brute-force
  3. Use CORS properly
  4. Use express.json({ limit }) → prevent payload overflows
  5. Disable x-powered-by header
  6. Validate all inputs (with Joi/Zod)

Example:

const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

app.use(helmet());
app.use(rateLimit({ windowMs: 60 * 1000, max: 100 }));
app.disable('x-powered-by');

Why:

Express is not secure by default; hardening prevents XSS, CSRF, and DoS attacks.

When:

Always in production environments.

Where:

At app initialization or middleware level.
Async functions throw errors inside Promises, so you must use:
  1. Try/catch inside route, or
  2. Wrap with async error handler.

Example:

// Approach 1: try/catch
app.get('/', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json(users);
  } catch (err) {
    next(err);
  }
});

// Approach 2: wrapper function
const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

Why:

Express doesn’t automatically catch async errors without .catch().

When:

Always for async routes (DB calls, APIs).

Where:

Use asyncHandler pattern across all async routes.
Express maintains a router stack for every HTTP method and path. When a request comes in, it iterates through this stack and executes the first matching route or middleware.

Example:

app.get('/users', handler1);
app.use('/users', handler2);
The router internally uses the path-to-regexp library to match routes efficiently.

Why:

To provide performant routing and predictable middleware flow.

When:

When debugging routing conflicts or order issues.

Where:

Inside express/lib/router/layer.js and route.js source.
Route parameters are dynamic values in URL paths, accessible via req.params.

Example:

app.get('/users/:id', (req, res) => {
  res.send(`User ID: ${req.params.id}`);
});

Why:

Used to identify specific resources (user by ID, post by slug).

When:

For REST endpoints like /users/:id, /posts/:slug.

Where:

Inside route handlers.

4. Express.js Authentication & Authorization

  • Authentication → verifying identity (e.g., login with email/password or Google OAuth)
  • Authorization → verifying permissions (e.g., only admin can delete a user)

Example:

// Example route using both
app.get('/admin', authenticateUser, authorizeRole('admin'), (req, res) => {
  res.send('Welcome Admin!');
});

Why:

Separating these concerns improves scalability and security.

When:

Always implement authenticate first, then apply authorize on protected routes.

Where:

Middleware layer is the best place — reusable across routes.
JWT (JSON Web Token) is a stateless authentication mechanism — no need to store sessions in the database.

Steps:

  1. User logs in with credentials
  2. Server verifies credentials and issues a signed JWT using jsonwebtoken
  3. Client stores JWT (usually in localStorage or cookie)
  4. Client sends JWT in Authorization: Bearer <token> header for protected routes

Example:

import jwt from 'jsonwebtoken';

// Generate Token
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, {
  expiresIn: '1h',
});

// Middleware for verification
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token provided' });

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    res.status(403).json({ error: 'Invalid token' });
  }
}

Why:

JWT avoids database lookups on every request (unlike sessions). The token itself contains the user’s identity and claims, so any server in a load-balanced cluster can verify it independently. This is critical for microservices where there is no shared session store.

When:

Use JWT for REST APIs and microservices. However, do not blindly default to JWT — sessions with Redis are often simpler and more secure for monolithic apps. The main advantage of JWT is in distributed, stateless architectures.

Where:

Ideal for stateless systems or distributed apps (like mobile + web + microservices all sharing the same auth).Critical security nuances most candidates miss:
  • Never store JWTs in localStorage — vulnerable to XSS. Use HttpOnly cookies instead
  • Always set short expiration (15 minutes for access tokens) and use refresh tokens for longevity
  • Include only necessary claims — the payload is Base64-encoded, not encrypted. Anyone can decode it
  • Use RS256 (asymmetric) for microservices instead of HS256 (symmetric), so services can verify tokens without knowing the secret
  • Validate the alg header — the “none algorithm” attack changes alg to "none" to bypass signature verification
What interviewers are really testing: Whether you understand JWT security pitfalls, not just how to generate tokens.Red flag answer: Storing JWT in localStorage, not mentioning token expiration, or not knowing the difference between HS256 and RS256.Follow-up:
  1. A user changes their password. How do you invalidate all their existing JWTs?
  2. What is the “none algorithm” attack and how do you prevent it?
  3. Your JWT payload is 8KB. What problems does this cause and how do you reduce it?
Use sessions when:
  • The app is monolithic and server-rendered (like an Express + EJS app)
  • You need easy session invalidation (e.g., logout all sessions)
  • You store user-specific state in the backend

Example (session-based login):

import session from 'express-session';

app.use(session({
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false },
}));

Why:

Sessions allow you to track user data server-side and revoke tokens instantly.

When:

In traditional web apps or dashboards.

Where:

Stored in Redis for scalability.
RBAC ensures only specific roles can perform actions.

Example:

function authorizeRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Access denied' });
    }
    next();
  };
}

// Usage
app.delete('/users/:id', verifyToken, authorizeRole('admin'), deleteUser);

Why:

Prevents unauthorized operations from lower-privileged users.

When:

In systems with hierarchical roles (admin, manager, user).

Where:

Middleware layer.
ConceptRBACABAC
Full formRole-Based Access ControlAttribute-Based Access Control
Based onUser roleUser + Resource attributes
FlexibilityStaticDynamic (context-aware)

Example (ABAC):

// Only allow if user owns the resource
if (req.user.id !== post.authorId) {
  return res.status(403).json({ error: 'Not allowed' });
}

Why:

ABAC allows more granular control.

When:

Use ABAC in enterprise apps needing dynamic policies (like file sharing).

Where:

Applied at business logic level.
Never store plain text passwords — always hash and salt them.

Example using bcrypt:

import bcrypt from 'bcrypt';

const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);

Why:

Hashing protects passwords even if the database is compromised.

When:

During user registration or password change.

Where:

Inside your user service layer before saving to DB.
Access tokens expire quickly (e.g., 15m). Refresh tokens generate new access tokens securely.

Example:

// Generate both
const accessToken = jwt.sign({ userId }, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' });

// Refresh endpoint
app.post('/refresh', (req, res) => {
  const { token } = req.body;
  const user = jwt.verify(token, REFRESH_SECRET);
  const newAccessToken = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '15m' });
  res.json({ accessToken: newAccessToken });
});

Why:

Improves security while maintaining usability.

When:

For long-lived sessions (like mobile apps).

Where:

Store refresh tokens in DB or secure cookies.
Since JWTs are stateless, you can’t just “delete” them.

Common strategies:

  1. Blacklist tokens in Redis
  2. Rotate refresh tokens (invalidate old ones)
  3. Use short expiry times for access tokens

Example:

// Blacklist token on logout
redisClient.setex(token, expiryTime, 'blacklisted');

Why:

Prevents reuse of stolen tokens.

When:

During logout or detected suspicious activity.

Where:

Store in Redis for quick lookup.
  1. JWT token leakage — store tokens securely
  2. No HTTPS — exposes credentials
  3. Brute force attacks — use rate limiting
  4. Insecure password reset links — use short-lived tokens

Example mitigation:

import rateLimit from 'express-rate-limit';
app.use('/auth/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }));

Why:

Authentication is a top security target.

When:

Apply protections globally.

Where:

Security middleware layer.
Use OAuth 2.0 and libraries like passport.js.

Example:

import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_ID,
  clientSecret: process.env.GOOGLE_SECRET,
  callbackURL: '/auth/google/callback',
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile);
}));

Why:

Simplifies user onboarding.

When:

In consumer apps (like e-commerce or SaaS).

Where:

Handled by auth microservice or route handler.

5. React Fundamentals & Core Concepts

React is a JavaScript library (not a framework) for building user interfaces through composable, reusable components. The key word is “library” — React handles the view layer only and deliberately does not include routing, state management, or data fetching (unlike Angular which is a full framework).Core concepts that define React:
  • Component-based architecture — UIs are built from self-contained components that manage their own state and compose together. Think of LEGO blocks
  • Virtual DOM — React maintains a lightweight in-memory representation of the real DOM. When state changes, React diffs the old and new virtual trees and applies only the minimal necessary changes to the real DOM (reconciliation)
  • One-way data flow — data flows down from parent to child via props. Children communicate up via callback props. This makes data flow predictable and debuggable
  • Declarative rendering — you describe what the UI should look like for a given state, and React figures out how to update the DOM. You never write document.getElementById().innerHTML = ...
What most people miss: React’s real innovation was not the Virtual DOM (which is actually slower than targeted manual DOM updates). It was making UI development predictable by treating rendering as a pure function of state: UI = f(state). This mental model is what makes large applications manageable.What interviewers are really testing: Whether you understand React’s philosophy (declarative, compositional, unidirectional) or just its features.Red flag answer: “React is a framework for building websites.” It is a library (no opinions on routing/state/HTTP), and it builds UIs, not just websites (React Native, React Three Fiber, etc.).Follow-up:
  1. What is the difference between a library and a framework? Why does this distinction matter for React?
  2. If the Virtual DOM adds overhead (diffing), why does React use it instead of direct DOM manipulation?
  3. How does React’s one-way data flow compare to Angular’s two-way data binding? What are the trade-offs?
JSX is a syntax extension for JavaScript that lets you write HTML-like markup inside JS files. It is not HTML — it is syntactic sugar that Babel/SWC transpiles to React.createElement() calls (or the new JSX transform in React 17+ which does not need React in scope).What JSX compiles to:
// You write:
const element = <h1 className="title">Hello</h1>;

// Babel compiles to:
const element = React.createElement("h1", { className: "title" }, "Hello");
Key differences from HTML:
  • class becomes className (because class is a reserved word in JS)
  • for becomes htmlFor
  • All tags must be closed (<img />, <br />)
  • Style is an object: style={{ color: "red", fontSize: "16px" }}
  • Curly braces {} embed any JavaScript expression: <p>{user.name}</p>
  • You cannot use if/else directly in JSX — use ternary or logical AND: {isLoggedIn ? <Dashboard /> : <Login />}
Why JSX exists (the controversial history): When React launched in 2013, mixing HTML in JavaScript was considered a terrible idea. The prevailing wisdom was strict separation of concerns (HTML, CSS, JS in separate files). React’s argument: the real unit of concern is a component, not a file type. A button’s markup, behavior, and styling are inherently coupled.What interviewers are really testing: Whether you understand that JSX is an abstraction over createElement calls and can reason about its limitations.Red flag answer: “JSX is HTML inside JavaScript.” It is not HTML — it is a JS expression that produces React elements. This distinction matters for understanding rendering.Follow-up:
  1. Can you use React without JSX? When would you want to?
  2. What happens if you return two sibling elements from a component without a wrapper? How do Fragments solve this?
  3. Why does JSX require a single root element?
Components are self-contained, reusable building blocks that encapsulate UI, behavior, and (optionally) state. Every React application is a tree of components, from the root App down to the smallest Button.Functional components (the standard since React 16.8):
function UserCard({ name, email, onEdit }) {
  return (
    <div className="card">
      <h2>{name}</h2>
      <p>{email}</p>
      <button onClick={onEdit}>Edit</button>
    </div>
  );
}
Key principles for good components:
  • Single responsibility — a component should do one thing well. If a component needs 500 lines, split it
  • Props are the interface — components receive data and callbacks via props (read-only inputs)
  • State is internal — components manage their own dynamic data with useState/useReducer
  • Composition over inheritance — React strongly favors composing components together rather than class inheritance. Use children prop, render props, or custom hooks
Component naming rules:
  • Must start with an uppercase letter (UserCard, not userCard) — lowercase is interpreted as HTML tags
  • By convention, one component per file, named the same as the file
What interviewers are really testing: Whether you can design clean, reusable component APIs and understand the composition model.Red flag answer: “Components are functions that return HTML.” They return React elements (not HTML), and the interesting part is how you design their prop interfaces and compose them.Follow-up:
  1. How do you decide when to split a component into smaller components?
  2. What is the difference between a “presentational” component and a “container” component? Is this pattern still relevant with hooks?
  3. How do you make a component truly reusable across different projects?
Props (short for “properties”) are read-only inputs that flow from parent to child components. They are React’s mechanism for passing data down the component tree and are the foundation of React’s one-way data flow.Key rules:
  • Props are immutable in the child — never modify props.name directly
  • Props can be any JavaScript value: strings, numbers, objects, arrays, functions, even other components
  • Default props can be set with destructuring: function Button({ variant = "primary" })
  • Children is a special prop: <Card><p>Hello</p></Card> — the <p> is available as props.children
Callback props for upward communication:
function Parent() {
  const [name, setName] = useState("");
  return <ChildInput onChange={setName} />;
}

function ChildInput({ onChange }) {
  return <input onChange={(e) => onChange(e.target.value)} />;
}
TypeScript props (production standard):
interface UserCardProps {
  name: string;
  email: string;
  isAdmin?: boolean; // optional
  onDelete: (id: string) => void;
}

function UserCard({ name, email, isAdmin = false, onDelete }: UserCardProps) {
  // ...
}
What interviewers are really testing: Whether you understand one-way data flow and can design clean prop interfaces, not just pass data.Red flag answer: “Props are like function arguments.” Technically correct but misses the immutability constraint, callback patterns, and children prop.Follow-up:
  1. What happens if you try to mutate a prop inside a child component?
  2. How do you avoid “prop drilling” when you need to pass data through 5+ levels of components?
  3. What is the difference between passing a callback via props vs using useContext for communication?
State is a component’s private, mutable data that, when changed, triggers a re-render. If props are inputs to a component, state is the component’s internal memory.Core rules:
  • State is local and encapsulated — no other component can read or modify it directly
  • State updates are asynchronous and batched — calling setState does not immediately change the value
  • Never mutate state directlystate.count++ will not trigger a re-render. Always use the setter: setCount(count + 1)
  • For updates based on previous state, use the functional form: setCount(prev => prev + 1) — this avoids stale closure bugs
When to use state vs derived values:
// BAD: storing derived state
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]); // redundant!

// GOOD: derive from existing state
const [items, setItems] = useState([]);
const [filter, setFilter] = useState("");
const filteredItems = items.filter(item => item.name.includes(filter));
The “single source of truth” principle: Every piece of data should have exactly one owner. If two components need the same state, lift it up to their common parent rather than duplicating it.What interviewers are really testing: Whether you understand when to use state, when to derive values, and the immutability rules. Candidates who over-use state (storing derived values, duplicating data) reveal weak React understanding.Red flag answer: “State is a variable that triggers re-render.” Misses immutability, async batching, functional updates, and the principle of minimal state.Follow-up:
  1. You have 10 pieces of data in a form. Should you use 10 separate useState calls or one useState with an object? What are the trade-offs?
  2. Why does React not allow direct state mutation? What would happen if it did?
  3. When should you use useReducer instead of useState?
Render is when React executes your component function and creates the Virtual DOM tree. Re-render happens when React needs to update the component due to state or props changes.

Initial Render (Mount Phase):

When a component is first added to the UI:
  • React calls your component function (App(), Button(), etc.)
  • Returns JSX (or React.createElement)
  • React builds a virtual DOM tree
  • It renders to the real DOM
  • Triggers effects like useEffect(() => {}, [])

Re-render (Update Phase):

A re-render happens when:
  • useState change: setCount(1)
  • useReducer dispatch: dispatch({ type: 'ADD' })
  • Props from parent change
  • Context value update

What happens during re-render:

  1. React re-invokes the component function
  2. Gets new JSX
  3. Builds a new virtual DOM
  4. Diffs it with the previous one (using a diffing algorithm)
  5. Updates only the changed elements in the real DOM — improving performance
That’s called reconciliation.
Understanding render vs re-render is crucial for optimizing React applications and debugging performance issues.
When you call useState, React:
  1. Creates an internal memory slot (in the Fiber node)
  2. Stores the initialValue there
  3. Returns two things:
    • The current value from memory
    • A setState function that schedules a re-render

React Internally Stores Hooks Like an Array:

function Component() {
  const [a] = useState(1); // hooks[0]
  const [b] = useState(2);  // hooks[1]
}
Internally it tracks like: hooks = [1, 2]
That’s why you can’t call hooks inside if/else or loops — React must know the order of hooks each render.
useState replaces the value entirely, unlike class components (this.setState) which merge.

Example:

const [user, setUser] = useState({ name: 'John', age: 25 });
setUser({ name: 'Doe' }); // ❌ age is gone!

To preserve previous values:

setUser(prev => ({ ...prev, name: 'Doe' })); // ✅ preserves age
Always use the functional update form when updating based on previous state.
setState is asynchronous. React schedules the state update, and the new state is applied on the next render.

Example:

setCount(1);
console.log(count); // ❌ still shows previous value

To get the latest value:

setCount(prev => {
  console.log(prev); // ✅ logs updated value
  return prev + 1;
});
Use useEffect or the functional update form to work with the latest state value.
MistakeWhy it breaks
Calling useState conditionallyBreaks hook ordering
Not copying old state in object/array updatesYou’ll lose previous data
Setting state and expecting it to update instantlyUpdates are asynchronous and batched

Example of state update pitfall:

// ❌ Wrong - loses previous state
const [user, setUser] = useState({ name: 'John', age: 25 });
setUser({ name: 'Doe' });

// ✅ Correct - preserves previous state
setUser(prev => ({ ...prev, name: 'Doe' }));
Common use cases include:
  • Form inputs: useState("")
  • Toggling modal open/close: useState(false)
  • Page tabs: useState("home")
  • Simple UI flags (loading, error): useState(false)

Example:

function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  return (
    <form>
      <input 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
      />
      <input 
        type="password"
        value={password} 
        onChange={(e) => setPassword(e.target.value)} 
      />
      <button disabled={isLoading}>
        {isLoading ? "Loading..." : "Login"}
      </button>
    </form>
  );
}
useEffect is a side effect hook that allows your code to run after the component renders.A side effect in React is anything that:
  • Reaches outside the component (e.g., fetch, timer, localStorage)
  • Doesn’t directly involve rendering JSX

Basic Syntax:

useEffect(() => {
  // side effect here (e.g., API call, event listener)
  return () => {
    // cleanup (optional)
  };
}, [dependencies]);

Breakdown by Dependency Array:

1. No array = Run on every render:

useEffect(() => {
  console.log("Runs on every render");
});

2. Empty array = Run once (like componentDidMount):

useEffect(() => {
  console.log("Runs once after mount");
}, []);

3. With dependencies = Run when values change:

useEffect(() => {
  console.log("Runs when user changes", user);
}, [user]);
The cleanup function is your replacement for componentWillUnmount. It runs:
  • Before running the effect again (on updates)
  • When the component unmounts

Example:

useEffect(() => {
  const timer = setInterval(() => console.log('Tick'), 1000);
  
  return () => {
    clearInterval(timer); // Cleanup on unmount or re-run
  };
}, []);

Real Example - Logging Scroll Position:

function Logger() {
  useEffect(() => {
    const logScroll = () => console.log(window.scrollY);
    window.addEventListener('scroll', logScroll);

    // Cleanup
    return () => {
      window.removeEventListener('scroll', logScroll);
    };
  }, []); // empty array = run once
}
Always clean up subscriptions, timers, and event listeners to prevent memory leaks.
LifecycleuseEffect Equivalent
componentDidMountuseEffect(() => {...}, [])
componentDidUpdateuseEffect(() => {...}, [dep])
componentWillUnmountCleanup function inside useEffect

Example:

// Class Component
class Example extends React.Component {
  componentDidMount() {
    console.log("Mounted");
  }
  componentDidUpdate(prevProps) {
    if (prevProps.user !== this.props.user) {
      console.log("User changed");
    }
  }
  componentWillUnmount() {
    console.log("Unmounting");
  }
}

// Functional Component with useEffect
function Example({ user }) {
  useEffect(() => {
    console.log("Mounted");
    return () => console.log("Unmounting");
  }, []);

  useEffect(() => {
    console.log("User changed", user);
  }, [user]);
}
useEffect runs after the component renders because:
  1. React first renders the component to the DOM
  2. Then it runs side effects (like API calls, subscriptions)
  3. This ensures the UI is visible immediately, and side effects don’t block rendering
If you need to run code before the browser paints, use useLayoutEffect instead.
Without a unique key, React cannot properly identify which items changed, were added, or removed — leading to:
  • Unnecessary re-renders
  • Incorrect UI updates

Code Example:

function ListExample({ items }) {
  return (
    <ul>
      {items.map((item) => (
        // Always provide a unique key!
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Bad Example (causes re-render issues):

<li key={index}>{item.name}</li>
If the list order changes, items may be mismatched when using index as key.
When multiple child components need to share or sync state, the state is “lifted up” to their common parent. This makes one source of truth for shared data.

Code Example:

function Parent() {
  const [value, setValue] = useState("");

  return (
    <>
      <ChildInput value={value} setValue={setValue} />
      <DisplayValue value={value} />
    </>
  );
}

function ChildInput({ value, setValue }) {
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

function DisplayValue({ value }) {
  return <p>You typed: {value}</p>;
}

Why it’s important:

Prevents inconsistent state across components.
  • Props Drilling: Passing props manually through multiple nested components
  • Context API: Allows data to be shared globally without passing props at every level

Props Drilling Example:

function GrandParent() {
  const theme = "dark";
  return <Parent theme={theme} />;
}

function Parent({ theme }) {
  return <Child theme={theme} />;
}

function Child({ theme }) {
  return <p>Theme is {theme}</p>;
}

Context API Example:

import { createContext, useContext } from "react";

const ThemeContext = createContext("light");

function GrandParent() {
  return (
    <ThemeContext.Provider value="dark">
      <Child />
    </ThemeContext.Provider>
  );
}

function Child() {
  const theme = useContext(ThemeContext);
  return <p>Theme is {theme}</p>;
}

When to use Context:

When data (like theme, user, locale) is needed globally or deeply nested.
The Virtual DOM (VDOM) is a lightweight copy of the real DOM that React keeps in memory. When a component’s state or props change:
  • React creates a new virtual DOM tree
  • It diffs it with the previous one (using a diffing algorithm)
  • It updates only the changed elements in the real DOM — improving performance

Code Example:

import React, { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  // React updates only the <p> element, not the entire DOM
  return (
    <div>
      <h1>Virtual DOM Example</h1>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Why it matters:

Directly updating the DOM is expensive because each change can trigger layout recalculation, repaint, and reflow. The Virtual DOM batches changes and applies them in a single DOM operation.What most candidates get wrong: The Virtual DOM is not faster than manual, targeted DOM manipulation. What it does is make declarative programming fast enough. Without it, you would need to manually track which DOM nodes changed — which is error-prone and scales poorly for complex UIs. The VDOM trades a small performance overhead for a massive improvement in developer experience and code maintainability.The Reconciliation Algorithm (the “diffing”):
  • React compares elements at the same tree level (O(n) complexity, not O(n^3))
  • If the element type changes (<div> to <span>), React destroys the old subtree and rebuilds
  • If the type is the same, React only updates the changed attributes
  • Keys help React identify which list items changed, were added, or removed
What interviewers are really testing: Whether you understand the trade-offs of the VDOM approach and can explain reconciliation, or just repeat “Virtual DOM is fast.”Red flag answer: “The Virtual DOM makes React faster than vanilla JavaScript.” This is technically incorrect — it makes React fast enough while being declarative.Follow-up:
  1. Svelte compiles away the Virtual DOM entirely. What is the argument for and against React’s VDOM approach?
  2. How do React’s key props affect the reconciliation algorithm? What happens with unstable keys?
  3. What is the difference between the “render phase” and the “commit phase” in React’s rendering pipeline?
  • Controlled Component: React controls the input’s value using state
  • Uncontrolled Component: The DOM handles the input’s value directly via ref

Code Example (Controlled):

import { useState } from "react";

function ControlledInput() {
  const [value, setValue] = useState("");

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      placeholder="Controlled"
    />
  );
}

Code Example (Uncontrolled):

import { useRef } from "react";

function UncontrolledInput() {
  const inputRef = useRef();

  const handleSubmit = () => {
    alert(inputRef.current.value);
  };

  return (
    <>
      <input ref={inputRef} placeholder="Uncontrolled" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}
Controlled is best for validation or dynamic UI; uncontrolled can be used for performance or simple forms.
Hooks are special functions that let you use React features (state, lifecycle, context, etc.) in function components.
  • useEffect allows you to perform side effects (like data fetching, DOM updates, event listeners)
  • The dependency array controls when the effect runs

Code Example:

import { useEffect, useState } from "react";

function FetchUser() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users/1")
      .then((res) => res.json())
      .then(setUser);
  }, []); // runs once after component mounts

  return <p>{user ? user.name : "Loading..."}</p>;
}

Behavior based on dependencies:

  • [] → runs once (on mount)
  • [var] → runs when var changes
  • no array → runs on every render
Custom Hooks allow you to reuse logic between components. You create one when multiple components share the same logic (e.g., fetching, resizing, authentication).

Code Example:

import { useState, useEffect } from "react";

function useFetch(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      .then(setData);
  }, [url]);

  return data;
}

// Usage
function User() {
  const user = useFetch("https://jsonplaceholder.typicode.com/users/1");
  return <p>{user ? user.name : "Loading..."}</p>;
}

When to use:

When you want to abstract and share reusable React logic without duplicating code.
In React 18, automatic batching means that all state updates—no matter where they happen (events, timeouts, async functions)—are grouped into a single re-render.

Difference from Earlier Versions:

  • React 17 and earlier: Only updates in React event handlers were batched
  • React 18: All updates are batched automatically, even in async code

Example:

function DataFetcher() {
  const [isLoading, setIsLoading] = useState(false);
  const [data, setData] = useState(null);

  const fetchData = async () => {
    setIsLoading(true);        // Update 1
    const result = await fetch('/api/data');
    const json = await result.json();
    setData(json);             // Update 2
    setIsLoading(false);       // Update 3
  };
}

What Happens in React 18:

All 3 updates (setIsLoading(true), setData(), and setIsLoading(false)) are batched together, and the component re-renders only once.
This reduces unnecessary re-renders and makes your app faster, especially when fetching data from APIs or using setTimeout/Promises.
Event handler batching means React groups multiple state updates made inside the same event handler (like a button click or input change) into a single re-render.
  • ✅ This helps avoid multiple re-renders for each individual state update
  • ✅ It’s built-in and works in both class and functional components
  • ✅ This behavior has existed in React even before version 18

Real-Life Example:

function Form() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  const handleSubmit = () => {
    setName('John Doe');           // 1st update
    setEmail('john@example.com');  // 2nd update
    // React batches these - only one re-render!
  };

  return (
    <div>
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
      />
      <input 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
      />
      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}
When handleSubmit runs, React batches the two state updates, and the component re-renders only once, not twice. This makes the UI faster and avoids flicker or lag.
flushSync() is a special React method that tells React to apply state updates immediately—not later, not in the next render cycle, but right now.Normally, React waits and batches updates to make rendering more efficient. But sometimes, you need the UI to update right away—synchronously.

Example:

import { useState } from 'react';
import { flushSync } from 'react-dom';

function Counter() {
  const [count, setCount] = useState(0);

  const handleIncrement = () => {
    flushSync(() => {
      setCount(count + 1); // force this update to be applied right away
    });
    console.log('Count after flushSync:', count); // logs updated count
  };

  return <button onClick={handleIncrement}>Increment</button>;
}

When to Use:

  • Testing: When writing tests, you want to ensure all updates are done before checking the result
  • Real-time UI: In collaborative editors or games where instant feedback is critical
  • Third-party libraries: When working with libraries that depend on real-time DOM updates
Use it sparingly—React delays updates on purpose to improve performance. Only force sync updates when truly needed.
These help optimize unnecessary re-renders by caching:
  • React.memo → skips re-rendering if props haven’t changed
  • useMemo → skips recalculating expensive values
  • useCallback → skips recreating identical function references
These do not prevent re-renders — they help avoid unnecessary re-renders.

Example with React.memo:

const Child = React.memo(({ name }) => {
  console.log("Child rendered");
  return <p>{name}</p>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      <p>Count: {count}</p>
      <Child name="John" /> {/* Won't re-render when count changes */}
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </>
  );
}

Example with useMemo:

function ExpensiveComponent({ items }) {
  const expensiveValue = useMemo(() => {
    return items.reduce((sum, item) => sum + item.value, 0);
  }, [items]);

  return <div>Total: {expensiveValue}</div>;
}

Example with useCallback:

const Child = React.memo(({ onClick }) => {
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount(c => c + 1), []);

  return <Child onClick={increment} />;
}
During a re-render with useEffect:
  1. React re-executes the component function
  2. Compares old + new Virtual DOM
  3. Decides what to update in the real DOM (reconciliation)
  4. Cleanup function runs (if dependencies changed or component unmounts)
  5. New effect runs after the updated DOM is committed

Real Example:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Effect runs");
    return () => {
      console.log("Cleanup runs");
    };
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

On First Render:

  • count is 0
  • Renders JSX
  • Prints: “Effect runs”

Click Button → Re-render:

  • setCount(1) triggers re-render
  • JSX re-evaluated
  • Cleanup prints: “Cleanup runs”
  • Then new effect prints: “Effect runs”
Use memoization and avoid unnecessary renders:
  • React.memo() → prevents re-render if props haven’t changed
  • useMemo() → memoizes values
  • useCallback() → memoizes functions
  • Avoid anonymous functions inside render if possible

Code Example:

import React, { useState, useCallback, memo } from "react";

const Child = memo(({ onClick }) => {
  console.log("Child rendered");
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);

  return (
    <>
      <p>Count: {count}</p>
      <Child onClick={increment} />
    </>
  );
}

Result:

Child won’t re-render unnecessarily because onClick reference stays stable.
Both are optimization hooks, but they serve different purposes:
HookPurposeReturns
useMemoMemoizes valuesThe memoized value
useCallbackMemoizes functionsThe memoized function reference

useMemo Example:

function ExpensiveComponent({ items }) {
  // Recalculates only when items change
  const total = useMemo(() => {
    return items.reduce((sum, item) => sum + item.price, 0);
  }, [items]);

  return <div>Total: ${total}</div>;
}

useCallback Example:

function Parent() {
  const [count, setCount] = useState(0);
  
  // Function reference stays stable
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return <Child onClick={handleClick} />;
}
Don’t overuse these hooks. They have their own overhead. Only use when you have performance issues.
Debouncing delays the execution of a function until after a specified time has passed.

Example:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    const timer = setTimeout(() => {
      // Perform search
      fetch(`/api/search?q=${query}`)
        .then(r => r.json())
        .then(setResults);
    }, 500); // Wait 500ms after user stops typing

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
}

Custom Hook Version:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// Usage
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
  if (debouncedQuery) {
    fetchResults(debouncedQuery);
  }
}, [debouncedQuery]);
Debouncing reduces API calls and improves performance by waiting for the user to finish typing.
Use useEffect with a resize event listener:

Example:

function ResponsiveComponent() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return <div>Window: {windowSize.width} x {windowSize.height}</div>;
}

Custom Hook Version:

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// Usage
function Component() {
  const { width, height } = useWindowSize();
  return <div>{width} x {height}</div>;
}
Handling async operations in React:

In useEffect:

useEffect(() => {
  async function fetchData() {
    const data = await fetch('/api/data').then(r => r.json());
    setData(data);
  }
  fetchData();
}, []);

In Event Handlers:

const handleSubmit = async (e) => {
  e.preventDefault();
  try {
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
    const result = await response.json();
    console.log('Success:', result);
  } catch (error) {
    console.error('Error:', error);
  }
};

With Promises:

useEffect(() => {
  fetch('/api/data')
    .then(response => response.json())
    .then(data => setData(data))
    .catch(error => setError(error));
}, []);
You cannot make the useEffect callback itself async. Always define an async function inside useEffect and call it.
useEffect is commonly used for data fetching because it runs after render and can handle async operations.

Example:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]); // Re-fetch when userId changes

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <div>{user?.name}</div>;
}
For better data fetching, consider React Query or SWR which handle caching, refetching, and error states automatically.
Error boundaries catch JavaScript errors in the component tree and display fallback UI.

Error Boundary Example:

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught:', error, errorInfo);
    // Log to error reporting service
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>;
    }
    return this.props.children;
  }
}

// Usage
<ErrorBoundary>
  <MyComponent />
</ErrorBoundary>

Important Notes:

  • Error boundaries only catch errors in children components
  • They don’t catch errors in event handlers, async code, or during SSR
  • Use multiple error boundaries for granular error handling
Error boundaries must be class components. Functional components cannot be error boundaries (yet).
Dynamic form handling with validation:

Example:

function DynamicForm() {
  const [formData, setFormData] = useState({});
  const [errors, setErrors] = useState({});

  const handleChange = (name, value) => {
    setFormData(prev => ({ ...prev, [name]: value }));
    // Clear error when user types
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };

  const validate = () => {
    const newErrors = {};
    if (!formData.email) newErrors.email = 'Email required';
    if (!formData.email?.includes('@')) newErrors.email = 'Invalid email';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) {
      console.log('Form valid:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.email || ''}
        onChange={(e) => handleChange('email', e.target.value)}
      />
      {errors.email && <span>{errors.email}</span>}
      <button type="submit">Submit</button>
    </form>
  );
}
For complex forms, consider libraries like React Hook Form or Formik for better validation and performance.
Lazy loading defers loading components until they’re needed, reducing initial bundle size.

How it works:

  • Components are split into separate chunks
  • Loaded only when required (route navigation, conditional rendering)
  • Reduces initial JavaScript bundle size

Example:

import { lazy, Suspense } from 'react';

// Lazy load the component
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Dashboard />
    </Suspense>
  );
}

Benefits:

  • Faster Initial Load: Smaller initial bundle
  • Better Performance: Load code on demand
  • Improved User Experience: Faster time to interactive
In Next.js, code splitting is automatic per page. Use React.lazy() for manual code splitting in regular React apps.
Context API provides a way to share data across the component tree without prop drilling.

Example:

// Create Context
const ThemeContext = createContext();

// Provider Component
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Header />
      <Content />
    </ThemeContext.Provider>
  );
}

// Consumer Component
function Header() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  );
}

When to Use:

  • Global state like theme, user, language
  • Avoiding prop drilling through many levels
  • Simple state that doesn’t need complex logic
Context causes all consumers to re-render when value changes. Split contexts or use memoization for performance.
React Router enables client-side routing in single-page applications.

Basic Setup:

import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/users/:id" element={<UserProfile />} />
      </Routes>
    </BrowserRouter>
  );
}

Dynamic Routing:

import { useParams } from 'react-router-dom';

function UserProfile() {
  const { id } = useParams(); // Gets :id from URL
  return <div>User ID: {id}</div>;
}
import { useNavigate, Link } from 'react-router-dom';

function Navigation() {
  const navigate = useNavigate();
  return (
    <>
      <Link to="/about">About</Link>
      <button onClick={() => navigate('/users/123')}>
        Go to User
      </button>
    </>
  );
}
React Router enables SPA navigation without full page reloads, improving user experience.
Methods to pass data between siblings without Redux:
  1. Lift State Up: Move shared state to common parent
  2. Context API: Share data through React Context
  3. Event Emitters: Use custom event system
  4. State Management Libraries: Zustand, Jotai (lighter than Redux)

Example - Lifting State:

function Parent() {
  const [sharedData, setSharedData] = useState('');
  return (
    <>
      <SiblingA data={sharedData} setData={setSharedData} />
      <SiblingB data={sharedData} />
    </>
  );
}

Example - Context API:

const DataContext = createContext();

function Parent() {
  const [data, setData] = useState('');
  return (
    <DataContext.Provider value={{ data, setData }}>
      <SiblingA />
      <SiblingB />
    </DataContext.Provider>
  );
}

function SiblingA() {
  const { setData } = useContext(DataContext);
  return <input onChange={(e) => setData(e.target.value)} />;
}
React has several limitations when building large-scale applications:
  • State Management Complexity: As apps grow, prop drilling and state management become complex without additional libraries (Redux, Zustand)
  • Bundle Size: React itself adds to bundle size, and without code splitting, initial load can be slow
  • SEO Challenges: Client-side rendering can hurt SEO without SSR (Next.js helps)
  • Performance with Large Lists: Rendering thousands of items can be slow without virtualization
  • No Built-in Routing: Requires additional libraries (React Router)
  • Learning Curve: JSX, hooks, and patterns require significant learning
  • Debugging Complexity: Large component trees can be hard to debug
These limitations are often addressed with ecosystem tools like Next.js, Redux, React Router, and virtualization libraries.
React manages the Virtual DOM by:
  1. Creating a Virtual Representation: React creates a lightweight JavaScript object representation of the DOM
  2. Diffing Algorithm: When state changes, React creates a new Virtual DOM tree and compares it with the previous one
  3. Reconciliation: React determines the minimal set of changes needed
  4. Batch Updates: React batches DOM updates for efficiency
  5. Commit Phase: React applies only the necessary changes to the real DOM

Benefits:

  • Performance: Avoids expensive direct DOM manipulation
  • Efficiency: Only updates what changed, not entire trees
  • Predictability: Makes UI updates more predictable and easier to reason about
  • Cross-browser Compatibility: Abstracts browser differences
The Virtual DOM is React’s core optimization strategy that makes it fast even with complex UIs.
React Hooks (useState, useContext, useReducer) can replace Redux in many cases, but not always.

When Hooks are Sufficient:

  • Small to medium applications
  • Simple state that doesn’t need time-travel debugging
  • Local component state or shared state via Context
  • No need for middleware (logging, async actions)

When Redux is Better:

  • Large applications with complex state
  • Need for time-travel debugging
  • Middleware requirements (Redux Thunk, Saga)
  • Predictable state updates with strict patterns
  • DevTools for debugging

Example with useReducer (Redux-like):

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    default: return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </div>
  );
}
For most apps, Context + useReducer can replace Redux. Use Redux when you need its ecosystem and tooling.
Best practices for state management in large React applications:
  1. Lift State Appropriately: Keep state as local as possible, lift only when needed
  2. Use Context for Global State: For theme, user, locale that many components need
  3. Consider State Management Libraries: Redux, Zustand, or Jotai for complex state
  4. Separate Server and Client State: Use React Query or SWR for server state
  5. Normalize State Shape: Keep state flat and normalized (like Redux)
  6. Use Custom Hooks: Abstract state logic into reusable hooks
  7. Avoid Prop Drilling: Use Context or state management for deeply nested props

Example Structure:

// Local state (component level)
const [isOpen, setIsOpen] = useState(false);

// Shared state (Context)
const { user, setUser } = useContext(UserContext);

// Server state (React Query)
const { data } = useQuery(['users'], fetchUsers);

// Global state (Zustand)
const { count, increment } = useStore();
Optimization strategies for large component trees:
  1. Code Splitting: Use React.lazy() and Suspense to split code
  2. Memoization: Use React.memo(), useMemo(), useCallback()
  3. Virtualization: Use react-window or react-virtualized for long lists
  4. Avoid Inline Functions: Move functions outside render or use useCallback
  5. Proper Keys: Use stable, unique keys in lists
  6. State Colocation: Keep state close to where it’s used
  7. React.memo with Custom Comparison: For expensive components

Example:

// Code splitting
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

// Virtualization
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={1000} itemSize={35}>
  {Row}
</FixedSizeList>

// Memoization
const ExpensiveChild = React.memo(Child, (prev, next) => {
  return prev.id === next.id;
});
React Strict Mode is a development tool that helps identify potential problems in your application.

What it does:

  • Double Invokes: Intentionally double-invokes functions to detect side effects
  • Deprecation Warnings: Warns about deprecated APIs
  • Unsafe Lifecycle Warnings: Identifies unsafe lifecycle methods
  • Legacy String Ref Warnings: Warns about string refs

Example:

import { StrictMode } from 'react';

function App() {
  return (
    <StrictMode>
      <MyComponent />
    </StrictMode>
  );
}

Impact:

  • Helps catch bugs early in development
  • Ensures components are resilient to re-mounting
  • Prepares for future React features
  • Note: Strict Mode only runs in development, not production
Strict Mode helps ensure your code is ready for concurrent features and future React versions.
Strategies to prevent unnecessary re-renders:
  1. React.memo(): Wrap components to prevent re-renders if props haven’t changed
  2. useMemo(): Memoize expensive calculations
  3. useCallback(): Memoize function references
  4. State Colocation: Move state down to the component that needs it
  5. Split Components: Break large components into smaller ones
  6. Avoid Creating Objects in Render: Move object/array creation outside render

Example:

// ❌ Bad - creates new object every render
function Parent() {
  const [count, setCount] = useState(0);
  return <Child config={{ theme: 'dark' }} />;
}

// ✅ Good - stable reference
const config = { theme: 'dark' };
function Parent() {
  const [count, setCount] = useState(0);
  return <Child config={config} />;
}

// ✅ Better - useMemo
function Parent() {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ theme: 'dark' }), []);
  return <Child config={config} />;
}
AspectFunctional ComponentsClass Components
SyntaxFunction declarationClass extends Component
StateuseState hookthis.state
LifecycleuseEffect hookcomponentDidMount, etc.
PerformanceGenerally fasterSlightly more overhead
Code SizeLess codeMore boilerplate
Hooks✅ Can use all hooks❌ Cannot use hooks
this bindingNot neededRequired for methods

Example Comparison:

// Functional Component
function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => console.log('Mounted'), []);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// Class Component
class Counter extends React.Component {
  state = { count: 0 };
  componentDidMount() { console.log('Mounted'); }
  render() {
    return <button onClick={() => this.setState({ count: this.state.count + 1 })}>
      {this.state.count}
    </button>;
  }
}
Functional components are now the recommended approach. Class components are maintained for legacy code.
React Fiber is a complete rewrite of React’s core reconciliation algorithm (shipped in React 16). The old “stack reconciler” processed the entire component tree synchronously — once it started rendering, it could not stop until the entire tree was processed. For complex UIs, this caused dropped frames and janky interactions.What Fiber changes:
  • Work units (Fibers): Each component instance becomes a “fiber” — a JavaScript object that represents a unit of work. React can process these units one at a time, yielding to the browser between them
  • Interruptible rendering: React can pause rendering mid-tree, handle a high-priority update (like a user click), and then resume where it left off. The old reconciler could not do this
  • Priority-based scheduling: User interactions (typing, clicking) get higher priority than background updates (data fetching, analytics). This is what powers useTransition and useDeferredValue in React 18
Fiber node structure (simplified): Each fiber contains: type (component function/class), key, child (first child fiber), sibling (next sibling fiber), return (parent fiber), pendingProps, memoizedState, effectTag (what DOM operation to perform).Concrete features Fiber enables:
  • Concurrent Mode / Concurrent Features (React 18) — rendering can be interrupted
  • Suspense — pause rendering while waiting for async data
  • Automatic batching — group multiple state updates into one render
  • Transitions (useTransition) — mark non-urgent updates so they do not block user input
  • Error boundaries — handle errors at the fiber level without crashing the entire tree
The two-phase rendering:
  • Render phase (interruptible): React walks the fiber tree, computing what changed. No side effects. Can be paused/restarted
  • Commit phase (synchronous): React applies all changes to the DOM in one batch. Cannot be interrupted
What interviewers are really testing: Whether you understand the architectural shift from synchronous to interruptible rendering and can connect Fiber to concrete features like Suspense and transitions.Red flag answer: “Fiber makes React faster.” Fiber is not about raw speed — it is about responsiveness. Rendering the same tree takes roughly the same time, but the UI stays interactive during heavy renders.Follow-up:
  1. How does useTransition use Fiber’s priority system to keep input fields responsive during expensive state updates?
  2. What is the difference between the “render phase” and “commit phase” in terms of what can and cannot be interrupted?
  3. Why does React warn against side effects during render? How does Fiber’s interruptible rendering make side effects in render especially dangerous?
React handles side effects through the useEffect hook, which runs after render.

Effective Management:

  1. Separate Concerns: Use multiple useEffect hooks for different side effects
  2. Proper Dependencies: Always include all dependencies in the dependency array
  3. Cleanup: Always clean up subscriptions, timers, and event listeners
  4. Avoid Infinite Loops: Be careful with dependencies that change on every render

Example:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  // Separate effects for different concerns
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  useEffect(() => {
    fetchPosts(userId).then(setPosts);
  }, [userId]);

  // Cleanup example
  useEffect(() => {
    const interval = setInterval(() => console.log('Tick'), 1000);
    return () => clearInterval(interval);
  }, []);

  return <div>{user?.name}</div>;
}
Custom hooks are functions that start with “use” and can call other hooks.

Example - useFetch:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(url)
      .then(r => r.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <div>{user.name}</div>;
}

Example - useLocalStorage:

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}
Custom hooks enable code reuse and logic abstraction across components.
useRef creates a mutable reference that persists across re-renders but, crucially, does not trigger a re-render when changed. This makes it fundamentally different from useState.Three primary use cases:1. Accessing DOM elements directly:
function TextInput() {
  const inputRef = useRef(null);

  const focusInput = () => inputRef.current.focus();

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus Input</button>
    </>
  );
}
2. Storing mutable values that should not trigger re-renders:
function Timer() {
  const intervalRef = useRef(null);
  const [seconds, setSeconds] = useState(0);

  const start = () => {
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  };

  const stop = () => clearInterval(intervalRef.current);

  return <div>{seconds}s <button onClick={start}>Start</button></div>;
}
3. Tracking previous values:
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => { ref.current = value; });
  return ref.current;
}
Key distinction from useState:
AspectuseStateuseRef
Triggers re-render on changeYesNo
Persists across rendersYesYes
Mutable directlyNo (use setter)Yes (ref.current = x)
Best forUI state (what user sees)Internal state (timer IDs, DOM refs, previous values)
What interviewers are really testing: Whether you know that not every piece of mutable data needs to be state. Using useState for timer IDs or render counts causes unnecessary re-renders.Red flag answer: “useRef is for accessing DOM elements.” That is one use case, but missing the mutable-value-without-re-render use case shows shallow understanding.Follow-up:
  1. Why does storing a timer ID in useState vs useRef cause different behavior? Which is correct?
  2. How does useRef avoid the stale closure problem that useState can have in callbacks?
  3. What is useImperativeHandle and when would you pair it with forwardRef?
HOCs are functions that take a component and return a new component with added functionality. They are the function composition pattern applied to React components: EnhancedComponent = higherOrderComponent(WrappedComponent).The key insight: HOCs do not modify the input component. They wrap it, creating a new component that renders the original with additional props or behavior.

Example:

// withAuth.js -- adds auth checking to any component
const withAuth = (WrappedComponent) => {
  return function AuthenticatedComponent(props) {
    const isAuthenticated = Boolean(localStorage.getItem("token"));
    if (!isAuthenticated) {
      return <p>Please login first</p>;
    }
    return <WrappedComponent {...props} />;
  };
};

// Usage -- Dashboard now requires auth without changing its code
const Dashboard = () => <h2>Welcome to Dashboard</h2>;
export default withAuth(Dashboard);
Why HOCs are less common now: Custom hooks solve the same problem (sharing logic) with less indirection. A useAuth() hook is simpler than withAuth(Component). However, HOCs are still valid for:
  • Cross-cutting concerns that need to wrap rendering (error boundaries, layout wrappers)
  • Third-party library integration (React-Redux’s connect() is a HOC)
  • When you need to intercept or modify rendering, not just add data
Common pitfalls:
  • HOCs lose the original component’s static methods — use hoist-non-react-statics to copy them
  • Refs do not pass through — use React.forwardRef to forward refs
  • Too many HOCs create “wrapper hell”: withAuth(withTheme(withRouter(Component))) — this is why hooks won
What interviewers are really testing: Whether you understand the evolution from HOCs to hooks and can articulate trade-offs between the patterns.Red flag answer: “HOCs are outdated, just use hooks.” They are less common but not obsolete — understanding both shows pattern literacy.Follow-up:
  1. How would you refactor a HOC into a custom hook? What changes and what does not?
  2. What is the “wrapper hell” problem with HOCs and how do hooks solve it?
  3. Can you compose multiple HOCs? What problems arise from deep HOC nesting?
Render Props is a technique where a component accepts a function as a prop and uses it to determine what to render. The component provides data or behavior, and the render function decides the UI. It inverts the control of rendering.

Example:

function MouseTracker({ render }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {render(pos)}
    </div>
  );
}

// Usage -- the consumer decides how to display the mouse position
<MouseTracker render={({ x, y }) => <p>Mouse at {x}, {y}</p>} />
The evolution of code sharing patterns in React:
EraPatternProsCons
2015MixinsSimpleNaming conflicts, tight coupling
2016HOCsComposableWrapper hell, prop conflicts
2017Render PropsExplicit data flowCallback nesting, less readable
2019+Custom HooksClean, composable, no wrappersRequires hooks rules knowledge
When render props are still useful: For libraries that need to give consumers complete control over rendering (e.g., Downshift, React Table v7, Formik’s <Field> component).What interviewers are really testing: Pattern evolution knowledge. They want to see you can trace the progression from mixins to hooks and explain why each pattern emerged.Red flag answer: “Render props are for passing functions as props.” Technically true but misses the purpose: decoupling logic from presentation.Follow-up:
  1. How would you convert a render props component into a custom hook?
  2. What is the performance concern with inline render prop functions, and how do you mitigate it?
  3. The children prop can also be used as a render prop (<Mouse>{pos => ...}</Mouse>). When would you use children vs a named render prop?
Benefits of SSR in React:
  1. SEO: Search engines can crawl fully rendered HTML
  2. Faster Initial Load: Users see content immediately
  3. Better Performance: Reduces client-side JavaScript execution
  4. Social Sharing: Proper meta tags for social media previews
  5. Accessibility: Works better with screen readers on initial load

SSR vs CSR:

AspectSSRCSR
Initial HTMLFully renderedEmpty shell
SEO✅ Excellent❌ Poor
Time to First ByteSlowerFaster
Time to InteractiveFasterSlower
Next.js provides excellent SSR support with features like getServerSideProps and Server Components.
Common styling approaches in React:
  1. CSS Modules: Scoped CSS files
  2. Styled Components: CSS-in-JS library
  3. Tailwind CSS: Utility-first CSS framework
  4. Inline Styles: JavaScript objects
  5. CSS-in-JS: Libraries like Emotion, styled-components

Example - CSS Modules:

// Button.module.css
.button { background: blue; }

// Button.jsx
import styles from './Button.module.css';
<button className={styles.button}>Click</button>

Example - Styled Components:

import styled from 'styled-components';
const Button = styled.button`
  background: blue;
  color: white;
`;
<Button>Click</Button>

Example - Tailwind:

<button className="bg-blue-500 text-white px-4 py-2">
  Click
</button>
Choose based on your project needs: CSS Modules for simplicity, Tailwind for rapid development, styled-components for dynamic styling.
Optimization strategies for large lists:
  1. Virtualization: Only render visible items
  2. Pagination: Load items in chunks
  3. Memoization: Memoize list items
  4. Proper Keys: Use stable, unique keys

Example with react-window:

import { FixedSizeList } from 'react-window';

function LargeList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={35}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Example with Pagination:

function PaginatedList() {
  const [page, setPage] = useState(1);
  const itemsPerPage = 20;
  const start = (page - 1) * itemsPerPage;
  const paginatedItems = items.slice(start, start + itemsPerPage);

  return (
    <>
      {paginatedItems.map(item => <Item key={item.id} data={item} />)}
      <button onClick={() => setPage(p => p + 1)}>Next</button>
    </>
  );
}
Virtualization is the best approach for very large lists (1000+ items) as it only renders visible items.
Shallow Comparison: Compares object references, not nested values Deep Comparison: Recursively compares all nested properties

Shallow Comparison (React.memo default):

const prev = { user: { name: 'John' } };
const next = { user: { name: 'John' } };
// Shallow: false (different object references)
// Deep: true (same values)

Custom Comparison:

const UserCard = React.memo(({ user }) => {
  return <div>{user.name}</div>;
}, (prevProps, nextProps) => {
  // Custom shallow comparison
  return prevProps.user.id === nextProps.user.id;
});
React uses shallow comparison by default for performance. Deep comparison is expensive and rarely needed.
Handling async code and state updates:

Pattern 1: useEffect with async:

useEffect(() => {
  let cancelled = false;
  async function fetchData() {
    const data = await fetch('/api/data').then(r => r.json());
    if (!cancelled) setData(data);
  }
  fetchData();
  return () => { cancelled = true; };
}, []);

Pattern 2: AbortController:

useEffect(() => {
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(r => r.json())
    .then(setData);
  return () => controller.abort();
}, []);

Pattern 3: Functional Updates:

const handleAsyncUpdate = async () => {
  const result = await someAsyncOperation();
  setCount(prev => prev + result); // Safe with functional update
};
Always clean up async operations to prevent state updates on unmounted components.
A common scalable structure:
src/
 ├── app/
 │    ├── (routes)
 │    ├── layout.tsx
 │    └── page.tsx
 ├── components/
 │    ├── ui/
 │    ├── forms/
 │    ├── layout/
 ├── hooks/
 ├── context/
 ├── lib/
 ├── services/
 ├── store/
 ├── styles/
 └── utils/
  • Feature-based organization helps scale
  • Shared state or UI logic goes to /context or /hooks
  • Pages are lazy-loaded in Next.js automatically
Error boundaries catch JavaScript errors in React components and show fallback UIs instead of breaking the app. They only work in class components.

Example:

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidCatch(error, info) {
    console.error("Error logged:", error, info);
  }
  render() {
    if (this.state.hasError) return <h2>Something went wrong!</h2>;
    return this.props.children;
  }
}

// Usage
<ErrorBoundary>
  <ComponentThatMayCrash />
</ErrorBoundary>
To prevent unnecessary re-renders:
  • Use React.memo() for pure functional components
  • Use useCallback and useMemo to memoize functions and computed values
  • Avoid creating new objects/functions in render unnecessarily

Example:

const Button = React.memo(({ onClick }) => {
  console.log("Rendered Button");
  return <button onClick={onClick}>Click me</button>;
});

function App() {
  const handleClick = React.useCallback(() => console.log("Clicked!"), []);
  return <Button onClick={handleClick} />;
}
AspectMonolithicModular (Micro-frontend)
StructureAll components tightly coupled and deployed togetherApp divided into independent modules (built & deployed separately)
Team SizeBetter for small teamsIdeal for large teams, multiple domains
DeploymentSingle deploymentIndependent deployments
Tech StackSingle technologyTech diversity (React + Vue + Angular)

When to use Modular:

  • Large teams, multiple domains
  • Independent deployments
  • Tech diversity requirements
Options:
  • Context API for light global state
  • Redux Toolkit / Zustand / Jotai for complex or shared logic
  • React Query for server state

Example with Zustand:

import { create } from "zustand";

const useStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
}));

function Counter() {
  const { count, increase } = useStore();
  return <button onClick={increase}>Count: {count}</button>;
}
Reconciliation is React’s algorithm for comparing the old and new Virtual DOM trees to determine what needs to be updated.

Process:

  1. Render Phase: React creates a new Virtual DOM tree
  2. Diffing: Compares new tree with previous tree
  3. Reconciliation: Determines minimal set of changes
  4. Commit Phase: Applies changes to real DOM

Key Optimizations:

  • Same-level Comparison: React compares elements at the same level
  • Key Optimization: Keys help React identify which items changed
  • Batching: Multiple updates are batched together
  • Fiber Architecture: Enables incremental rendering

Example:

// Old Virtual DOM
<div>
  <p>Hello</p>
  <span>World</span>
</div>

// New Virtual DOM
<div>
  <p>Hello</p>
  <span>React</span>  // Only this changes
</div>

// React only updates the <span> text node, not the entire tree
Reconciliation is what makes React fast - it minimizes expensive DOM operations by only updating what changed.

6. NEXT JS

FeaturePages Router (pages/)App Router (app/)
IntroducedBefore Next.js 13Next.js 13+
RenderingCSR, SSR, SSGRSC, SSR, SSG, ISR
File-based routingYesYes (with nested layouts)
Data fetchinggetServerSideProps, getStaticPropsAsync components or fetch directly
LayoutsCustomBuilt-in persistent layouts
Server Components✅ Default
Client Components✅ (with “use client”)

Example (App Router):

// app/page.tsx
export default async function HomePage() {
  const data = await fetch("https://api.example.com/posts").then(res => res.json());
  return <div>{data.map(p => <p key={p.id}>{p.title}</p>)}</div>;
}

Example (Pages Router):

// pages/index.js
export async function getServerSideProps() {
  const res = await fetch("https://api.example.com/posts");
  const data = await res.json();
  return { props: { data } };
}
export default function Home({ data }) {
  return <div>{data.map(p => <p key={p.id}>{p.title}</p>)}</div>;
}
  • Server Components (default):
    • Run on the server (never in browser)
    • Can fetch data directly
    • Reduce bundle size and improve performance
  • Client Components:
    • Run in the browser
    • Must be marked with “use client”
    • Can use useState, useEffect, event handlers, etc.

Example:

// app/page.tsx (Server Component)
import ClientButton from "./ClientButton";

export default async function Page() {
  const data = await fetch("https://api.example.com/user").then(r => r.json());
  return (
    <div>
      <h1>Hello, {data.name}</h1>
      <ClientButton />
    </div>
  );
}

// app/ClientButton.tsx (Client Component)
"use client";
import { useState } from "react";

export default function ClientButton() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>Clicked {count}</button>;
}
ModeDescriptionWhen to Use
SSR (Server-Side Rendering)Page rendered at every requestDynamic data that changes often (e.g., dashboard)
SSG (Static Site Generation)Page pre-rendered at build timeStatic content (e.g., blog, docs)
ISR (Incremental Static Regeneration)SSG + revalidation after a time intervalSemi-static content (e.g., news feed)

Example (ISR):

// app/blog/page.tsx
export const revalidate = 60; // regenerate every 60 seconds

export default async function BlogPage() {
  const posts = await fetch("https://api.example.com/posts").then(res => res.json());
  return <div>{posts.map(p => <p key={p.id}>{p.title}</p>)}</div>;
}
  • SSR: When SEO and fast first paint are important
  • CSR (Client-Side Rendering): When user-specific or highly interactive data is needed (like dashboards)

Example CSR (client fetch):

"use client";
import { useEffect, useState } from "react";

export default function Dashboard() {
  const [data, setData] = useState([]);

  useEffect(() => {
    fetch("/api/data").then(res => res.json()).then(setData);
  }, []);

  return <div>{data.map(item => <p key={item.id}>{item.name}</p>)}</div>;
}
In App Router, API routes live under /app/api/*/route.ts.

Example:

// app/api/user/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const user = { name: "Shehroze", age: 28 };
  return NextResponse.json(user);
}

export async function POST(req: Request) {
  const body = await req.json();
  return NextResponse.json({ message: "User created", data: body });
}

Usage:

const res = await fetch("/api/user");
const user = await res.json();
Middleware runs before a request is processed. It can:
  • Redirect or rewrite requests
  • Check authentication
  • Modify headers
Runs on: Edge Runtime (very fast, lightweight)

Example:

// middleware.ts
import { NextResponse } from "next/server";

export function middleware(req) {
  const isLoggedIn = req.cookies.get("token");
  const url = req.nextUrl.clone();

  if (!isLoggedIn && url.pathname.startsWith("/dashboard")) {
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }
}
generateMetadata() allows dynamic SEO metadata generation per page.

Example:

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
  return {
    title: post.title,
    description: post.excerpt,
  };
}

export default function BlogPost({ params }) {
  return <h1>Post: {params.slug}</h1>;
}
Benefit: SEO-friendly and supports dynamic OG tags, locales, etc.
  • Layouts: Persistent UI (e.g., navbars, sidebars) wrapping child routes
  • Parallel Routes: Allow rendering multiple route segments simultaneously (e.g., tabs)

Layout Example:

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <nav>Navigation</nav>
        {children}
      </body>
    </html>
  );
}

Parallel Routes Example:

// app/@tabs/(profile)/page.tsx
export default function ProfileTab() {
  return <p>Profile Tab</p>;
}

// app/@tabs/(settings)/page.tsx
export default function SettingsTab() {
  return <p>Settings Tab</p>;
}
Next.js <Image> component optimizes images automatically:
  • Lazy loading
  • Responsive resizing
  • WebP conversion

Example:

import Image from "next/image";

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero Banner"
      width={1200}
      height={600}
      priority
    />
  );
}
Benefit: Automatically reduces payload and improves Core Web Vitals.
Next.js has built-in i18n support in config and route-level solutions.

Example using next-intl:

// middleware.ts
import createMiddleware from "next-intl/middleware";

export default createMiddleware({
  locales: ["en", "ar"],
  defaultLocale: "en",
});

Usage:

// app/[locale]/page.tsx
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("Home");
  return <h1>{t("welcome")}</h1>;
}
Result: Automatically renders pages in the selected locale.
Server Actions are functions that run exclusively on the server but can be called directly from client components — no API route needed. They are defined with the "use server" directive and represent a fundamental shift in how full-stack React apps handle mutations.

Example — form submission without an API route:

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";

export async function createTodo(formData: FormData) {
  const title = formData.get("title") as string;
  await db.todo.create({ data: { title } });
  revalidatePath("/todos"); // revalidate the page cache
}

// app/todos/page.tsx
import { createTodo } from "../actions";

export default function TodoPage() {
  return (
    <form action={createTodo}>
      <input name="title" placeholder="New todo" />
      <button type="submit">Add</button>
    </form>
  );
}
What is happening under the hood:
  • Next.js creates an API endpoint automatically for each Server Action
  • The client sends a POST request with the form data
  • The server executes the function and can revalidate cached pages
  • Progressive enhancement: forms work even without JavaScript enabled (the action attribute is a real URL)
When to use Server Actions vs API Routes:
Use CaseServer ActionsAPI Routes
Form submissionsPreferredWorks but more boilerplate
Database mutationsPreferredWorks
Third-party API calls from clientNot idealPreferred
Webhook endpointsCannot useRequired
Public API for mobile appsCannot useRequired
Security considerations:
  • Server Actions are effectively public API endpoints — always validate input and authenticate the user
  • Never trust form data; use Zod or similar validation
  • Use cookies() or headers() to verify authentication inside the action
What interviewers are really testing: Whether you understand the paradigm shift from “frontend calls API” to “frontend calls server function,” and the security implications.Red flag answer: “Server Actions let you write backend code in frontend files.” This misses that they are compiled into separate server endpoints and have specific security requirements.Follow-up:
  1. How do Server Actions handle optimistic updates in the UI? How does useOptimistic work with them?
  2. What happens if a Server Action throws an error? How does Next.js handle it on the client?
  3. Are Server Actions safe from CSRF attacks? How does Next.js protect them?
Next.js has four distinct caching layers, and understanding them is critical for building performant applications. Misunderstanding caching is the #1 source of “my data is stale” bugs in Next.js apps.The four caching layers:
CacheWhat it cachesWhereDurationOpt-out
Request MemoizationDuplicate fetch() calls in the same renderServerSingle render passCannot opt out
Data Cachefetch() responsesServerPersistent (until revalidation)cache: "no-store"
Full Route CacheRendered HTML and RSC payloadServerPersistent (until revalidation)Dynamic functions or cache: "no-store"
Router CacheRSC payload for visited routesClientSession-based (30s for dynamic, 5min for static)router.refresh()
Common mistakes and fixes:
// Data is cached indefinitely by default (Data Cache)
const data = await fetch("https://api.example.com/data");

// Force fresh data every request
const data = await fetch("https://api.example.com/data", {
  cache: "no-store"
});

// Revalidate every 60 seconds (ISR pattern)
const data = await fetch("https://api.example.com/data", {
  next: { revalidate: 60 }
});
The debugging nightmare: If you update data via a Server Action but the page still shows old data, the problem is usually the Router Cache on the client. Call revalidatePath("/path") or revalidateTag("tag") in your Server Action.What interviewers are really testing: Whether you have actually built Next.js apps in production and debugged caching issues, or just know the theory.Red flag answer: “Next.js caches pages for performance.” This is too vague — not specifying which cache layer or how to invalidate reveals no hands-on experience.Follow-up:
  1. You updated a database record via a Server Action, but the page still shows old data. Walk through every caching layer and how you would debug this.
  2. What is the difference between revalidatePath and revalidateTag? When would you use each?
  3. How does the Router Cache interact with browser back/forward navigation?

7. React State Management and Advanced Patterns

Code splitting is the practice of breaking your JavaScript bundle into smaller chunks that are loaded on demand, rather than shipping one massive file on initial page load. This directly improves Time to Interactive (TTI) and Largest Contentful Paint (LCP) — two Core Web Vitals that Google uses for ranking.How it works in React:
// Without code splitting: Dashboard is included in the main bundle
import Dashboard from './Dashboard';

// With code splitting: Dashboard is loaded only when rendered
const Dashboard = React.lazy(() => import('./Dashboard'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Dashboard />
    </Suspense>
  );
}
Route-based splitting (the most common pattern): Split by route so users only download the code for the page they visit. In Next.js, this is automatic — each page is a separate chunk.Real impact: A typical React SPA might have a 2MB bundle. Code splitting can reduce the initial load to 200-400KB, with the rest loaded on demand. At scale, this translates to measurable improvements in conversion rates — Amazon found that every 100ms of added latency cost them 1% of sales.What interviewers are really testing: Whether you understand when to split, not just how. Over-splitting creates too many network requests; under-splitting bloats the initial bundle.Red flag answer: Mentioning React.lazy without discussing when and where to split, or not mentioning Suspense fallbacks.Follow-up:
  1. How do you analyze your bundle to decide where to split? What tools do you use?
  2. What happens if a lazy-loaded chunk fails to load (network error)? How do you handle it gracefully?
  3. What is the difference between code splitting and tree shaking?
Error boundaries are React components that catch JavaScript errors in their child component tree and display a fallback UI instead of crashing the entire application. They are React’s version of try/catch for the component tree.Critical details:
  • Error boundaries must be class components — there is no hook equivalent (as of React 19). The react-error-boundary library provides a functional wrapper
  • They catch errors during rendering, lifecycle methods, and constructors of the tree below them
  • They do NOT catch: event handler errors, async errors (setTimeout, Promises), server-side rendering errors, or errors in the error boundary itself
Production pattern — granular error boundaries:
// Wrap individual features, not just the entire app
<ErrorBoundary fallback={<p>Widget failed to load</p>}>
  <StockTicker />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Chat unavailable</p>}>
  <ChatWidget />
</ErrorBoundary>
This way, if the StockTicker crashes, the ChatWidget still works. Compare this with a single root error boundary that shows “Something went wrong” for the entire page.What interviewers are really testing: Whether you use error boundaries strategically (per-feature) or naively (one at the root), and whether you know their limitations.Red flag answer: “Error boundaries catch all errors.” They do not catch event handler errors or async errors — these need try/catch.Follow-up:
  1. How do you log errors caught by error boundaries to a monitoring service like Sentry?
  2. Why can error boundaries only be class components? Will this change?
  3. How do you implement a “retry” button in an error boundary to attempt re-rendering the failed component?
Portals let you render a React component’s output into a different DOM node than its parent, while keeping it in the same React component tree. This means events still bubble up through the React tree, not the DOM tree.The classic use case:
function Modal({ isOpen, children }) {
  if (!isOpen) return null;
  return ReactDOM.createPortal(
    <div className="modal-overlay">
      <div className="modal-content">{children}</div>
    </div>,
    document.getElementById("modal-root") // renders outside #app-root
  );
}
Why portals exist: Modals, tooltips, and dropdowns need to visually “escape” their parent container (which might have overflow: hidden or z-index stacking context issues). Without portals, a modal inside a overflow: hidden container would be clipped.The event bubbling gotcha: Even though the modal renders in #modal-root in the DOM, a click inside it will still trigger onClick handlers on its React parent. This is because React’s synthetic event system follows the React tree, not the DOM tree. This is useful (parent can handle modal events) but can be surprising.What interviewers are really testing: Whether you understand the DOM vs React tree distinction and can explain why portals maintain React event bubbling.Red flag answer: “Portals render elements outside the parent div.” This misses the key insight about event propagation.Follow-up:
  1. How does event bubbling work with portals? If a portal renders into document.body, does clicking inside it bubble to the React parent?
  2. How would you manage focus trapping inside a portal-based modal for accessibility?
  3. When would you use a portal vs just using CSS position: fixed and high z-index?
React Router is the de facto client-side routing library for React SPAs. It intercepts browser navigation events and renders different components based on the URL path — without a full page reload.How it works under the hood: React Router uses the browser’s History API (pushState, replaceState, popstate event) to update the URL and trigger component re-renders. The URL becomes the “state” that determines which component tree to render.Key concepts in React Router v6+:
  • <BrowserRouter> — wraps the app and provides routing context
  • <Routes> / <Route> — defines the route-to-component mapping
  • <Link> / <NavLink> — navigates without page reload
  • useNavigate() — programmatic navigation
  • useParams() — access route parameters
  • useSearchParams() — access and modify query strings
  • <Outlet> — renders child routes in nested layouts
Nested routes and layouts (the production pattern):
<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<Home />} />
    <Route path="users" element={<Users />}>
      <Route path=":id" element={<UserDetail />} />
    </Route>
  </Route>
</Routes>
What interviewers are really testing: Whether you understand nested routing, protected routes, and how routing interacts with code splitting.Red flag answer: Only showing basic <Route path="/" element={<Home />} /> without discussing nested routes, protected routes, or lazy loading route components.Follow-up:
  1. How do you implement protected routes that redirect unauthenticated users to login?
  2. What is the difference between BrowserRouter and HashRouter? When would you use each?
  3. How does React Router interact with React.lazy for route-based code splitting?
React performance optimization is about preventing unnecessary work — unnecessary re-renders, unnecessary re-computations, and unnecessary network transfers. The key principle: measure first, optimize second. Premature optimization is a real problem in React apps.The optimization hierarchy (in order of impact):
  1. Architecture-level (highest impact):
    • Keep state as local as possible — state in a top-level provider re-renders everything below it
    • Separate server state (React Query/SWR) from client state (Zustand/useState)
    • Use code splitting to reduce initial bundle size
  2. Component-level:
    • React.memo() — skip re-rendering when props have not changed. Only useful when re-rendering is actually expensive
    • Split large components — a component with both expensive and cheap parts should be split so the expensive part can be memoized independently
    • Use proper key props in lists — wrong keys cause unnecessary DOM destruction/recreation
  3. Computation-level:
    • useMemo() — cache expensive calculations. Do not use for cheap operations (the memoization overhead is not free)
    • useCallback() — stabilize function references passed to memoized children
  4. Rendering-level:
    • Virtualization (react-window) for lists with 100+ items
    • <Suspense> for streaming and lazy loading
    • useTransition / useDeferredValue (React 18) for non-urgent updates
The rule of thumb: If you cannot measure the performance problem with React DevTools Profiler, you probably do not need to optimize it.What interviewers are really testing: Whether you optimize strategically or reach for useMemo on every value. Over-memoization is a code smell.Red flag answer: “Use React.memo, useMemo, and useCallback everywhere.” This shows no understanding of when optimization helps vs when it adds unnecessary complexity.Follow-up:
  1. When does React.memo actually hurt performance instead of helping?
  2. How do you use the React DevTools Profiler to find components that re-render too often?
  3. Your React app takes 4 seconds to become interactive on mobile. Walk me through your optimization strategy.
LibraryPurposeBest For
ReduxCentralized predictable state management (global state)Large apps needing structured data flow
ZustandLightweight state management using hooksSimpler state sharing without boilerplate
React Query (TanStack Query)Server-state management (fetching, caching, syncing)Handling API data efficiently

Example: Redux

// store.ts
import { configureStore, createSlice } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",
  initialState: { count: 0 },
  reducers: { increment: (state) => { state.count++; } }
});

export const { increment } = counterSlice.actions;
export const store = configureStore({ reducer: counterSlice.reducer });

// Counter.tsx
"use client";
import { useDispatch, useSelector } from "react-redux";
import { increment } from "./store";

export default function Counter() {
  const dispatch = useDispatch();
  const count = useSelector((state) => state.count);
  return <button onClick={() => dispatch(increment())}>Count: {count}</button>;
}

Example: Zustand

// useCounterStore.ts
import { create } from "zustand";

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

// Component
"use client";
import { useCounterStore } from "./useCounterStore";

export default function Counter() {
  const { count, increment } = useCounterStore();
  return <button onClick={increment}>Zustand Count: {count}</button>;
}

Example: React Query

// app/page.tsx
"use client";
import { useQuery } from "@tanstack/react-query";

export default function Users() {
  const { data, isLoading } = useQuery({
    queryKey: ["users"],
    queryFn: () => fetch("https://jsonplaceholder.typicode.com/users").then(r => r.json()),
  });

  if (isLoading) return <p>Loading...</p>;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Summary:

  • Redux → predictable, structured, global state
  • Zustand → minimal, fast local/global store
  • React Query → asynchronous data fetching & caching
Server data is fetched and stored on load (SSR or ISR), while client data changes locally. To sync:
  1. Use React Query or SWR for automatic refetching
  2. Use mutations and invalidate queries after updates

Example with React Query:

"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function Todos() {
  const queryClient = useQueryClient();
  const { data: todos } = useQuery(["todos"], () =>
    fetch("/api/todos").then(r => r.json())
  );

  const addTodo = useMutation({
    mutationFn: (newTodo) =>
      fetch("/api/todos", {
        method: "POST",
        body: JSON.stringify(newTodo),
      }),
    onSuccess: () => queryClient.invalidateQueries(["todos"]),
  });

  return (
    <>
      <ul>{todos?.map(t => <li key={t.id}>{t.title}</li>)}</ul>
      <button onClick={() => addTodo.mutate({ title: "New Task" })}>
        Add Todo
      </button>
    </>
  );
}
Key concept: invalidateQueries ensures fresh data after mutation — syncing server + client.
useReducer is an alternative to useState for managing complex state logic.

Example:

"use client";
import { useReducer } from "react";

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case "increment": return { count: state.count + 1 };
    case "decrement": return { count: state.count - 1 };
    default: return state;
  }
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
}

When to use:

When state transitions are complex or multiple actions affect the same state.
  • Use API parameters like ?page=1&limit=10
  • Use React Query with getNextPageParam for infinite scroll

Example:

"use client";
import { useInfiniteQuery } from "@tanstack/react-query";

export default function InfiniteUsers() {
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ["users"],
    queryFn: ({ pageParam = 1 }) =>
      fetch(`https://api.example.com/users?page=${pageParam}`).then(r => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextPage ?? false,
  });

  return (
    <>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.users.map((user) => <p key={user.id}>{user.name}</p>)}
        </div>
      ))}
      {hasNextPage && <button onClick={() => fetchNextPage()}>Load more</button>}
    </>
  );
}
Benefit: Efficient, incremental loading instead of fetching the entire dataset.
  • Hydration = The process where React on the client attaches event handlers to the already rendered HTML from the server
  • Hydration mismatch happens when server-rendered HTML differs from client render output

Example (problematic):

// app/page.tsx
"use client";
export default function Page() {
  const now = new Date().toLocaleTimeString();
  return <p>{now}</p>;
}
Issue: Server renders one time, client renders another (time mismatch)

Fix:

"use client";
import { useEffect, useState } from "react";

export default function Page() {
  const [time, setTime] = useState("");
  useEffect(() => setTime(new Date().toLocaleTimeString()), []);
  return <p>{time}</p>;
}

Common causes of mismatch:

  • Conditional rendering differences between server/client
  • Using window, localStorage on the server
  • Non-deterministic values during SSR
Use React Suspense or manual conditional rendering.

Example (React Query):

"use client";
import { useQuery } from "@tanstack/react-query";

function Users() {
  const { data, error, isLoading } = useQuery({
    queryKey: ["users"],
    queryFn: () => fetch("/api/users").then(r => r.json()),
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
You can persist state using:
  • LocalStorage / SessionStorage
  • URL query params
  • Redux Persist or Zustand middleware

Example (Zustand persist):

import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useUserStore = create(
  persist(
    (set) => ({
      name: "",
      setName: (name) => set({ name }),
    }),
    { name: "user-storage" }
  )
);

Usage:

"use client";
import { useUserStore } from "./useUserStore";

export default function Profile() {
  const { name, setName } = useUserStore();
  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name}</p>
    </>
  );
}
You can use React’s async Server Components to fetch before rendering (SSR-like behavior).

Example:

// app/users/page.tsx
export default async function UsersPage() {
  const users = await fetch("https://api.example.com/users", {
    cache: "no-store", // SSR-like
  }).then(r => r.json());

  return (
    <div>
      {users.map(u => <p key={u.id}>{u.name}</p>)}
    </div>
  );
}

Options:

  • cache: "no-store" → always fresh (SSR)
  • next: { revalidate: 60 } → ISR caching
HOCs are functions that take a component and return a new component with added functionality. They’re used to share logic across multiple components (like authentication, logging, or analytics).

Example:

// withAuth.js
const withAuth = (WrappedComponent) => {
  return function AuthenticatedComponent(props) {
    const isAuthenticated = Boolean(localStorage.getItem("token"));
    if (!isAuthenticated) {
      return <p>Please login first</p>;
    }
    return <WrappedComponent {...props} />;
  };
};

// Usage
const Dashboard = () => <h2>Welcome to Dashboard</h2>;
export default withAuth(Dashboard);
Render Props is a technique where a component accepts a function as a prop and uses it to determine what to render. It helps in sharing logic between components without using HOCs.

Example:

// MouseTracker.js
function MouseTracker({ render }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {render(pos)}
    </div>
  );
}

// Usage
<MouseTracker render={({ x, y }) => <p>Mouse at {x}, {y}</p>} />
Compound components allow multiple components to work together, sharing implicit state via React Context.

Example:

const TabsContext = React.createContext();

function Tabs({ children }) {
  const [active, setActive] = React.useState(0);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      <div>{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div>{children}</div>;
}

function Tab({ index, children }) {
  const { active, setActive } = React.useContext(TabsContext);
  return (
    <button
      onClick={() => setActive(index)}
      style={{ fontWeight: active === index ? "bold" : "normal" }}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  const { active } = React.useContext(TabsContext);
  return <div>{children[active]}</div>;
}

// Usage
<Tabs>
  <TabList>
    <Tab index={0}>Profile</Tab>
    <Tab index={1}>Settings</Tab>
  </TabList>
  <TabPanels>
    <div>Profile content</div>
    <div>Settings content</div>
  </TabPanels>
</Tabs>
The Provider Pattern exposes shared data and behavior to components via React Context. It’s heavily used for themes, authentication, and state management.

Example:

const ThemeContext = React.createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState("light");
  const toggle = () => setTheme(t => (t === "light" ? "dark" : "light"));

  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Usage
function ThemeToggleButton() {
  const { theme, toggle } = React.useContext(ThemeContext);
  return <button onClick={toggle}>Theme: {theme}</button>;
}
Code-splitting helps load only the code needed for the current page, improving performance. React provides React.lazy() and Suspense for this.

Example:

const Dashboard = React.lazy(() => import("./Dashboard"));

function App() {
  return (
    <React.Suspense fallback={<p>Loading...</p>}>
      <Dashboard />
    </React.Suspense>
  );
}
In Next.js, code-splitting is automatic per page.
A common scalable structure:
src/
 ├── app/
 │    ├── (routes)
 │    ├── layout.tsx
 │    └── page.tsx
 ├── components/
 │    ├── ui/
 │    ├── forms/
 │    ├── layout/
 ├── hooks/
 ├── context/
 ├── lib/
 ├── services/
 ├── store/
 ├── styles/
 └── utils/
  • Feature-based organization helps scale
  • Shared state or UI logic goes to /context or /hooks
  • Pages are lazy-loaded in Next.js automatically
Error boundaries catch JavaScript errors in React components and show fallback UIs instead of breaking the app. They only work in class components.

Example:

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidCatch(error, info) {
    console.error("Error logged:", error, info);
  }
  render() {
    if (this.state.hasError) return <h2>Something went wrong!</h2>;
    return this.props.children;
  }
}

// Usage
<ErrorBoundary>
  <ComponentThatMayCrash />
</ErrorBoundary>
To prevent unnecessary re-renders:
  • Use React.memo() for pure functional components
  • Use useCallback and useMemo to memoize functions and computed values
  • Avoid creating new objects/functions in render unnecessarily

Example:

const Button = React.memo(({ onClick }) => {
  console.log("Rendered Button");
  return <button onClick={onClick}>Click me</button>;
});

function App() {
  const handleClick = React.useCallback(() => console.log("Clicked!"), []);
  return <Button onClick={handleClick} />;
}
AspectMonolithicModular (Micro-frontend)
StructureAll components tightly coupled and deployed togetherApp divided into independent modules (built & deployed separately)
Team SizeBetter for small teamsIdeal for large teams, multiple domains
DeploymentSingle deploymentIndependent deployments
Tech StackSingle technologyTech diversity (React + Vue + Angular)

When to use Modular:

  • Large teams, multiple domains
  • Independent deployments
  • Tech diversity requirements
Options:
  • Context API for light global state
  • Redux Toolkit / Zustand / Jotai for complex or shared logic
  • React Query for server state

Example with Zustand:

import { create } from "zustand";

const useStore = create((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
}));

function Counter() {
  const { count, increase } = useStore();
  return <button onClick={increase}>Count: {count}</button>;
}

8. React and Next JS testing

Testing ensures that UI and logic behave as expected after changes. It helps catch regressions early and builds confidence in refactoring.

Types of tests:

  • Unit tests → Test small pieces (functions, components)
  • Integration tests → Verify component interaction
  • E2E tests → Test entire user flows (via Playwright / Cypress)
  • Jest → Testing framework (built-in with Next.js)
  • React Testing Library (RTL) → DOM interaction & behavior testing
  • Playwright / Cypress → End-to-end browser testing

Example Component:

// Counter.js
export default function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

Test File:

// Counter.test.js
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./Counter";

test("increments counter when button clicked", () => {
  render(<Counter />);
  fireEvent.click(screen.getByText("Increment"));
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
Key Concept: RTL focuses on testing how users interact, not implementation details.
You can use Jest’s jest.fn() or libraries like MSW (Mock Service Worker).

Example using Jest mock:

// fetchUser.js
export const fetchUser = async () => {
  const res = await fetch("/api/user");
  return res.json();
};

// fetchUser.test.js
import { fetchUser } from "./fetchUser";

global.fetch = jest.fn(() =>
  Promise.resolve({ json: () => Promise.resolve({ name: "Shehroze" }) })
);

test("fetches user correctly", async () => {
  const user = await fetchUser();
  expect(user.name).toBe("Shehroze");
});
You can test server actions, API routes, or getServerSideProps using Jest’s mocks.

Example:

// app/api/hello/route.js
export async function GET() {
  return Response.json({ message: "Hello Next.js" });
}

// route.test.js
import { GET } from "./route";

test("returns hello message", async () => {
  const response = await GET();
  const data = await response.json();
  expect(data.message).toBe("Hello Next.js");
});
Snapshot tests ensure UI doesn’t change unexpectedly.

Example:

import { render } from "@testing-library/react";
import Button from "./Button";

test("renders correctly", () => {
  const { asFragment } = render(<Button label="Click me" />);
  expect(asFragment()).toMatchSnapshot();
});
If the UI changes, Jest will show a diff.
Wrap your component inside the provider in the test.

Example:

// ThemeContext.js
const ThemeContext = React.createContext();
export const ThemeProvider = ({ children }) => (
  <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
);
export default ThemeContext;

// ThemedText.js
import ThemeContext from "./ThemeContext";
export default function ThemedText() {
  const theme = React.useContext(ThemeContext);
  return <p>Theme: {theme}</p>;
}

// ThemedText.test.js
import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "./ThemeContext";
import ThemedText from "./ThemedText";

test("renders theme from context", () => {
  render(
    <ThemeProvider>
      <ThemedText />
    </ThemeProvider>
  );
  expect(screen.getByText("Theme: dark")).toBeInTheDocument();
});
  • Use React Developer Tools in browser
  • Add console.log() inside hooks or effects
  • Use VSCode breakpoints
  • Use why-did-you-render library to detect unnecessary re-renders
  • For Next.js, run next dev --inspect for Node debugging
Jest has built-in coverage reporting:
npm run test -- --coverage
It generates a report showing what percentage of lines, functions, and branches are tested.

Key metrics to aim for:

  • 80%+ line coverage
  • 70%+ branch coverage
ProblemPossible CauseSolution
Hydration ErrorMismatch between SSR and client renderingAvoid using browser-only APIs during SSR
API Route not workingWrong file structure or method nameEnsure correct /app/api/.../route.js naming
404 after deployStatic export paths missingCheck dynamic routes and revalidate configs
Performance lagOver-fetching or large bundleUse lazy loading, memoization, code-splitting

Example Component:

// UserProfile.js
export default function UserProfile() {
  const [user, setUser] = React.useState(null);
  React.useEffect(() => {
    fetch("/api/user")
      .then((res) => res.json())
      .then(setUser);
  }, []);
  if (!user) return <p>Loading...</p>;
  return <p>Hello, {user.name}</p>;
}

Test File:

// UserProfile.test.js
import { render, screen, waitFor } from "@testing-library/react";
import UserProfile from "./UserProfile";

global.fetch = jest.fn(() =>
  Promise.resolve({ json: () => Promise.resolve({ name: "Shehroze" }) })
);

test("renders user after fetch", async () => {
  render(<UserProfile />);
  await waitFor(() => expect(screen.getByText("Hello, Shehroze")).toBeInTheDocument());
});

9. Performance and Optimization React | Next JS

To prevent unnecessary re-renders in React:
  • Wrap pure components with React.memo
  • Use useCallback to memoize event handlers
  • Use useMemo for computed values
  • Split large components
  • Keep state as local as possible

Example:

const Child = React.memo(({ value }) => {
  console.log("Child rendered");
  return <p>{value}</p>;
});

function App() {
  const [count, setCount] = React.useState(0);
  const memoValue = React.useMemo(() => count * 2, [count]);
  return (
    <>
      <Child value={memoValue} />
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </>
  );
}
Use the built-in next/image component for automatic:
  • Lazy loading
  • Responsive sizes
  • Format optimization (WebP/AVIF)

Example:

import Image from "next/image";

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero Image"
      width={1200}
      height={600}
      priority
    />
  );
}

Tips:

  • Use priority for above-the-fold images
  • Use blurDataURL for placeholder effect
  • Use dynamic imports:
const Chart = dynamic(() => import("../components/Chart"), { ssr: false });
  • Analyze with:
npm run build && npx next analyze
  • Move heavy logic to server components (Next.js App Router)
Suspense lets you pause rendering while data is loading, improving perceived performance.

Example:

const User = React.lazy(() => import("./User"));

function App() {
  return (
    <React.Suspense fallback={<p>Loading user...</p>}>
      <User />
    </React.Suspense>
  );
}
  • Server Components: Run on the server; ideal for data fetching and heavy logic
  • Client Components: Run in the browser; used for interactivity

Example:

// server component (default)
export default async function Page() {
  const data = await fetch("https://api.example.com/posts").then(r => r.json());
  return <List posts={data} />;
}

// client component
"use client";
export function List({ posts }) {
  return posts.map(p => <p key={p.id}>{p.title}</p>);
}
  • Use revalidate for ISR (Incremental Static Regeneration)
  • Use fetch caching options (force-cache, no-store, revalidate)

Example:

export default async function Page() {
  const res = await fetch("https://api.example.com/data", { 
    next: { revalidate: 60 } 
  });
  const data = await res.json();
  return <div>{data.title}</div>;
}
  • React Profiler → Measures render time
  • Lighthouse → Measures Core Web Vitals
  • Next.js build analyzer → Bundle size breakdown
  • Chrome DevTools Performance tab → JS execution time
React rendering has:
  1. Render phase → Reconciliation (diffing virtual DOM)
  2. Commit phase → Apply changes to real DOM
Use React DevTools Profiler to visualize and measure these phases.
  • Use windowing (e.g. react-window or react-virtualized)
  • Use key properly
  • Paginate data

Example:

import { FixedSizeList as List } from "react-window";

const Row = ({ index, style }) => <div style={style}>Row {index}</div>;

<List height={400} width={300} itemCount={1000} itemSize={35}>
  {Row}
</List>;
  • Use generateMetadata() in App Router
  • Lazy load below-the-fold components
  • Preload fonts via next/font
  • Optimize routes with static rendering when possible
  • Use semantic HTML and proper heading structure
  • Implement structured data with JSON-LD
  • Ensure fast loading times with image optimization

10. Security and Best Practices in React | Next JS

React escapes all strings by default, preventing XSS unless you use dangerouslySetInnerHTML.

Safe Practice:

Never insert user input into HTML directly.
<p>{userInput}</p> // ✅ Safe
<div dangerouslySetInnerHTML={{ __html: userInput }} /> // ❌ Dangerous
If you must, sanitize it using DOMPurify:
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />;
  • Use authentication middleware
  • Validate incoming data with Zod or Yup
  • Restrict methods (GET, POST, etc.)
  • Avoid exposing sensitive env vars to the client

Example:

// app/api/user/route.js
import { auth } from "@/lib/auth";
import { z } from "zod";

const schema = z.object({ name: z.string() });

export async function POST(req) {
  const user = await auth(req);
  if (!user) return new Response("Unauthorized", { status: 401 });

  const body = await req.json();
  schema.parse(body);
  // safe to use body.name
}
  • Use .env.local for local secrets
  • Access via process.env.SECRET_KEY on server
  • Never expose private keys using NEXT_PUBLIC_ prefix unless intentional
Environment variables without NEXT_PUBLIC_ prefix are only available on the server side.
  • Use tokens or double-submit cookies
  • For sensitive actions, validate Origin and Referer headers
  • Use libraries like NextAuth.js (handles CSRF internally)
  • Implement SameSite cookies
cookies.set("token", jwt, { 
  httpOnly: true, 
  secure: true, 
  sameSite: "strict" 
});
  • Use HTTP-only cookies for tokens
  • Avoid storing JWT in localStorage (can be stolen via XSS)
  • Implement proper session management
  • Use secure and sameSite flags for cookies
cookies.set("token", jwt, { 
  httpOnly: true, 
  secure: true, 
  sameSite: "strict" 
});
HTTP-only cookies prevent JavaScript access, reducing XSS attack impact.
A CSP header restricts sources for scripts, styles, images, etc., reducing XSS risk.

Example:

// next.config.js
async headers() {
  return [
    {
      source: "/(.*)",
      headers: [
        {
          key: "Content-Security-Policy",
          value: "default-src 'self'; img-src https: data:; script-src 'self';",
        },
      ],
    },
  ];
}
CSP helps prevent XSS attacks by whitelisting trusted sources for content loading.
Use middleware like Upstash, Redis, or rate-limiter-flexible.

Example (middleware):

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.fixedWindow(10, "60 s"),
});

export async function middleware(req) {
  const ip = req.ip ?? "127.0.0.1";
  const { success } = await ratelimit.limit(ip);
  if (!success) return new Response("Too many requests", { status: 429 });
  return NextResponse.next();
}
Never log JWTs, passwords, or full API responses. Mask sensitive data before logging.

Example:

// ❌ Don't do this
console.log({ email, password: userPassword });

// ✅ Do this instead
console.log({ email, password: "***" });

// For debugging, use redaction
const safeLog = (data) => {
  const { password, token, ...safeData } = data;
  console.log({ ...safeData, sensitive: "REDACTED" });
};
Always redact sensitive information before logging to prevent data leaks.
  • Always whitelist origins
  • Avoid using * in production
  • Use middleware to configure headers

Example:

export const config = { api: { bodyParser: false } };
export default function handler(req, res) {
  res.setHeader("Access-Control-Allow-Origin", "https://trusted.com");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
Never use Access-Control-Allow-Origin: * in production for sensitive endpoints.

Security Checklist:

  • ✅ Use HTTPS
  • ✅ Set NODE_ENV=production
  • ✅ Use environment variables, not hardcoded secrets
  • ✅ Keep dependencies updated (npm audit)
  • ✅ Use a WAF (Web Application Firewall) if available
  • ✅ Implement proper CORS policies
  • ✅ Use security headers (CSP, HSTS, X-Frame-Options)
  • ✅ Regular security scanning and penetration testing
  • ✅ Monitor for suspicious activities
  • ✅ Implement proper error handling (don’t leak stack traces)
Regular security audits and dependency updates are crucial for maintaining application security.
Environment variables starting with NEXT_PUBLIC_ are exposed to the client. Sensitive keys (like API secrets or DB credentials) must not use NEXT_PUBLIC_ and should only be accessed on the server (API routes, getServerSideProps, or server components).

Example:

# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com
SECRET_API_KEY=my_super_secret_key
// app/api/data/route.js
export async function GET() {
  const res = await fetch("https://secureapi.com", {
    headers: { Authorization: `Bearer ${process.env.SECRET_API_KEY}` },
  });
  const data = await res.json();
  return Response.json(data);
}
Secret key never goes to the browser when not using NEXT_PUBLIC_ prefix.

1️⃣ XSS (Cross-Site Scripting):

Occurs when malicious scripts are injected into the DOM.✅ Fix:
  • Avoid dangerouslySetInnerHTML
  • Sanitize input using libraries like DOMPurify
import DOMPurify from "dompurify";

function SafeHtml({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

2️⃣ CSRF (Cross-Site Request Forgery):

Attackers trick users into making unwanted requests.✅ Fix:
  • Use anti-CSRF tokens (NextAuth and other libs handle this)
  • Validate origin headers in API routes

3️⃣ Data Leakage via Source Code:

Leaking private tokens in frontend code.✅ Fix:
  • Never expose secrets in NEXT_PUBLIC_ vars
  • Validate .env usage during CI/CD

4️⃣ Insecure Direct Object Reference (IDOR):

Users access others’ data by manipulating IDs.✅ Fix:
  • Always verify authorization on the server
  • Never trust frontend route params

Approach 1: Using NextAuth.js

// app/api/auth/[...nextauth]/route.js
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        const res = await fetch("https://api.example.com/login", {
          method: "POST",
          body: JSON.stringify(credentials),
        });
        const user = await res.json();
        if (user?.token) return user;
        return null;
      },
    }),
  ],
  session: { strategy: "jwt" },
});

export { handler as GET, handler as POST };
Uses server-side token validation. Sessions stored as HTTP-only cookies (protects from XSS).
  • HttpOnly flag prevents JavaScript from reading cookies
  • Reduces XSS impact since scripts can’t access sensitive tokens

Example:

// Setting cookie securely
cookies().set("token", jwt, { 
  httpOnly: true, 
  secure: true, 
  sameSite: "Strict" 
});
Prevents token theft from browser scripts by making cookies inaccessible to JavaScript.
CORS controls which domains can make API requests.

Example:

// app/api/route.js
export async function GET(req) {
  return new Response("ok", {
    headers: {
      "Access-Control-Allow-Origin": "https://yourdomain.com",
      "Access-Control-Allow-Credentials": "true",
    },
  });
}
Never use ”*” for production as it allows any domain to access your API.
Attackers could redirect users to malicious sites.✅ Fix: Validate redirect URLs before allowing them:
export async function POST(req) {
  const { redirectUrl } = await req.json();
  const safeDomains = ["example.com", "app.example.com"];
  const url = new URL(redirectUrl);

  if (!safeDomains.includes(url.hostname))
    return new Response("Invalid redirect", { status: 400 });

  return Response.redirect(redirectUrl);
}

Example:

import { cookies } from "next/headers";

export async function GET() {
  const token = cookies().get("token");
  if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 });
  
  // verify JWT, then fetch data
  const user = await verifyToken(token.value);
  return Response.json({ data: user });
}
Authentication handled on the server, not the client.
CSP prevents malicious scripts or resources from loading.

Example:

// next.config.js
const securityHeaders = [
  {
    key: "Content-Security-Policy",
    value: "default-src 'self'; img-src *; script-src 'self'; style-src 'self';",
  },
];

module.exports = {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};
Helps prevent XSS and data injection attacks by restricting content sources.

Example:

import jwt from "jsonwebtoken";
import { cookies } from "next/headers";

export async function GET() {
  const token = cookies().get("token")?.value;
  if (!token) return Response.json({ message: "No token" }, { status: 401 });

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    return Response.json({ user: decoded });
  } catch {
    return Response.json({ message: "Invalid token" }, { status: 403 });
  }
}
Validates access at runtime using JWT verification.

Example:

"use server";
import { cookies } from "next/headers";

export async function deleteUserAccount() {
  const token = cookies().get("token");
  if (!token) throw new Error("Unauthorized");

  // call backend securely
  await deleteAccountFromDatabase();
}
Server actions run on the server only, so secrets remain safe.
CategoryBest Practice
🔐 AuthenticationUse JWT or NextAuth with HttpOnly cookies
🔑 SecretsStore in .env.local, never in client
🧱 XSSSanitize HTML, avoid dangerouslySetInnerHTML
🔄 CSRFUse anti-CSRF tokens or same-site cookies
🧭 RoutingValidate redirect URLs
🧰 PackagesAudit dependencies with npm audit
⚙️ HeadersSet CSP, X-Frame-Options, Strict-Transport-Security
📦 APIRate limit requests and validate input
📡 HTTPSAlways use SSL in production
Regular security audits and dependency updates are essential for maintaining application security.

11. Node.js Advanced Topics

Node.js is a JavaScript runtime built on Chrome’s V8 engine that allows JavaScript to run outside the browser — primarily on servers. But “JavaScript on the server” undersells it. The real innovation is Node’s event-driven, non-blocking I/O model which makes it exceptionally good at handling many concurrent connections with minimal resources.Why Node.js is architecturally different from traditional servers:
  • Traditional servers (Apache, Tomcat): One thread per connection. 10,000 concurrent users = 10,000 threads. Each thread consumes ~2MB of RAM. That is 20GB just for threads
  • Node.js: Single thread + event loop + non-blocking I/O. 10,000 concurrent users share one thread. The event loop delegates I/O operations to the OS kernel or libuv’s thread pool, so the main thread is never blocked waiting for disk/network
Where Node.js excels:
  • I/O-bound applications — APIs, real-time apps (chat, gaming), microservices, streaming
  • Not ideal for CPU-bound tasks (image processing, video encoding, heavy computation) because they block the single thread. For these, use Worker Threads or offload to a different service
The V8 engine connection: V8 compiles JavaScript to machine code using JIT (Just-In-Time) compilation, making Node.js surprisingly fast for a dynamic language — within 2-5x of C++ for many workloads.What interviewers are really testing: Whether you understand the event-driven architecture that makes Node unique, not just that it “runs JS on the server.”Red flag answer: “Node.js is a server-side JavaScript framework.” It is a runtime (not a framework), and the important part is how it handles concurrency.Follow-up:
  1. Why is Node.js not recommended for CPU-intensive tasks? What happens to other requests when a CPU-heavy operation is running?
  2. How does Node.js handle 10,000 concurrent connections with a single thread?
  3. What is the difference between Node.js and Deno? Why did Ryan Dahl create Deno?
Node.js is single-threaded, but it handles concurrency using the event loop, which is part of the libuv library. The event loop allows Node.js to perform non-blocking I/O operations, even though JavaScript runs on a single thread.

Event Loop Phases (TCCPPI)

  1. Timers – Executes callbacks from setTimeout() and setInterval()
  2. Pending Callbacks – Executes I/O callbacks deferred from the previous cycle
  3. Idle / Prepare – Internal use
  4. Poll – Retrieves new I/O events; executes their callbacks
  5. Check – Executes callbacks from setImmediate()
  6. Close Callbacks – Executes callbacks like socket.on('close')

Example

setTimeout(() => console.log("Timeout"), 0);
setImmediate(() => console.log("Immediate"));
process.nextTick(() => console.log("NextTick"));
console.log("Sync");
Output:
Sync
NextTick
Timeout
Immediate

Why this order?

  • process.nextTick() runs before the event loop continues
  • Timers (setTimeout) are queued for the Timers phase
  • setImmediate() runs during the Check phase

When to use:

  • Use process.nextTick() for micro-tasks after the current operation
  • Use setImmediate() for tasks after I/O events are processed
What interviewers are really testing: Whether you can trace the execution order of mixed sync/async code and explain why each runs when it does. This is the single most-asked Node.js interview question.Red flag answer: “The event loop handles async code.” Too vague. A strong candidate names the phases, explains microtask vs macrotask queues, and can predict output order for mixed code.Follow-up:
  1. Where do Promises fit in the event loop? Are they part of the microtask queue or the macrotask queue?
  2. What happens if you call process.nextTick() recursively? How does it differ from recursive setImmediate()?
  3. Your API has an endpoint that takes 2 seconds to respond. 100 other requests are waiting. Is the event loop blocked? How can you tell?
Both schedule asynchronous callbacks, but they run at different times in the event loop.
  • process.nextTick() executes before the event loop continues — a micro-task
  • setImmediate() executes after the current poll phase — a macro-task

Example:

console.log('Start');
process.nextTick(() => console.log('Next Tick'));
setImmediate(() => console.log('Immediate'));
console.log('End');
Output:
Start
End
Next Tick
Immediate

When to use:

  • Use process.nextTick() for quick callbacks that must run before I/O
  • Use setImmediate() when you want to yield to I/O first to prevent blocking
Node.js uses libuv, a C library providing an event-driven, non-blocking I/O model. I/O operations (file read, network calls, etc.) are delegated to the OS kernel or libuv’s thread pool, so Node’s main thread stays free to handle other tasks.

Example:

const fs = require('fs');

console.log('Start');
fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log('File read complete');
});
console.log('End');
Output:
Start
End
File read complete

Why:

The file read happens asynchronously; Node doesn’t block waiting for it.

Where/When:

Used in high-throughput systems — e.g., API gateways, chat apps — where many concurrent requests are served without thread blocking.
Streams are continuous data flows — they let you process data chunk by chunk instead of loading it all into memory. They’re instances of the EventEmitter class.

Types of Streams:

  1. Readable – e.g. fs.createReadStream()
  2. Writable – e.g. fs.createWriteStream()
  3. Duplex – both readable and writable (e.g. TCP socket)
  4. Transform – modifies data while reading/writing (e.g. zlib compression)

Example:

const fs = require('fs');
const read = fs.createReadStream('input.txt');
const write = fs.createWriteStream('output.txt');

read.pipe(write);

Why:

  • Efficient for large files or live data (video, logs)
  • Uses constant memory regardless of file size — a 10GB file uses the same ~64KB buffer whether you stream it or not

Where:

  • File uploads/downloads (S3 streaming)
  • Real-time data transfer (video/audio streaming)
  • Log streaming and processing pipelines
  • HTTP response streaming (server-sent events)
Real-world example — streaming a CSV export:
app.get('/export', (req, res) => {
  res.setHeader('Content-Type', 'text/csv');
  res.setHeader('Content-Disposition', 'attachment; filename="users.csv"');

  const cursor = User.find().cursor(); // MongoDB cursor is a Readable stream
  cursor.pipe(new CSVTransform()).pipe(res);
  // Memory usage stays constant even for 1M records
});
The backpressure concept: If the writable stream (e.g., network socket) is slower than the readable stream (e.g., file disk), Node.js automatically pauses the readable stream to prevent memory overflow. This is called backpressure and is handled by .pipe() automatically. When you use manual write() calls, you must handle backpressure yourself by checking the return value and listening for the drain event.What interviewers are really testing: Whether you understand memory-efficient data processing and can explain backpressure. This separates Node.js beginners from intermediate+ developers.Red flag answer: “Streams read files in chunks.” This misses backpressure, piping, and the four stream types.Follow-up:
  1. What is backpressure in streams, and what happens if you ignore it?
  2. How would you implement a Transform stream that gzips data on the fly?
  3. When would you use pipeline() from the stream module instead of .pipe()?
MethodDescriptionUse Case
spawnLaunches a new processFor long-running or streaming output
execLaunches a process and buffers entire outputFor small output commands
forkSpecial case of spawn that runs a Node.js script with IPCFor creating worker processes

Example:

const { spawn, exec, fork } = require('child_process');

// spawn example
const ls = spawn('ls', ['-lh', '/usr']);
ls.stdout.on('data', data => console.log(`Spawn output: ${data}`));

// exec example
exec('ls -lh /usr', (err, stdout) => console.log(`Exec output: ${stdout}`));

// fork example
const child = fork('./worker.js');
child.on('message', msg => console.log('Message from child:', msg));

Why/When/Where:

  • Use spawn for streaming output (e.g., logs)
  • Use exec when you need full command output as a string
  • Use fork for scaling CPU-bound tasks or worker threads
Worker Threads allow multi-threading in Node.js for CPU-intensive tasks, unlike the single-threaded event loop.

Example:

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', msg => console.log('Received:', msg));
} else {
  parentPort.postMessage('Hello from Worker');
}

Why:

To offload heavy computations (e.g. image processing, encryption) so they don’t block the main event loop.

Where:

In apps with mixed I/O and CPU workloads, like video encoding servers.
You should always handle unexpected errors globally, but never rely solely on them — they indicate bugs that should be fixed.

Example:

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  process.exit(1); // Restart service safely
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled Promise Rejection:', reason);
});

Why:

Prevents app from crashing unexpectedly and allows logging/restarting.

When/Where:

Use this for graceful shutdown and to catch programming mistakes in production.
Cluster mode allows you to spawn multiple Node.js processes (workers) to utilize multi-core CPUs. Each worker runs a separate instance of your app and shares the same server port via IPC managed by the cluster module.

Example:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isPrimary) {
  for (let i = 0; i < numCPUs; i++) cluster.fork();
  cluster.on('exit', (worker) => cluster.fork());
} else {
  http.createServer((req, res) => {
    res.end(`Handled by worker ${process.pid}`);
  }).listen(3000);
}

Why:

  • Single Node.js process uses only one CPU core
  • Cluster mode scales horizontally across all cores

Where:

Used in production APIs — e.g., Express servers, GraphQL APIs — to handle more traffic efficiently.
Node.js uses V8’s garbage collector, which manages heap memory automatically, but developers must avoid leaks.

Common causes of leaks:

  • Global variables
  • Unclosed timers or listeners
  • Caching large data objects indefinitely

Example Leak:

const cache = {};
setInterval(() => {
  cache[Math.random()] = new Array(1000000).join('x');
}, 1000);
Fix: Use WeakMap, clear intervals, or implement LRU caches.

When/Where:

Monitor memory with tools like:
  • --inspect + Chrome DevTools
  • clinic.js, heapdump, node --trace-gc
Buffers are binary data containers — used to handle raw data (files, streams, sockets) that can’t be represented as strings.

Example:

const buf = Buffer.from('Hello');
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(buf.toString()); // 'Hello'

Why:

Buffers allow manipulation of binary data efficiently (e.g., file systems, TCP streams).

Where:

Used in file operations, network protocols, and binary serialization.
Node.js uses CommonJS (require) and ES Modules (import) systems.

Example:

// math.js
module.exports.add = (a, b) => a + b;

// app.js
const { add } = require('./math');
console.log(add(2, 3));

Module Resolution Order:

  1. Core modules (fs, path)
  2. Local files (./, ../)
  3. node_modules directory

Why:

Encapsulation of code — prevents global scope pollution.

Where:

Used in all Node.js apps; ES Modules preferred for modern codebases.
Large Node.js applications need modular, layered architecture to keep code organized and maintainable.

Common Structure:

src/
 ┣ config/          # Environment variables, constants
 ┣ modules/
 ┃ ┣ user/
 ┃ ┃ ┣ user.controller.js
 ┃ ┃ ┣ user.service.js
 ┃ ┃ ┣ user.model.js
 ┃ ┃ ┣ user.routes.js
 ┣ middlewares/
 ┣ utils/
 ┣ app.js
 ┣ server.js

Layers Explained:

  • Controller: Handles request/response (HTTP logic)
  • Service: Business logic
  • Model: Database schema and queries
  • Middleware: Reusable pre-processing (auth, validation)
  • Routes: Maps endpoints to controllers

Why:

  • Improves separation of concerns
  • Enables unit testing per layer
  • Simplifies onboarding and scalability

Where/When:

Used in all enterprise-level Node.js APIs or microservices where team collaboration and modularization are essential.
Node.js heavily uses asynchronous and modular design patterns.

Key Patterns:

PatternDescriptionExampleUse Case
SingletonSingle shared instanceDB connection poolDatabase connections
FactoryCreates objects dynamicallyModel creationDynamic service instantiation
ObserverEvent-drivenEventEmitterReal-time systems
MiddlewareChain of functionsExpress middlewaresAPI requests
RepositoryAbstract data layerRepository classDecoupled DB logic
DecoratorAdds behavior without alteringWrapping servicesLogging, caching

Singleton Pattern Example:

class Database {
  constructor() {
    if (Database.instance) return Database.instance;
    this.connection = this.connect();
    Database.instance = this;
  }
  connect() {
    console.log("DB connected");
    return {};
  }
}
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true
Why: Ensures only one DB connection exists across the app
When: Use for connection pools, config objects, caches

Factory Pattern Example:

class Payment {
  process() {}
}

class Paypal extends Payment {
  process() { console.log("PayPal Payment"); }
}

class Stripe extends Payment {
  process() { console.log("Stripe Payment"); }
}

class PaymentFactory {
  static create(type) {
    if (type === "paypal") return new Paypal();
    if (type === "stripe") return new Stripe();
  }
}

const payment = PaymentFactory.create("paypal");
payment.process(); // PayPal Payment
Why: Avoids tight coupling between code and object types
When: Use for service selection (e.g., multiple gateways, APIs)
MVC separates your application into:
  • Model: Manages data and database operations
  • View: Renders UI (in REST APIs, often JSON)
  • Controller: Handles requests, uses Model to get data, and sends responses

Example:

// user.model.js
export const User = mongoose.model('User', new Schema({ name: String }));

// user.controller.js
import { User } from './user.model.js';
export const getUsers = async (req, res) => {
  const users = await User.find();
  res.json(users);
};

// user.routes.js
router.get('/users', getUsers);

Why:

Encourages separation of concerns, clean testing, and reusability.

Where/When:

Common in Express-based web APIs and server-rendered apps.
Microservices = small, independent services communicating via APIs or message queues.

Key Components:

  • Each service has own database, own deployment
  • Communication via REST, gRPC, or message queues (e.g., RabbitMQ, Kafka)
  • Use API Gateway for centralized routing/auth

Example Setup:

user-service      → handles users
order-service     → handles orders
payment-service   → handles payments
api-gateway       → routes and aggregates requests

Example Communication:

// user-service calls order-service via REST
const axios = require('axios');
const orders = await axios.get(`http://order-service/orders?userId=${userId}`);

Why:

  • Independent scaling and deployment
  • Easier fault isolation
  • Better for large teams or multi-domain systems

When/Where:

Used in enterprise systems (e.g., eCommerce, SaaS) where modularity and scaling are critical.
Node.js microservices can communicate via:
TypeExampleUse Case
Synchronous (Request-Response)REST, gRPCReal-time data
Asynchronous (Event-driven)RabbitMQ, Kafka, Redis Pub/SubDecoupled systems

Asynchronous Messaging Example (RabbitMQ):

const amqp = require('amqplib');

(async () => {
  const conn = await amqp.connect('amqp://localhost');
  const channel = await conn.createChannel();
  await channel.assertQueue('orderQueue');
  channel.sendToQueue('orderQueue', Buffer.from('New order created'));
})();

Why:

Async messaging improves resilience and fault tolerance.

When/Where:

Use async communication when loose coupling is desired (e.g., sending notifications after order creation).
Repository Pattern abstracts database logic into a single layer, separating it from business logic.

Example:

// user.repository.js
export class UserRepository {
  async findByEmail(email) {
    return User.findOne({ email });
  }
}

// user.service.js
const repo = new UserRepository();
const user = await repo.findByEmail(req.body.email);

Why:

  • Makes business logic database-agnostic
  • Simplifies unit testing (mock repositories)
  • Promotes clean architecture

When/Where:

Used in large teams where database changes should not affect business logic.
Dependency Injection (DI) means passing dependencies (services, repositories, etc.) into classes/functions instead of creating them inside.

Example:

class EmailService {
  send(email, msg) { console.log(`Email sent to ${email}`); }
}

class UserController {
  constructor(emailService) {
    this.emailService = emailService;
  }
  register(user) {
    this.emailService.send(user.email, "Welcome!");
  }
}

const emailService = new EmailService();
const userController = new UserController(emailService);

Why:

  • Enables loose coupling and easier testing
  • Supports inversion of control

When/Where:

Used in testable architectures, e.g., NestJS framework (which has DI built-in).
Store configurations separately for each environment (dev, staging, prod).

Example Structure:

config/
 ┣ default.json
 ┣ development.json
 ┣ production.json
Use libraries like dotenv or config.

Example with dotenv:

require('dotenv').config();
console.log(process.env.DB_HOST);

Why:

  • Keeps secrets out of source code
  • Easier environment portability

Where/When:

In CI/CD pipelines and multi-environment deployments (e.g., AWS, Docker).
Layered architecture separates the backend into logical layers — each with its responsibility.

Typical Layers:

  1. Presentation (Controller) – Handles requests
  2. Business (Service) – Contains logic
  3. Data (Repository) – Handles persistence
  4. Integration (External APIs) – Handles external comms

Example Flow:

Controller → Service → Repository → Database

Why:

  • Makes the system modular, testable, and maintainable

Where:

Used in enterprise backends and API-first architectures.
Versioning allows you to introduce new features without breaking old clients.

Ways to Version:

  1. URL-based: /api/v1/users
  2. Header-based: Accept: application/vnd.api.v2+json
  3. Query-based: /users?version=2

Example:

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

Why:

Maintains backward compatibility and smooth client migration.

When/Where:

Use when rolling out breaking API changes.
CQRS separates read and write operations into different models/services.

Example:

// Command (write)
POST /orderscreates order in DB

// Query (read)
GET /orders/:idreads from optimized read model or cache

Why:

  • Improves scalability (writes and reads can scale independently)
  • Enables event sourcing and audit trails

Where/When:

Used in financial, e-commerce, and high-scale event systems.
Modules in Node.js are reusable blocks of code that encapsulate functionality. They help in organizing code into manageable, independent components.

Node.js supports three main types of modules:

  1. Core Modules (built-in) → e.g., fs, path, http
  2. Local Modules → custom files in your app
  3. Third-party Modules → installed from npm (e.g., express, lodash)

Example:

// math.js (Local module)
function add(a, b) {
  return a + b;
}
module.exports = { add };

// app.js
const { add } = require('./math');
console.log(add(5, 10));

Why:

To promote reusability and separation of concerns. Without modules, everything would live in one file, making maintenance and testing hard.

When:

Use modules when splitting logic — routes, services, utilities, models, etc.

Where:

Common in every medium to large-scale Node.js app, especially with MVC or layered architecture.
FeatureCommonJS (require)ES Modules (import)
LoadingSynchronousAsynchronous
Syntaxconst x = require('x')import x from 'x'
ScopeWrapped in functionStrict top-level
Default inNode.js before v14Modern Node.js (with “type”: “module”)

Example:

// CommonJS
const express = require('express');

// ES Module
import express from 'express';

Why:

ESM is the modern standard (tree-shaking, async loading, static analysis).

When:

  • Use CommonJS in legacy or mixed codebases
  • Use ESM for modern projects (especially with TypeScript or Next.js APIs)

Where:

Configured via package.json → "type": "module"
ConceptMVCLayered Architecture
PatternModel–View–ControllerRequest–Controller–Service–Repository
PurposeWeb apps with UIAPIs and backend systems
FocusSeparation of UI and logicLogical separation by responsibility

Example Layered Architecture Flow:

Request → Controller → Service → Repository → Database
// userController.js
const userService = require('../services/userService');
exports.getUser = async (req, res) => {
  const user = await userService.getById(req.params.id);
  res.json(user);
};

// userService.js
const userRepo = require('../repositories/userRepo');
exports.getById = async (id) => await userRepo.findById(id);

Why:

Each layer has a single responsibility — easy to test and change independently.

When:

For scalable APIs and microservices.

Where:

Used in enterprise Node.js apps (Express, NestJS, Fastify-based systems).
Using dotenv or process.env to manage secrets and configurations per environment (dev, staging, production).

Example:

# .env file
PORT=5000
DB_URI=mongodb://localhost:27017/mydb
// config.js
require('dotenv').config();
module.exports = {
  port: process.env.PORT,
  db: process.env.DB_URI,
};

Why:

Keeps sensitive data (like DB passwords, API keys) out of code.

When:

Always use environment variables for configuration.

Where:

  • process.env → globally accessible
  • Used in CI/CD pipelines, Docker, Kubernetes secrets, etc.
Dependency Injection (DI) is a design pattern where dependencies are injected into a module instead of being hardcoded inside it.

Example:

// Without DI:
const userRepo = require('./userRepo');
exports.getUser = () => userRepo.findAll();

// With DI:
module.exports = (userRepo) => ({
  getUser: () => userRepo.findAll(),
});

Why:

  • Makes testing easier (can inject mocks)
  • Improves flexibility and maintainability

When:

Used heavily in frameworks like NestJS or in test-driven architectures.

Where:

Service layers, repositories, utilities, or external integrations.
A circular dependency occurs when two modules depend on each other directly or indirectly.

Example:

// a.js
const b = require('./b');
module.exports = { name: 'A' };

// b.js
const a = require('./a');
module.exports = { name: 'B' };
This can cause incomplete exports or undefined values.

Solution:

  1. Refactor shared logic into a new module
  2. Use dependency injection
  3. Lazy-load the dependency inside a function
// a.js - Lazy loading solution
function getB() {
  const b = require('./b');
  return b.name;
}

Why:

To prevent runtime bugs due to incomplete module initialization.

When:

When modules reference each other’s exports.

Where:

Common in large codebases with intertwined controllers/services.
EventEmitter is the core pub/sub pattern built into Node.js. Almost everything in Node — HTTP servers, streams, file system watchers — inherits from EventEmitter. Understanding it is essential because it is the foundation of Node’s event-driven architecture.

Core API:

const EventEmitter = require('events');
const emitter = new EventEmitter();

// Subscribe to an event
emitter.on('orderPlaced', (order) => {
  console.log(`Processing order ${order.id}`);
});

// Emit an event
emitter.emit('orderPlaced', { id: 1, total: 99.99 });

Real-world production use case — decoupled business logic:

// orderService.js
class OrderService extends EventEmitter {
  async createOrder(data) {
    const order = await db.orders.create(data);
    this.emit('orderCreated', order);  // other services react
    return order;
  }
}

// In app setup:
const orderService = new OrderService();
orderService.on('orderCreated', (order) => emailService.sendConfirmation(order));
orderService.on('orderCreated', (order) => inventoryService.reduceStock(order));
orderService.on('orderCreated', (order) => analyticsService.track(order));
Key methods:
  • on(event, listener) — subscribe (alias: addListener)
  • once(event, listener) — subscribe for one event only
  • emit(event, ...args) — trigger all listeners for an event
  • removeListener(event, listener) — unsubscribe
  • setMaxListeners(n) — default is 10; exceeding it prints a memory leak warning
Production gotcha: If you add listeners in a loop or on every request without removing them, you will hit the “MaxListenersExceededWarning” — a classic memory leak pattern in Node.js.What interviewers are really testing: Whether you can use EventEmitter for architectural decoupling, not just event handling.Red flag answer: “EventEmitter is for custom events.” Too vague — misses that it is the backbone of Node’s I/O model and can be used for service decoupling.Follow-up:
  1. How do you prevent memory leaks caused by forgotten event listeners?
  2. What is the difference between on and once? When would you use once?
  3. How does EventEmitter relate to Node.js streams?
Graceful shutdown means finishing in-flight requests and cleaning up resources before the process exits, rather than abruptly killing connections. This is critical in production where a deploy or restart should not cause errors for active users.

Production-grade implementation:

const server = app.listen(3000);

function gracefulShutdown(signal) {
  console.log(`Received ${signal}. Starting graceful shutdown...`);

  // 1. Stop accepting new connections
  server.close(() => {
    console.log('HTTP server closed');

    // 2. Close database connections
    mongoose.connection.close(false, () => {
      console.log('MongoDB connection closed');

      // 3. Close Redis connections
      redisClient.quit(() => {
        console.log('Redis connection closed');
        process.exit(0);
      });
    });
  });

  // 4. Force kill after timeout (prevent hanging)
  setTimeout(() => {
    console.error('Forcefully shutting down');
    process.exit(1);
  }, 10000); // 10 second timeout
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Why this matters in production:
  • Kubernetes sends SIGTERM before killing a pod. Without graceful shutdown, active requests get 502 errors
  • PM2 sends SIGINT before restarting. Without graceful shutdown, database connections may not close properly, causing connection pool exhaustion
  • Load balancers need time to drain connections from the old instance before routing to the new one
The order matters:
  1. Stop accepting new connections (server.close())
  2. Finish in-flight requests (they complete normally)
  3. Close external connections (DB, Redis, message queues)
  4. Exit the process
What interviewers are really testing: Production maturity. This question separates developers who have deployed Node.js to production from those who only run node app.js locally.Red flag answer: “Just call process.exit(0).” This kills everything immediately, including in-flight requests and open database transactions.Follow-up:
  1. What signal does Kubernetes send before killing a pod, and how much time do you have to shut down?
  2. How do you handle long-running WebSocket connections during graceful shutdown?
  3. What happens if a database operation is mid-transaction when SIGTERM is received?
Split routes by feature or module to avoid one large routes file.

Example:

// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');

router.get('/', userController.getAllUsers);
router.post('/', userController.createUser);

module.exports = router;

// app.js
const express = require('express');
const app = express();
app.use('/users', require('./routes/userRoutes'));

Why:

Keeps routing organized and readable.

When:

As soon as routes exceed 4–5 endpoints.

Where:

Used in all Express/Fastify REST APIs.
FolderPurposeExample
configCentralized configurationsDB, environment setup
utilsReusable functionsformatters, loggers, date handlers
helpersRequest-specific helper logicvalidation, response shaping

Example:

// utils/logger.js
module.exports = (msg) => console.log(`[LOG]: ${msg}`);

Why:

Encourages DRY (Don’t Repeat Yourself) coding.

When:

Used in any mid-size project to centralize repetitive logic.

Where:

Globally accessible via imports across layers.
In microservices:
  • Each service owns a single business domain
  • Communicates via APIs, queues, or message brokers (e.g., RabbitMQ, Kafka)
  • Uses its own database

Example Architecture:

/user-service
/order-service
/notification-service
Each runs independently and communicates via REST or async queues.

Why:

To achieve scalability, fault isolation, and independent deployment.

When:

When the app grows large and needs distributed scaling.

Where:

Used in large enterprise systems (e.g., Uber, Netflix architectures).

12. Node.js Testing and Debugging

Testing ensures your application works as expected, remains stable during code changes, and helps catch bugs early before deployment.

Types of testing in Node.js:

  1. Unit Testing – Tests individual functions or modules
    • Tools: Jest, Mocha, Chai
    • Example: testing a utility function like calculateTax()
  2. Integration Testing – Tests multiple components working together
    • Example: testing API endpoints interacting with database and services
  3. End-to-End (E2E) Testing – Simulates real user scenarios
    • Tools: Supertest, Cypress
  4. Regression Testing – Ensures new code doesn’t break existing functionality
  5. Performance / Load Testing – Evaluates API performance under stress
    • Tools: Artillery, K6

Why:

Improves code reliability and confidence in deployment.

When:

After each module development or before merging into main branch.

Where:

Applied across APIs, database queries, and business logic layers.
Unit testing focuses on testing individual functions or components in isolation.

Why:

To verify that each unit of your code performs as intended without depending on external systems.

When:

During development or before integration.

Example using Jest:

// tax.js
function calculateTax(amount) {
  if (amount <= 0) return 0;
  return amount * 0.1;
}
module.exports = calculateTax;

// tax.test.js
const calculateTax = require('./tax');

test('should calculate 10% tax', () => {
  expect(calculateTax(100)).toBe(10);
});

test('should return 0 for invalid amount', () => {
  expect(calculateTax(-50)).toBe(0);
});

Where:

Stored inside a __tests__ folder or with .test.js extension inside the same module directory.
Use Supertest (with Jest or Mocha) to test your Express APIs.

Why:

It performs real HTTP requests to your routes and verifies the response.

Example:

const request = require('supertest');
const app = require('../app');

describe('GET /users', () => {
  it('should return all users', async () => {
    const res = await request(app).get('/users');
    expect(res.statusCode).toEqual(200);
    expect(Array.isArray(res.body)).toBe(true);
  });
});

When:

Run after every build or deploy via CI/CD pipelines.

Where:

Tests are usually stored in /tests/api or /__integration__/.
Use mocking to isolate your code from external APIs, databases, or modules.

Why:

To ensure tests run fast, deterministically, and don’t depend on network or environment.

When:

When your code calls APIs, databases, or third-party SDKs.

Example using Jest Mocks:

// userService.js
const axios = require('axios');
async function getUser(id) {
  const res = await axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);
  return res.data;
}
module.exports = getUser;

// userService.test.js
jest.mock('axios');
const axios = require('axios');
const getUser = require('./userService');

test('should return mocked user', async () => {
  axios.get.mockResolvedValue({ data: { id: 1, name: 'John' } });
  const user = await getUser(1);
  expect(user.name).toBe('John');
});

Where:

In any module that uses third-party dependencies like AWS SDK, Stripe, or Axios.
Debugging helps track down bugs, performance issues, and unexpected behaviors.

Common methods:

  1. Console logging: Quick and easy but not ideal for large projects.
    console.log('User:', user);
    
  2. Node Inspector / Chrome DevTools: Run app with:
    node --inspect app.js
    
    Then open chrome://inspect → Attach debugger → Add breakpoints.
  3. VS Code Debugger: Add a launch.json config:
    {
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/app.js"
    }
    
  4. PM2 Logs: For production debugging:
    pm2 logs
    

When:

During development or after reproducing a bug reported from QA.

Where:

You can debug application logic, event loops, or async operations.
Use structured error handling so the system remains stable even when exceptions occur.

Why:

Uncaught exceptions can crash the server.

Example (Async/Await):

app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) throw new Error('User not found');
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// global error handler
app.use((err, req, res, next) => {
  console.error(err.message);
  res.status(500).json({ error: err.message });
});

When:

Apply globally via middleware for every route.

Where:

At controller level or in a centralized error handler.
  • Node.js Inspector – built-in debugger for step-through debugging
  • Chrome DevTools – UI-based debugging
  • VS Code Debugger – integrated IDE tool
  • PM2 – process manager for logs and metrics
  • Clinic.js – performance profiler
  • Winston / Pino – structured logging libraries

Why:

They help in isolating performance bottlenecks, memory leaks, and runtime errors.

Where:

Use locally (VS Code/Chrome) or in production (PM2, Winston).
Test coverage measures how much of your code is executed during tests.

Tool: Jest provides built-in coverage reports.

jest --coverage
This outputs:
Statements   : 92%
Branches     : 85%
Functions    : 90%
Lines        : 93%

CI/CD Integration (GitHub Actions example):

name: Node.js CI
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm install
      - run: npm test -- --coverage

Why:

Automatically ensures all commits pass tests before merging.

When:

Triggered on every pull request or push to main branch.
Memory leaks occur when memory is allocated but never freed.

Detection Steps:

  1. Monitor heap usage:
    setInterval(() => console.log(process.memoryUsage()), 5000);
    
  2. Use Chrome DevTools:
    • Run node --inspect
    • Open Heap Snapshots → Compare over time
  3. Use Clinic.js or Memwatch-next

Common causes:

  • Global variables
  • Unclosed timers
  • Unreleased event listeners

Fix:

  • Use WeakMap for temporary references
  • Remove listeners:
    emitter.removeAllListeners();
    
  • Close database connections

13. Database Design

It depends on data structure, relationships, and query patterns.
SQL (Relational)NoSQL (Document/Key-Value)
Structured schema (tables, columns)Flexible schema (JSON, documents)
Strong relationships (JOINs)Denormalized, nested data
ACID transactionsEventual consistency
Example: PostgreSQL, MySQLExample: MongoDB, DynamoDB

Example Use Cases:

  • SQL: Financial systems, HR platforms (strong relationships)
  • NoSQL: E-commerce product catalogs, social feeds (flexible schema)

Why:

SQL enforces strict integrity, NoSQL provides scalability.

When:

Choose SQL when data relations are strong; NoSQL for fast-growing, schema-less data.

Where:

SQL → transactional layer; NoSQL → analytics or caching layer.
  • Normalization: Process of organizing data to reduce redundancy and improve consistency
  • Denormalization: Combining related data into a single structure to improve read performance

Example:

Normalized (two tables):
Users: (id, name)
Orders: (id, user_id, product)
Denormalized (single collection in MongoDB):
{
  "userId": 1,
  "name": "Ali",
  "orders": [
    { "product": "Laptop" },
    { "product": "Mouse" }
  ]
}

Why:

Normalization improves consistency; denormalization improves read speed.

When:

Normalize for frequent writes, denormalize for heavy reads.

Where:

E.g., OLTP → normalized; OLAP/NoSQL → denormalized.
An index is a data structure (like a B-tree or hash) that allows fast lookups on columns or fields.

Example (MongoDB & SQL):

// MongoDB
db.users.createIndex({ email: 1 });

// SQL
CREATE INDEX idx_email ON users(email);

Why:

It avoids full collection/table scans.

When:

Use on frequently filtered or sorted fields (like email, createdAt).

Where:

Use on read-heavy collections/tables.
Indexes slow down writes (inserts/updates) and increase memory usage. Avoid over-indexing — only index what you query often.
A transaction ensures a group of operations succeeds or fails as one unit (ACID — Atomicity, Consistency, Isolation, Durability).

Example (PostgreSQL with Sequelize):

const t = await sequelize.transaction();

try {
  await User.create({ name: 'Ali' }, { transaction: t });
  await Order.create({ userId: 1, product: 'Phone' }, { transaction: t });
  await t.commit();
} catch (err) {
  await t.rollback();
}

Why:

Prevents partial updates when one step fails.

When:

Use for multi-table or dependent operations (e.g., payments, inventory).

Where:

Implement in service layer functions handling multi-step DB operations.
Step-by-step approach:
  1. Use EXPLAIN or explain() to inspect query execution plan
  2. Add proper indexes
  3. Avoid SELECT *; specify columns
  4. Paginate large queries
  5. Cache repetitive queries

Example (MongoDB):

db.orders.find({ status: 'completed' }).explain('executionStats');

Example (Redis caching):

const cached = await redis.get('orders');
if (cached) return JSON.parse(cached);

const data = await Order.find({ status: 'completed' });
await redis.setEx('orders', 3600, JSON.stringify(data));

Why:

Optimized queries save cost and improve API response times.

When:

Apply during scaling or under heavy load.

Where:

Inside data-access layer (repositories).
Common types:
  • One-to-One: User ↔ Profile
  • One-to-Many: User → Orders
  • Many-to-Many: Students ↔ Courses

Example (MongoDB - embedding vs referencing):

// Referencing (normalized)
const order = { userId: ObjectId("..."), product: "Laptop" };

// Embedding (denormalized)
const user = { name: "Ali", orders: [{ product: "Laptop" }] };

Why:

Embedding improves read speed; referencing saves space.

When:

Embed for frequent reads; reference for frequent writes.

Where:

Schema design phase, based on access patterns.
Offset-based (SQL):
SELECT * FROM orders LIMIT 10 OFFSET 20;
Cursor-based (MongoDB or large datasets):
db.orders.find({ _id: { $gt: lastId } }).limit(10);

Why:

Offset is simple but inefficient for large data; cursor-based is faster.

When:

Cursor-based for infinite scrolling or APIs.

Where:

Implement in API layer — GET /orders?cursor=<id>
Migrations are version control for your database schema.

Example (with Sequelize):

npx sequelize migration:generate --name add_isActive_to_users

Example (migration file):

export async function up(queryInterface, Sequelize) {
  await queryInterface.addColumn('Users', 'isActive', Sequelize.BOOLEAN);
}

Why:

Ensures consistent schema across environments (dev, staging, prod).

When:

Whenever adding/removing/modifying columns or constraints.

Where:

Stored in /migrations folder, managed by ORM/CLI.
Techniques:
  1. Read replicas — offload read traffic
  2. Sharding — partition data by key (e.g., userId)
  3. Caching — Redis/Memcached for frequent reads
  4. Connection pooling — reuse DB connections

Example:

const pool = new Pool({
  max: 10, // limit active connections
  idleTimeoutMillis: 30000,
});

Why:

Prevents bottlenecks under high load.

When:

Beyond 10k+ users or concurrent reads.

Where:

Database + ORM configuration level.
Consistency ensures all services see the same data.

Patterns:

  • Two-phase commit (2PC) for strict consistency
  • Eventual consistency for scalability
  • Sagas pattern for distributed transactions

Example (Sagas):

// Place order -> reduce stock -> charge payment
// If payment fails -> rollback stock -> cancel order

Why:

Keeps data correct even across microservices.

When:

In event-driven or microservice architectures.

Where:

Implement at service orchestration level.
ACID (SQL)BASE (NoSQL)
AtomicityBasically Available
ConsistencySoft-state
IsolationEventual consistency
Durability

Why:

ACID ensures reliable transactions; BASE ensures availability at scale.

When:

Use ACID for critical systems (banking), BASE for high-volume systems (social media).

Where:

Choose based on business priority: consistency vs availability.
The N+1 query problem occurs when fetching a list of N items results in 1 query for the list + N additional queries for related data. It is the single most common performance issue in backend applications.

Example of the problem:

// 1 query: get all 100 users
const users = await User.find();

// N queries: get orders for EACH user (100 more queries!)
for (const user of users) {
  user.orders = await Order.find({ userId: user._id });
}
// Total: 101 queries for what should be 1-2 queries

Solutions:

1. Population/Join (MongoDB populate or SQL JOIN):
// MongoDB - 2 queries total (1 for users, 1 for all their orders)
const users = await User.find().populate('orders');

// SQL - 1 query with JOIN
SELECT users.*, orders.* FROM users LEFT JOIN orders ON users.id = orders.user_id;
2. Batch loading (DataLoader pattern from GraphQL):
// Instead of N individual queries, batch them:
const userIds = users.map(u => u._id);
const allOrders = await Order.find({ userId: { $in: userIds } });
// Then distribute orders to their users in-memory
3. Aggregation pipeline (MongoDB):
db.users.aggregate([
  { $lookup: { from: "orders", localField: "_id", foreignField: "userId", as: "orders" } }
]);
Real-world impact: A SaaS dashboard loading 50 customers with their orders, invoices, and activity logs could fire 200+ queries instead of 4. Response time goes from 50ms to 5 seconds.What interviewers are really testing: Whether you have profiled and optimized database queries in production.Red flag answer: Not knowing what N+1 is, or solving it with application-level caching instead of fixing the query pattern.Follow-up:
  1. How would you detect N+1 queries in a production Node.js application?
  2. Does MongoDB’s populate() actually solve N+1, or does it just hide it? How many queries does it actually execute?
  3. How does the DataLoader pattern batch and deduplicate queries in a GraphQL API?
Connection pooling maintains a pool of reusable database connections instead of creating a new connection for every request. Creating a database connection is expensive — it involves TCP handshake, authentication, and SSL negotiation (~20-100ms per connection).

Without pooling (bad):

// Every request creates and destroys a connection
app.get('/users', async (req, res) => {
  const client = await MongoClient.connect(uri); // 50ms overhead
  const users = await client.db().collection('users').find().toArray();
  await client.close(); // connection destroyed
  res.json(users);
});

With pooling (good):

// One pool shared across all requests
const client = new MongoClient(uri, {
  maxPoolSize: 50,      // max concurrent connections
  minPoolSize: 10,      // keep warm connections ready
  maxIdleTimeMS: 30000, // close idle connections after 30s
});

await client.connect(); // one-time setup

app.get('/users', async (req, res) => {
  // Reuses existing connection from pool (~0ms overhead)
  const users = await client.db().collection('users').find().toArray();
  res.json(users);
});
Pool sizing formula (production rule of thumb): For Node.js: poolSize = (number of CPU cores * 2) + number of disks
  • Too few connections: requests queue up waiting for a connection
  • Too many connections: database server is overwhelmed (MongoDB has a default 1024 connection limit)
  • A typical Node.js service with 4 cores should start with 10-20 connections
Why this is especially important in Node.js:
  • Node.js handles thousands of concurrent requests with one thread
  • Each concurrent request that needs a DB query needs a connection
  • Without pooling, 1000 concurrent requests create 1000 connections — most databases cannot handle this
What interviewers are really testing: Production database management experience. Connection pool exhaustion is one of the most common production incidents.Red flag answer: “I just connect to the database when the app starts.” This might work but shows no understanding of pool sizing, connection limits, or concurrent request handling.Follow-up:
  1. Your app starts throwing “connection pool exhausted” errors during peak traffic. How do you diagnose and fix it?
  2. How do you handle database connection pool in a serverless environment (Lambda, Vercel Functions)?
  3. What is the connection pool behavior difference between Mongoose and the native MongoDB driver?

14. API Design & Best Practices

REST (Representational State Transfer) is an architectural style for building scalable web services that communicate over HTTP.

Core Principles:

  1. Statelessness → Server doesn’t store client state between requests
  2. Uniform Interface → Consistent structure for all endpoints (/users, /products)
  3. Client-Server Separation → Independent evolution of frontend & backend
  4. Cacheable → Responses can be cached for performance
  5. Layered System → Requests pass through intermediaries like load balancers or proxies

Example:

// RESTful Routes
GET    /users          // Fetch users
POST   /users          // Create user
GET    /users/:id      // Get user by ID
PUT    /users/:id      // Update user
DELETE /users/:id      // Delete user

Why:

REST’s simplicity makes it ideal for large-scale distributed systems. The uniform interface means any developer can understand your API without reading implementation code.

When:

Use for stateless communication (e.g., SaaS apps, mobile backends, third-party integrations). REST is the default choice for CRUD-heavy APIs.

Where:

Commonly implemented with Express.js, NestJS, or Fastify. Consumed by React/Next.js frontends, mobile apps, and other services.Common REST anti-patterns to avoid:
  • Verb in the URL: /api/getUsers or /api/createUser — HTTP methods already convey the action
  • Inconsistent naming: /api/Users vs /api/user-profiles vs /api/orders_list — pick one convention (plural nouns, kebab-case is standard)
  • Returning 200 for everything: Even errors return 200 OK with { "error": true } in the body. Use proper HTTP status codes
  • No pagination: Returning all 50,000 records on a list endpoint
What interviewers are really testing: Whether you can design clean, predictable APIs and know the principles beyond just “GET/POST/PUT/DELETE.”Red flag answer: Defining REST as “an API that uses HTTP methods” without mentioning statelessness, resource-based URLs, or the uniform interface principle.Follow-up:
  1. What makes an API truly “RESTful” vs just “REST-like”? What is the Richardson Maturity Model?
  2. How do you handle actions that do not map cleanly to CRUD? For example, “send a password reset email.”
  3. What is HATEOAS and why is it rarely implemented in practice?
Follow best practices:

Use modular folder structure:

src/
  controllers/
  routes/
  services/
  models/
  middlewares/
  1. Follow Controller-Service-Repository pattern
  2. Use async/await and central error handling
  3. Add pagination, filtering, sorting

Example:

// Controller
export const getUsers = async (req, res, next) => {
  const { page = 1, limit = 10 } = req.query;
  const users = await userService.getAll({ page, limit });
  res.json(users);
};

Why:

Separation of concerns ensures scalability and testability.

When:

For medium to large projects.

Where:

Apply across all route modules for consistency.
MethodPurposeExample
PUTReplace the entire resource{ "name": "Ali", "email": "x@x.com" }
PATCHUpdate part of a resource{ "email": "new@x.com" }

Example:

app.patch('/users/:id', async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
  res.json(user);
});

Why:

Use PATCH for partial updates to avoid overwriting data.

When:

Frontend sends only changed fields.

Where:

In APIs supporting user profile updates, etc.
Centralized error handling ensures cleaner code and consistent responses.

Example:

// Middleware
function errorHandler(err, req, res, next) {
  res.status(err.status || 500).json({ message: err.message });
}

// Controller
if (!email) throw { status: 400, message: 'Email is required' };

// Validation (Zod / Joi / Express Validator):
import { z } from 'zod';
const userSchema = z.object({ email: z.string().email(), password: z.string().min(6) });
userSchema.parse(req.body);

Why:

Prevents invalid or unsafe input.

When:

Before DB operations or external API calls.

Where:

Middleware or controller level.
API versioning ensures backward compatibility when updating endpoints.

Methods:

  1. URL versioning (most common):
    /api/v1/users
    /api/v2/users
    
  2. Header versioning:
    Accept: application/vnd.myapp.v2+json
    

Example:

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

Why:

Prevents breaking changes for existing clients.

When:

On major updates or endpoint restructuring.

Where:

Route definition layer.
  1. Authentication & Authorization (JWT, OAuth2)
  2. Input validation (prevent SQL/NoSQL injection)
  3. Rate limiting (prevent brute-force)
  4. CORS control
  5. HTTPS for encryption

Example (rate limiting):

import rateLimit from 'express-rate-limit';
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));

Why:

Prevents abuse, leaks, and attacks.

When:

Always — security should be baked in early.

Where:

Applied globally or per route.
An idempotent method gives the same result even if called multiple times.
MethodIdempotent?Example
GET✅ YesFetching user
PUT✅ YesUpdating same data
DELETE✅ YesDeleting same resource again
POST❌ NoCreates a new record each time

Example:

DELETE /users/5
// Returns 204 No Content even if user was already deleted

Why:

Idempotency ensures reliability in retry scenarios.

When:

Especially in payment APIs or distributed systems.

Where:

Route and controller logic level.

Example:

GET /products?page=2&limit=10&sort=price:desc&category=shoes

Implementation:

const { page = 1, limit = 10, sort, category } = req.query;
const filter = category ? { category } : {};
const products = await Product.find(filter)
  .skip((page - 1) * limit)
  .limit(limit)
  .sort(sort.replace(':', ' '));

Why:

Enhances performance and user experience.

When:

For list-based data (users, products, posts).

Where:

In every list API endpoint.
Use OpenAPI/Swagger for auto-generated documentation.

Example:

npm install swagger-ui-express swagger-jsdoc

import swaggerUi from 'swagger-ui-express';
import swaggerJsDoc from 'swagger-jsdoc';

const specs = swaggerJsDoc({
  definition: { openapi: '3.0.0', info: { title: 'API Docs', version: '1.0.0' } },
  apis: ['./routes/*.js'],
});
app.use('/docs', swaggerUi.serve, swaggerUi.setup(specs));

Why:

Improves onboarding and collaboration with frontend teams.

When:

As soon as APIs are stable.

Where:

Separate /docs route.
Rate limiting: restricts number of requests per user/IP
Throttling: delays excessive requests

Example (Redis-based):

import rateLimit from 'express-rate-limit';
app.use('/api', rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: 'Too many requests, try again later.',
}));

Why:

Prevents DDoS and API abuse.

When:

For login or public APIs.

Where:

At gateway or middleware layer.
CodeMeaningUse Case
200OKSuccessful GET
201CreatedResource created (POST)
204No ContentSuccessful DELETE
400Bad RequestValidation error
401UnauthorizedMissing/invalid token
403ForbiddenAccess denied
404Not FoundResource doesn’t exist
500Internal Server ErrorUnexpected error

Example:

res.status(201).json({ message: 'User created successfully' });

Why:

Clear status codes improve debugging and API usability.

When:

Always respond with meaningful HTTP codes.

Where:

Controller layer.
RESTGraphQL
Multiple endpointsSingle endpoint /graphql
Fixed response shapeClient defines response
Over-fetching commonFetch only needed fields
Simpler cachingComplex but flexible queries

Example:

// GraphQL query
query {
  user(id: "1") {
    name
    posts {
      title
    }
  }
}

Why:

GraphQL avoids under/over-fetching and gives the frontend team independence from the backend team’s endpoint design. In a REST API, the frontend often needs to request 3-4 endpoints and stitch data together. GraphQL does this in one request.

When:

  • Choose GraphQL for: complex UIs with diverse data needs (dashboards, social feeds), mobile apps where bandwidth is precious, or when multiple frontend teams consume the same API with different data requirements
  • Choose REST for: simple CRUD APIs, public APIs (REST is universally understood), webhooks, file uploads, or when caching is critical (REST + CDN is simpler than GraphQL caching)

Where:

Use Apollo Server, GraphQL Yoga, or Mercurius (Fastify) with Node.js. On the client, Apollo Client or urql.The hidden cost of GraphQL:
  • Query complexity attacks: Without depth limiting, a client can send a deeply nested query that takes down your server. Use graphql-depth-limit and graphql-query-complexity
  • N+1 problem amplified: Resolvers execute per-field, so a list of 100 users with their orders fires 100 individual order queries. DataLoader is mandatory
  • Caching is harder: REST endpoints are easily cached by CDNs (same URL = same response). GraphQL POST requests all go to /graphql with different bodies, making CDN caching ineffective
  • Schema management overhead: Maintaining a GraphQL schema, resolvers, and type generation (codegen) is more work than REST endpoints
What interviewers are really testing: Whether you can make an informed technology choice based on project constraints, not just preference.Red flag answer: “GraphQL is better than REST because it avoids over-fetching.” This ignores caching, complexity, the N+1 problem, and the operational overhead of GraphQL.Follow-up:
  1. How do you prevent malicious GraphQL queries from overloading your server?
  2. What is the DataLoader pattern and why is it essential for GraphQL performance?
  3. Can you use GraphQL and REST in the same project? When would you want to?

15. Caching and Performance Optimization

Caching is the process of storing frequently accessed data in a fast-access storage layer (like memory) so that future requests for that data can be served faster.

Why:

  • Reduces response time
  • Minimizes database load
  • Improves scalability and user experience

When to use:

When you have repetitive, read-heavy operations such as:
  • Fetching static product details
  • Returning popular posts
  • Computing costly aggregations

Where:

Typically used:
  • Between the API and the database
  • At CDN level for static assets
  • In-memory (e.g., Redis, Node cache) for API data

Example:

const express = require('express');
const redis = require('redis');
const fetch = require('node-fetch');

const client = redis.createClient();
const app = express();

app.get('/posts', async (req, res) => {
  const cachedPosts = await client.get('posts');
  if (cachedPosts) return res.json(JSON.parse(cachedPosts));

  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const data = await response.json();

  client.setEx('posts', 3600, JSON.stringify(data)); // cache for 1 hour
  res.json(data);
});
The first request fetches data from the API, but subsequent ones serve from Redis memory, reducing external API calls.

1. In-memory cache:

  • Stored in Node process memory using packages like node-cache or lru-cache
  • Fastest but not shared across multiple server instances
  • Best for: Single-instance apps, computed results
const NodeCache = require("node-cache");
const myCache = new NodeCache({ stdTTL: 600 });
myCache.set("key", "value");
console.log(myCache.get("key")); // 'value'

2. Distributed cache (Redis / Memcached):

  • External in-memory databases shared across multiple app instances
  • Best for: Scalable and load-balanced applications

3. Browser caching / CDN caching:

  • For static files like images, scripts, and CSS
  • Best for: Reducing load time for front-end users

4. Application-level caching:

  • Using HTTP headers like Cache-Control, ETag, or response-level caching middleware
By profiling and monitoring the app’s runtime performance using tools such as:

Node’s built-in profiler:

node --inspect app.js
Opens Chrome DevTools for debugging and profiling.

Performance monitoring tools:

  • PM2 monitoring dashboard
  • New Relic, Datadog, or AppDynamics for production-level insights

Steps to identify bottlenecks:

  1. Measure response time and CPU usage
  2. Analyze event loop lag (using clinic.js or node —inspect)
  3. Check for slow database queries and missing indexes
  4. Profile memory leaks via heap snapshots

When:

Always perform during load testing before production deployment.

1. Use asynchronous code properly:

Avoid blocking the event loop by using non-blocking async operations.
// BAD: Blocking
const fs = require('fs');
const data = fs.readFileSync('file.txt');

// GOOD: Non-blocking
fs.readFile('file.txt', (err, data) => { ... });

2. Enable GZIP compression:

Compress HTTP responses using middleware like compression.
const compression = require('compression');
app.use(compression());

3. Use Redis or in-memory cache:

Cache frequent DB queries to reduce load.

4. Cluster your Node process:

Utilize multiple CPU cores using cluster or PM2.
const cluster = require('cluster');
const os = require('os');

if (cluster.isPrimary) {
  os.cpus().forEach(() => cluster.fork());
} else {
  require('./server');
}

5. Optimize database queries:

Use proper indexes, projections, and pagination.

6. Use streaming for large data:

Instead of loading entire data into memory.
const fs = require('fs');
fs.createReadStream('largeFile.txt').pipe(res);

7. Use load balancing & reverse proxies (Nginx):

Helps distribute load across multiple Node instances.
Redis is an in-memory data store that can be used for:
  • Caching responses
  • Session storage
  • Pub/Sub systems
  • Rate limiting

Why Redis:

  • Very fast (stores data in RAM)
  • Persistent (can save snapshots to disk)
  • Supports TTL (time-to-live) expiration

Example:

const redis = require('redis');
const client = redis.createClient();

client.connect();

async function getCachedUser(id) {
  const cachedUser = await client.get(`user:${id}`);
  if (cachedUser) return JSON.parse(cachedUser);

  const user = await getUserFromDatabase(id);
  await client.setEx(`user:${id}`, 600, JSON.stringify(user)); // cache 10min
  return user;
}

When:

When your system frequently requests the same data from a slow data source like MongoDB or an external API.
Node.js is single-threaded, so any CPU-intensive task can block the event loop and delay other requests.

Example of blocking:

// BAD
app.get('/heavy', (req, res) => {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) sum += i; // blocks everything
  res.send('Done');
});

Fix:

Use worker threads or child processes for CPU-heavy operations.
const { Worker } = require('worker_threads');
app.get('/heavy', (req, res) => {
  const worker = new Worker('./heavyTask.js');
  worker.on('message', msg => res.send(msg));
});
Rate limiting ensures a user doesn’t overwhelm the server with too many requests in a short period.

When to use:

For APIs prone to abuse — login, search, or payment endpoints.

Example using express-rate-limit:

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
});

app.use(limiter);
You can also store rate limit counters in Redis for distributed environments.
You can monitor heap usage using:
console.log(process.memoryUsage());
or by using Chrome DevTools:
  • Run app with node --inspect
  • Open chrome://inspect
  • Take Heap Snapshots and compare over time

When leaks occur:

  • Global variables not freed
  • Large cached data without TTL
  • Event listeners not removed

Where to fix:

Ensure proper cleanup using:
emitter.removeAllListeners();
cache.del('largeKey');

Advanced Scenario-Based Questions

These questions simulate real production incidents and architectural decisions that separate candidates who have actually built and operated MERN applications at scale from those who have only completed tutorials.
Scenario: Your e-commerce platform runs on MongoDB. The product catalog has 12 million documents. A developer added a new “search by tags + price range + rating” feature last week. Since launch, the /api/products/search endpoint has degraded from 80ms to 6.2 seconds at p95. The collection has individual indexes on tags, price, and rating. CPU on your Atlas M40 cluster is pegged at 92%. The PM is asking why search is broken and wants it fixed by EOD. Walk me through your diagnosis and fix.What weak candidates say: “I’d add more indexes or increase the server size. Maybe we need to shard the collection.”What strong candidates say:
  • The core issue is almost certainly index intersection inefficiency. MongoDB can combine multiple single-field indexes via index intersection, but it is wildly unpredictable and almost always slower than a proper compound index for multi-field queries. With 12M documents, the query planner is likely doing a collection scan or picking one index and then scanning millions of results for the other predicates.
  • Step 1 - Confirm with explain("executionStats"): Run the exact query the API fires with .explain("executionStats") and look at totalDocsExamined vs totalKeysExamined vs nReturned. If totalDocsExamined is in the millions but nReturned is in the hundreds, you have an index selectivity problem.
  • Step 2 - Check with $indexStats: Run db.products.aggregate([{$indexStats: {}}]) to see which indexes are actually being used and how often. Bet the compound query is falling back to a single-field index scan.
  • Step 3 - Build a compound index: Create db.products.createIndex({tags: 1, rating: -1, price: 1}) putting the equality match field first (tags), then the sort/range field (rating descending since users want highest first), then the secondary range field (price). This follows the ESR rule: Equality, Sort, Range.
  • Step 4 - Drop the redundant single-field indexes on tags and rating (the compound index covers those prefixes). Keep the price index only if other queries use it in isolation.
  • Step 5 - Monitor with Atlas Performance Advisor or db.currentOp() for slow queries after deployment. Target should be under 50ms at p95.
  • Real-world gotcha: Building an index on 12M docs will lock writes if you use foreground indexing. Always use background: true in older MongoDB versions. In MongoDB 4.2+ index builds are hybrid by default, but they still consume I/O. Schedule it during low-traffic hours or use a rolling index build across replica set members.
  • The sharding suggestion is a red flag at this scale. 12M documents is well within a single replica set capability. Sharding adds massive operational complexity and should only be considered when you are past several hundred million documents or your working set exceeds available RAM.
Follow-up: How would your indexing strategy change if the tags field is an array with an average of 8 tags per product?Follow-up: The fix worked but the PM now wants full-text search with typo tolerance across product names and descriptions. Do you keep this in MongoDB or reach for something else? What are the trade-offs of MongoDB Atlas Search vs Elasticsearch vs Meilisearch?Follow-up: You discover that 40% of your queries are range queries on price alone without tags. How do you handle index design when different query patterns compete for optimization?
Scenario: Your team built a real-time dashboard in React that displays 200 rows of financial data updating via WebSocket every 500ms. Users report the UI is “janky” and unresponsive. React DevTools Profiler shows the entire table re-renders on every WebSocket message, even though each message only updates 3-5 rows. Each render cycle takes 180ms, well above the 16ms budget for 60fps. The component tree is: Dashboard then DataTable then DataRow (x200). How do you fix this?What weak candidates say: “I’d use React.memo on everything and add useMemo and useCallback everywhere.”What strong candidates say:
  • Blanket React.memo is the spray-and-pray approach. It helps, but without understanding why things re-render, you will just shift the bottleneck. Here is a systematic approach:
  • Root cause diagnosis: The WebSocket handler likely sets state at the Dashboard level with something like setRows(newRows), which replaces the entire array reference. Even if only 3 rows changed, React sees a new array and re-renders everything downstream.
  • Fix 1 - Normalize state and update surgically: Instead of storing rows as an array, use a Map or object keyed by row ID. When a WebSocket message arrives, only update the specific rows that changed:
// Instead of: setRows(allNewRows)
// Do this:
const handleWSMessage = useCallback((updates) => {
  setRowMap(prev => {
    const next = new Map(prev);
    updates.forEach(u => next.set(u.id, u));
    return next;
  });
}, []);
  • Fix 2 - React.memo on DataRow with proper comparison: Wrap DataRow in React.memo but ensure props are stable. If you pass callbacks, they must be wrapped in useCallback. If you pass objects, they need referential stability.
  • Fix 3 - Virtualization: 200 rows is borderline, but if each row has complex cells, use react-window or @tanstack/virtual. Only render the ~20 rows visible in the viewport. This alone can cut render time by 90%.
  • Fix 4 - Decouple subscription from React state: For truly high-frequency updates, consider using a useRef + requestAnimationFrame pattern where the WebSocket writes to a ref and a rAF loop reads from it to batch visual updates. Or use a state manager like Zustand which allows component-level subscriptions where each DataRow subscribes to only its own slice of data.
  • Measurement: After each fix, check the Profiler “Highlight updates when components render” and the Performance tab frame rate graph. Target: render time under 8ms to leave headroom for browser paint.
  • What to avoid: shouldComponentUpdate with deep comparison on 200 rows because the comparison cost itself becomes the bottleneck. Also avoid JSON.stringify comparisons. That is O(n) on object size for every render cycle.
Follow-up: The product team now wants to add column sorting and filtering on top of the real-time updates. How do you keep derived state (sorted/filtered view) in sync with live updates without re-sorting on every WebSocket tick?Follow-up: One developer suggests moving the entire table to canvas rendering with something like react-konva. When would canvas-based rendering actually make sense over DOM-based React rendering?Follow-up: How would you approach this differently if you were using React Server Components and the data needed to be real-time?
Scenario: Your Express API server runs in a Docker container with a 512MB memory limit. Monitoring shows RSS memory climbing steadily from 180MB at deploy to 480MB over 72 hours, at which point the container OOM-kills and restarts. There are no obvious errors in logs. The app serves ~2000 req/s. This started happening after a recent release that added a “user activity tracking” middleware. How do you find and fix the leak?What weak candidates say: “I’d increase the container memory or add more replicas. Maybe restart the container on a schedule.”What strong candidates say:
  • Periodic restarts are a band-aid that masks the root cause. Here is a systematic approach:
  • Step 1 - Confirm it is a heap leak, not native memory: Run process.memoryUsage() on a timer and log heapUsed, heapTotal, rss, and external. If heapUsed grows linearly, it is a JS heap leak. If rss grows but heapUsed is stable, it is a native addon, Buffer, or stream leak which is a different debugging path entirely.
  • Step 2 - Take heap snapshots in production: Use the --inspect flag or v8.writeHeapSnapshot() triggered via a debug endpoint (protected behind auth). Take one snapshot at startup and another after 24 hours. Load both into Chrome DevTools Memory tab and use the “Comparison” view to see what objects accumulated.
  • Step 3 - The “activity tracking” middleware is the prime suspect. Common leak patterns in middleware:
    • Closures capturing request objects: If the middleware stores req or res references in a module-level array, Map, or Set for later processing, and those references are never cleaned up.
    • Event listeners accumulating: If it attaches listeners to a shared EventEmitter on every request without removing them. Node will warn with “MaxListenersExceededWarning” but check if this warning was suppressed with setMaxListeners(0), which is a classic antipattern.
    • Unbounded in-memory cache: If it caches user activity data in a plain object or Map without TTL or size limits.
  • Step 4 - Likely fix: Based on the middleware pattern, look for something like:
// THE LEAK - module-level Map that grows forever
const activityLog = new Map();

app.use((req, res, next) => {
  const entry = { user: req.userId, path: req.path, time: Date.now() };
  activityLog.set(req.requestId, entry);
  // BUG: Nothing ever calls activityLog.delete()
  next();
});
  • Fix: Either flush to database/Redis periodically and clear the Map, use an LRU cache with a max size (lru-cache package), or better yet, do not accumulate in memory at all. Write directly to a log stream or message queue.
  • Step 5 - Verify the fix: Deploy and watch the memory graph. Healthy Node apps should have a sawtooth pattern (heap grows, GC collects, drops back) rather than a linear climb.
  • Production tooling to set up: Use clinic.js (clinic doctor and clinic heap) for automated analysis. In production, add a /debug/memory endpoint that returns process.memoryUsage() and trigger global.gc() (with --expose-gc flag) to differentiate between “has not GC’d yet” and “cannot GC.”
Follow-up: The heap snapshot shows 800,000 retained IncomingMessage objects. But the middleware does not explicitly store req. What other patterns could cause request objects to be retained?Follow-up: You fixed the leak, but now you notice the GC pause times spike to 200ms every few minutes, causing p99 latency spikes. How do you tune V8 garbage collection for a high-throughput API server?Follow-up: How would you set up proactive memory leak detection in your CI/CD pipeline so this does not reach production again?
Scenario: Your security team discovered that an attacker accessed 15,000 user records through your Express API. The logs show the attacker made requests to /api/users?role=admin and /api/users/../../etc/passwd. Your API has JWT authentication but no further authorization layer. The team asks you to do a post-mortem and harden the API. Walk through your analysis and remediation plan.What weak candidates say: “I’d add input validation and make sure the JWT is checked on every route.”What strong candidates say:
  • This breach has at least three distinct vulnerabilities that need separate fixes. Here is the breakdown of each attack vector:
  • Vulnerability 1 - Broken Access Control (BOLA/IDOR): The /api/users?role=admin endpoint likely returns all users matching the query without checking whether the requesting user has permission to see those records. This is OWASP #1 Broken Access Control. The JWT proves who you are (authentication) but nothing checks what you are allowed to access (authorization).
    • Fix: Implement authorization middleware that checks the requesting user role/permissions before executing queries. Never trust query parameters for access control:
// BEFORE (vulnerable)
app.get('/api/users', authenticate, async (req, res) => {
  const users = await User.find(req.query); // attacker controls the query!
  res.json(users);
});

// AFTER (hardened)
app.get('/api/users', authenticate, authorize('admin'), async (req, res) => {
  const allowedFilters = pick(req.query, ['name', 'department']);
  const users = await User.find(allowedFilters).select('-password -ssn -resetToken');
  res.json(users);
});
  • Vulnerability 2 - Path Traversal: The /api/users/../../etc/passwd request is a classic path traversal attack. This suggests the app is using a user-supplied parameter to construct file paths or that the router is not sanitizing path parameters.
    • Fix: Never construct file paths from user input. If you must, use path.resolve() and verify the result stays within an allowed directory. Also, add helmet middleware which sets security headers, and use express-mongo-sanitize to prevent NoSQL injection via query parameter manipulation.
  • Vulnerability 3 - Mass Assignment / Query Injection: The fact that req.query is passed directly to User.find() means an attacker can inject arbitrary MongoDB operators like {"role": {"$ne": null}} to dump all records.
    • Fix: Whitelist allowed query fields. Never pass raw req.query or req.body to database operations. Use a validation library like joi or zod:
const searchSchema = z.object({
  name: z.string().optional(),
  department: z.string().optional(),
  page: z.number().int().positive().default(1),
  limit: z.number().int().min(1).max(100).default(20),
});
  • Broader hardening checklist:
    • Add rate limiting per user/IP (express-rate-limit + Redis store for distributed)
    • Implement request payload size limits (express.json({limit: '10kb'}))
    • Add CORS configuration restricted to your frontend domains
    • Enable security headers via helmet()
    • Log all access with correlation IDs for forensic analysis
    • Add field-level projection so you never return password, resetToken, __v fields
    • Implement API response pagination to prevent bulk data extraction
    • Set up anomaly detection: alert when a single user requests >100 records/minute
Follow-up: The security team asks you to implement row-level security so users can only access records they own, except admins who can see everything. How do you architect this in Express + MongoDB without scattering authorization checks across every route handler?Follow-up: How would you handle API versioning in this hardening effort? Some mobile clients are on the old, vulnerable API version and cannot update immediately.Follow-up: The attacker used a leaked JWT that was valid for 30 days. How do you implement JWT revocation without losing the stateless benefit of JWTs?
Scenario: Users are reporting intermittent 500 errors on your MERN application checkout flow. It happens maybe 1 in 50 requests. Your stack: React frontend, Express API, MongoDB with Mongoose, deployed on AWS ECS behind an ALB. The error does not reproduce locally. Logs show MongoServerError: connection pool was cleared at random intervals. Your APM (Datadog) shows p99 latency spikes correlating with the errors. Walk me through your debugging approach.What weak candidates say: “I’d check the MongoDB connection string and maybe increase the connection pool size.”What strong candidates say:
  • Intermittent errors that do not reproduce locally are almost always infrastructure or connection management issues. The connection pool was cleared error is a dead giveaway. This means Mongoose connection pool detected a problem with the MongoDB server and dropped all connections, forcing reconnection. Here is a systematic approach:
  • Step 1 - Check MongoDB health: Look at Atlas metrics (or your MongoDB monitoring) for the exact timestamps of the errors. Look for:
    • Primary elections: If a replica set member steps down and a new primary is elected, all connections to the old primary are terminated. This causes exactly this error pattern.
    • Network blips: Transient network issues between ECS and MongoDB. Check AWS CloudWatch for ENI errors, NAT gateway timeouts, or VPC flow log anomalies.
    • Maintenance windows: Atlas performs maintenance that can trigger rolling restarts.
  • Step 2 - Check Mongoose connection settings: The default Mongoose connection config is often too aggressive on timeouts:
// Resilient connection config
mongoose.connect(uri, {
  maxPoolSize: 50,
  minPoolSize: 10,
  serverSelectionTimeoutMS: 5000,
  heartbeatFrequencyMS: 10000,
  retryWrites: true,
  retryReads: true,
  w: 'majority',
  socketTimeoutMS: 45000,
});
  • Step 3 - Implement retry logic at the application layer: MongoDB driver v4+ has built-in retryWrites and retryReads, but Mongoose operations can still fail on pool clears. Wrap critical operations with retry logic:
async function withRetry(operation, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await operation();
    } catch (err) {
      if (i === maxRetries - 1) throw err;
      if (err.message.includes('pool was cleared') ||
          err.message.includes('topology was destroyed')) {
        await new Promise(r => setTimeout(r, 100 * Math.pow(2, i)));
        continue;
      }
      throw err; // non-retryable error
    }
  }
}
  • Step 4 - Add connection event monitoring:
mongoose.connection.on('disconnected', () => logger.warn('MongoDB disconnected'));
mongoose.connection.on('reconnected', () => logger.info('MongoDB reconnected'));
mongoose.connection.on('error', (err) => logger.error('MongoDB error', err));
  • Step 5 - Check ECS task networking: If ECS tasks are cycling (deploys, autoscaling), new tasks might spike connection count. With 10 ECS tasks each opening 50 connections, that is 500 connections to MongoDB. Check if you are hitting Atlas connection limits.
  • The real fix is usually a combination: resilient connection config, retry logic for transient errors, proper error handling in Express that returns 503 (Service Unavailable) with a Retry-After header instead of 500, and client-side retry logic in the React app for checkout (with idempotency keys to prevent double-charging).
Follow-up: The checkout flow involves creating an order, charging a payment, and updating inventory across three collections. How do you handle the case where the payment succeeds but the MongoDB connection drops before the order is saved?Follow-up: You notice the connection pool clear events always happen at exactly the same time each day. What would that suggest, and how would you confirm?Follow-up: How would you implement idempotency keys end-to-end from the React frontend through Express to MongoDB to prevent duplicate orders during retries?
Scenario: You inherited a React application where the previous team used Redux for everything including form inputs, modal open/close state, hover states, and API loading states. The Redux store has 47 reducers. Every keystroke in a form dispatches an action, runs through all middleware, and triggers connected component re-renders across the app. The Redux DevTools extension crashes the browser when opened. The team wants to “fix state management.” How do you approach this?What weak candidates say: “I’d rewrite everything to use React Context or switch to Zustand. Redux is overkill for most apps.”What strong candidates say:
  • The problem is not Redux itself. It is that everything was put in Redux regardless of whether it belongs there. The fix is state colocation, not a wholesale migration. Here is the approach:
  • Categorize state by scope and lifespan:
    • Local UI state (modal open/close, hover, form inputs, accordions): Move to useState / useReducer in the component that owns it. This is the biggest win. It eliminates 60-70% of the Redux noise immediately.
    • Shared client state (current user, theme, feature flags): Keep in a lightweight global store. Could stay in Redux or move to Zustand/Jotai.
    • Server state (API data, loading, error states): Move to React Query or SWR. This alone eliminates the need for most async Redux middleware (redux-thunk / redux-saga).
    • URL state (pagination, filters, search terms): Move to URL search params with useSearchParams(). This makes the state shareable and bookmarkable.
  • Migration strategy (incremental, not big-bang):
    • Week 1: Install React Query. Migrate the 3 most-used API data flows. Delete those reducers. Measure bundle size and render performance improvement.
    • Week 2-3: Audit all 47 reducers. Tag each as “local”, “shared”, or “server”. Create a spreadsheet tracking migration status.
    • Week 4-6: Migrate local state out of Redux, feature by feature. Each PR should remove one reducer and be independently deployable.
    • Week 7-8: Evaluate what is left in Redux. If it is <5 reducers of genuinely shared client state, keep Redux (or switch to Zustand for simplicity).
  • Form state specifically: For the form keystroke problem, use react-hook-form which keeps form state in refs (not React state), so inputs do not trigger re-renders at all:
const { register, handleSubmit } = useForm();
// Each keystroke updates a ref, not state — zero re-renders
<input {...register('email')} />
  • What to avoid: Rewriting everything at once. Teams have spent 3 months on a “state management rewrite” that introduces as many bugs as it fixes. The incremental approach lets you ship improvements weekly and catch regressions early.
  • Metrics to track: Time-to-interactive, bundle size (Redux + middleware can be 30-40KB), render count per user action (measure in React Profiler), and developer velocity (how fast can new features be built).
Follow-up: The team pushes back and says “Context is simpler than Redux.” How do you explain to them that React Context has its own re-render problems and is not a state management solution?Follow-up: After migrating API state to React Query, the team notices stale data issues where one user updates a record but another user screen still shows the old data. How do you handle cache invalidation across multiple related queries?Follow-up: Some of the Redux middleware handles complex business logic like “if the user adds item X to cart, check inventory, apply discount rules, and suggest complementary products.” Where does this logic live after the migration?
Scenario: You are deploying a MERN application for the first time to production. The stack: React (Vite build), Express API, MongoDB Atlas, deployed on a single AWS EC2 instance with Nginx as reverse proxy. The deployment “works” but you discover: (1) The React app shows a blank white page on some routes when refreshed, (2) API responses are slow despite fast local performance, (3) Environment variables like VITE_API_URL are undefined in the built frontend, and (4) CORS errors appear when the frontend calls the API. Diagnose all four issues.What weak candidates say: “I’d check the console for errors. Maybe it’s a CORS configuration issue.”What strong candidates say:
  • These are four distinct issues that every MERN developer hits on their first real deployment. Addressing each:
  • Issue 1 - Blank page on route refresh: This is the classic SPA routing problem. When the user is on /dashboard/settings and refreshes, Nginx tries to serve a file at that path, which does not exist. Nginx returns 404 instead of serving index.html and letting React Router handle it.
    • Fix — Add a catch-all in Nginx:
location / {
  root /var/www/frontend/dist;
  try_files $uri $uri/ /index.html;
}
  • Issue 2 - Slow API responses: On EC2, the Express server connects to MongoDB Atlas over the public internet instead of a local connection. Each query has an added 20-50ms network round trip. Also, if the EC2 instance is in us-east-1 but the Atlas cluster is in us-west-2, you are adding cross-region latency.
    • Fix: Enable VPC Peering between your AWS VPC and MongoDB Atlas. Ensure both are in the same region. Also enable connection pooling in Mongoose (default maxPoolSize is 100, which is fine) and add response compression:
const compression = require('compression');
app.use(compression()); // gzip responses — 60-80% size reduction for JSON
  • Issue 3 - VITE_API_URL undefined: Vite environment variables are statically replaced at build time, not read at runtime. If the .env.production file was not present during vite build, or if the variable does not start with VITE_, it will not be embedded in the bundle.
    • Fix: Ensure .env.production exists with VITE_API_URL=https://api.yourdomain.com before running vite build. Verify with grep VITE_API_URL dist/assets/*.js after build. For dynamic runtime config, use a window.__CONFIG__ approach by injecting a script tag or serving a /config.json endpoint.
  • Issue 4 - CORS errors: The frontend is served from https://app.yourdomain.com but makes API calls to https://api.yourdomain.com (different subdomain = different origin). The Express API is not sending the right CORS headers.
    • Fix:
const cors = require('cors');
app.use(cors({
  origin: ['https://app.yourdomain.com'],
  credentials: true, // if using cookies/sessions
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
}));
  • Better approach: Configure Nginx to proxy /api/* to the Express server so both frontend and API are served from the same origin, eliminating CORS entirely:
location /api/ {
  proxy_pass http://localhost:3001/;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
}
  • Bonus production hardening: Enable Nginx rate limiting, add pm2 or systemd to keep the Node process alive, set NODE_ENV=production (Express enables view caching, reduces verbose errors), configure SSL with Let’s Encrypt/Certbot, and add health check endpoints.
Follow-up: The PM asks you to set up zero-downtime deployments for this single EC2 instance. Is it possible without adding more servers? How?Follow-up: You move to containerized deployment with Docker. The React build step takes 4 minutes. How do you optimize the Dockerfile for faster builds and smaller image size?Follow-up: Traffic grows 10x and the single EC2 instance cannot keep up. What is your migration path: ECS, EKS, or a PaaS like Railway/Render? What factors drive this decision?
Scenario: Your social media app stores posts with comments embedded in the post document. It worked fine at launch. Now, six months in, some viral posts have 50,000+ comments. Users report that loading any page with a viral post takes 12 seconds. The post document for the most viral post is 14MB, approaching MongoDB 16MB document size limit, and some posts are now causing write failures. You need to redesign the data model without significant downtime. How do you approach this?What weak candidates say: “I’d just move comments to a separate collection and reference them by post ID.”What strong candidates say:
  • This is a textbook example of embedded documents failing at scale. The fix is clear (move to a referenced model) but the migration strategy is the hard part. Covering both:
  • Why the embedded model failed: MongoDB loads the entire document into memory for any operation on it. A 14MB post document means reading 50,000 comments just to display the post title. Every new comment rewrites the entire document. At 16MB, writes hard-fail.
  • Target data model:
// posts collection
{
  _id: ObjectId,
  author: ObjectId,
  content: String,
  commentCount: Number,  // denormalized counter
  topComments: [/* top 3 comments embedded for preview */],
  createdAt: Date
}

// comments collection (separate)
{
  _id: ObjectId,
  postId: ObjectId,     // indexed
  author: ObjectId,
  content: String,
  likes: Number,
  createdAt: Date       // indexed for pagination
}
  • The hybrid approach is key: embed the top 3 comments (for the feed preview) and reference the rest. This gives you the read performance of embedding for the common case (showing a post with a few comments) while handling scale for viral posts.
  • Migration strategy (zero-ish downtime):
    • Phase 1: Create the comments collection. Deploy new code that writes to both locations (embedded and referenced) using the dual-write pattern.
    • Phase 2: Run a background migration script that reads each post, extracts embedded comments, inserts them into the comments collection, and updates the post to keep only topComments + commentCount. Process in batches of 100 posts with a small delay between batches to avoid slamming the database.
    • Phase 3: Switch reads to the new comments collection. The feed still reads topComments from the post document.
    • Phase 4: Remove the old embedded comments array from post documents. Stop dual-writes.
  • Index the comments collection: {postId: 1, createdAt: -1} for paginated comment loading. Add {postId: 1, likes: -1} if you support “top comments” sorting.
  • Pagination: Use cursor-based pagination on createdAt rather than skip/limit. For a post with 50,000 comments, skip(49000) would scan and discard 49,000 documents:
// Cursor-based: efficient at any offset
db.comments.find({
  postId: postObjectId,
  createdAt: { $lt: lastSeenCommentDate }
}).sort({ createdAt: -1 }).limit(20);
  • Keeping commentCount in sync: Use MongoDB change streams or handle it in the application layer. Accept that the count might be slightly stale. Facebook and Instagram do this too.
Follow-up: A product manager asks if you should use MongoDB $lookup (aggregation join) to fetch post + comments in one query, or do two separate queries from the application layer. What are the performance trade-offs?Follow-up: You need to support threaded/nested comments (replies to replies). How does this change your data model? What is the trade-off between materialized path, adjacency list, and nested set models?Follow-up: How would you handle the case where a viral post gets 500 new comments per second? At what point does even the referenced model struggle, and what do you reach for next?
Scenario: Your Express API serves both REST endpoints and a real-time notification system via Server-Sent Events (SSE). Under load testing, you discover that when 500 SSE connections are active, the REST endpoints slow from 20ms to 3 seconds. The server CPU is only at 25%. Memory is fine. There are no database bottlenecks. What is happening and how do you fix it?What weak candidates say: “The server is probably overloaded. I’d add more instances or increase the thread pool.”What strong candidates say:
  • Low CPU + high latency is the hallmark of event loop starvation. Something is blocking or monopolizing the event loop, preventing the REST handlers from executing. The CPU is low because most of the time is spent waiting, not computing. Here is the diagnosis:
  • The SSE connection pattern is likely the culprit. Each SSE connection is a long-lived HTTP response. If the notification system is doing any synchronous work per SSE tick like iterating over all 500 connections in a tight loop, serializing data, or checking conditions, that blocks the event loop for the duration.
  • Common antipattern to look for:
// BAD: Synchronous broadcast to all SSE clients blocks the event loop
function broadcastNotification(data) {
  const payload = JSON.stringify(data); // fine if small
  clients.forEach(client => {          // 500 iterations
    client.res.write(`data: ${payload}\n\n`);  // synchronous I/O write
  });
}
The res.write() call is not truly async. If the TCP write buffer is full (slow clients), it can block. With 500 connections, even small per-client overhead accumulates.
  • Diagnosis with --prof and clinic.js:
    • Run clinic doctor -- node server.js under load. It will show the event loop delay graph. Healthy is <10ms. This likely shows 500ms+ delays.
    • Use process.hrtime() to measure event loop lag:
setInterval(() => {
  const start = process.hrtime.bigint();
  setImmediate(() => {
    const delay = Number(process.hrtime.bigint() - start) / 1e6;
    if (delay > 50) logger.warn(`Event loop lag: ${delay}ms`);
  });
}, 1000);
  • Fix 1 - Batch SSE writes with setImmediate:
async function broadcastNotification(data) {
  const payload = `data: ${JSON.stringify(data)}\n\n`;
  const batch = 50;
  for (let i = 0; i < clients.length; i += batch) {
    const slice = clients.slice(i, i + batch);
    slice.forEach(c => c.res.write(payload));
    // Yield to event loop between batches
    await new Promise(resolve => setImmediate(resolve));
  }
}
  • Fix 2 - Separate SSE into its own process: Use Node cluster module or a separate microservice for SSE connections. REST and SSE have fundamentally different resource profiles. REST is request-response (short bursts), SSE is long-lived (sustained connections). Mixing them on one event loop is asking for trouble.
  • Fix 3 - Use Redis Pub/Sub for notification distribution: Instead of one process managing all SSE connections, each Node process handles a subset. Notifications are published to a Redis channel, and each process subscribes and broadcasts to its own clients. This scales horizontally.
  • The thread pool (UV_THREADPOOL_SIZE) is a red herring here. That affects file I/O and DNS lookups, not HTTP connection handling. SSE operates on the main event loop.
Follow-up: How does backpressure work in Node.js streams, and how would it affect your SSE implementation when a client has a slow connection?Follow-up: Would switching to WebSockets instead of SSE solve the event loop problem? What are the fundamental differences in how Node handles each?Follow-up: You decide to move SSE to a separate service. How do you handle the case where a user has an active SSE connection to Service A but their REST request that triggers a notification goes to Service B?
Scenario: Your MERN e-commerce app has a Lighthouse score of 34 on mobile. The CEO just read an article that every 100ms of latency costs 1% in revenue. Google Core Web Vitals are failing: LCP is 8.2s (target <2.5s), FID is 380ms (target <100ms), CLS is 0.45 (target <0.1). The backend API p95 is 800ms. You have 2 weeks and one other developer to make meaningful improvements. What is your prioritized action plan?What weak candidates say: “I’d optimize images, add lazy loading, and enable caching. Maybe switch to Next.js for SSR.”What strong candidates say:
  • Two weeks with two developers means ruthless prioritization. The key is to identify the highest-impact, lowest-effort fixes first. Here is the plan, ordered by expected impact:
  • Week 1 — The 80/20 Fixes (target: Lighthouse 60+, LCP <4s):
    • Fix LCP (8.2s to target <4s): LCP is usually the hero image or the largest text block. Run Lighthouse and check what element is the LCP.
      • If it is an image: Convert to WebP/AVIF, add width/height attributes to prevent layout shift, add fetchpriority="high" and loading="eager" to the LCP image specifically, serve via CDN (CloudFront/Cloudflare). This alone can cut 3-4 seconds.
      • If it is text rendered after a large JS bundle: That is a code-splitting problem. The main bundle is probably 2MB+ uncompressed. Implement route-based code splitting with React.lazy() and Suspense.
      • Check if the font is render-blocking. Add font-display: swap to @font-face declarations. Preload the primary font with a link preload tag with as="font" and crossorigin.
    • Fix CLS (0.45 to target <0.1): This is almost always caused by images without dimensions, dynamically injected ads/banners, or web fonts causing FOUT (Flash of Unstyled Text).
      • Add explicit width and height to every img tag.
      • Reserve space for dynamic content with CSS min-height or aspect-ratio.
      • Audit for any content that loads after initial paint and pushes things around.
    • Fix the 800ms API p95: Run explain() on the top 5 slowest MongoDB queries (get these from Atlas Slow Query Analyzer or Mongoose debug mode). Add missing indexes. Enable compression middleware on Express. Add Redis caching for the product listing and category pages since they are read-heavy and cache-friendly.
  • Week 2 — Deeper Optimizations (target: Lighthouse 70+, LCP <3s):
    • Fix FID (380ms to target <100ms): High FID means the main thread is blocked during page load. The culprit is JavaScript, too much of it executing synchronously.
      • Audit the bundle with vite-plugin-visualizer or source-map-explorer. Find the largest dependencies. Common offenders: moment.js (replace with date-fns or dayjs), full lodash import (switch to lodash-es with tree-shaking), analytics libraries loading synchronously.
      • Defer non-critical third-party scripts: analytics, chat widgets, social embeds. Use async or defer attributes on script tags, or load them after window.onload.
      • Move heavy computations to web workers if applicable.
    • Enable HTTP/2 on Nginx: Multiplexed connections eliminate head-of-line blocking. This is a config change, not code.
    • Add stale-while-revalidate caching headers for API responses that tolerate slight staleness (product listings, categories):
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  • Implement incremental static regeneration (if using Next.js) or pre-render critical pages to HTML at build time.
  • What NOT to do in 2 weeks: Migrate to Next.js (too risky for a rewrite under pressure), implement a CDN from scratch (use Cloudflare with 5-minute setup), or “optimize all images” (focus only on above-the-fold and LCP images first).
  • Measurement cadence: Run Lighthouse CI on every PR. Set up Real User Monitoring (RUM) with web-vitals library to track field data, not just lab data. Report daily to the CEO with the specific numbers and the revenue impact estimate.
Follow-up: The CEO asks why you are not just “switching to Next.js for SSR” since that would fix everything. How do you explain the trade-offs and risks of that migration under time pressure?Follow-up: After your fixes, Lighthouse shows 72 but real users on 3G connections in India still report 6-second load times. What is the gap between lab data and field data, and how do you optimize for real-world conditions?Follow-up: How would you set up a performance budget that prevents the team from regressing these metrics as new features are added?

Conclusion and Interview Tips

This comprehensive guide covers essential MERN Stack interview questions across all four technologies. The MERN stack combines MongoDB, Express.js, React, and Node.js to create powerful full-stack web applications.

Key Interview Preparation Tips

  • Master fundamentals before advanced topics — interviewers can tell immediately if you memorized answers vs truly understand concepts. Start with the event loop, reconciliation, and middleware pipeline before touching optimization techniques
  • Build a full-stack project and deploy it — nothing replaces the experience of debugging a production CORS issue at 2am or figuring out why your MongoDB queries are slow with real data
  • Understand how technologies integrate — the best MERN candidates can trace a request from React click to MongoDB write and back. Most candidates only know one layer deeply
  • Focus on trade-offs, not just solutions — senior interviewers care more about why you chose X over Y than whether you know X exists
  • Know your tools and metrics — be able to name specific tools: React DevTools Profiler for render performance, explain() for MongoDB queries, Lighthouse for Core Web Vitals, Sentry for error tracking

During the Interview

  • Ask clarifying questions before coding — “What scale are we talking about? How many concurrent users? What are the consistency requirements?” shows senior thinking
  • Think aloud to show your problem-solving approach — interviewers cannot evaluate what they cannot hear. Verbalize trade-offs as you consider them
  • Lead with the “why” before the “how” — “I would use Redis here because we need sub-millisecond lookups and the data has a natural TTL” is much stronger than “I would use Redis”
  • Discuss trade-offs in your solutions — every technical decision has downsides. Acknowledging them shows engineering maturity
  • Be honest about edges of your knowledge — “I have not used that in production, but my understanding is…” is far better than confidently giving a wrong answer

What Separates Good from Great Candidates

Good CandidateGreat Candidate
Knows what React.memo doesKnows when React.memo hurts performance
Can write a JWT auth flowCan explain why JWTs in localStorage are insecure
Understands MongoDB indexingCan design the right compound index for a given query pattern
Knows the event loop phasesCan explain why setImmediate and process.nextTick have different use cases
Lists performance optimization techniquesMeasures first, optimizes second, and can explain the specific bottleneck they fixed
Remember that interviews assess not just technical knowledge but also problem-solving ability, communication skills, and engineering judgment. The best candidates do not just know the answers — they understand the reasoning behind them and can adapt their knowledge to novel situations.