Skip to content

Cookies on Jobverse

We use cookies that are needed to sign you in and keep the site working. With your permission we’d also use an analytics cookie to count job views, and Google advertising cookies to show ads. The site works the same whatever you choose. Read the Cookie Policy.

Jobverse
Sign in

Node.js Backend Developer Top 100 Interview Questions

100 Node.js backend developer interview questions with structured answers, comparison tables, flow diagrams, worked calculations and runnable code. Covers the event loop, async patterns, streams, HTTP servers, databases, caching, testing, security, performance and system design, from the basics to production scenarios.

100 questions

Questions 41-60 of 100

  1. Q41.How do you stop many concurrent requests from loading the same data at once?

    Store the promise of an in-progress load in a map keyed by what is being loaded. Any request for the same key while the load is running receives that same promise, so the expensive call runs once and every caller gets its result. This is often called request coalescing or single flight.

    sequenceDiagram
      participant A as Request A
      participant B as Request B
      participant M as Pending-load map
      participant D as Database
      A->>M: Load user 7
      M->>D: Start one query
      B->>M: Load user 7
      M-->>B: Same pending promise
      D-->>M: Row for user 7
      M-->>A: Result
      M-->>B: Result
      M->>M: Remove entry in finally
    • Remove the entry when the promise settles, with finally, so later requests trigger a fresh load and a failure is not cached forever.
    • Share failures deliberately: every waiting caller sees the same error, which is usually right for a failed load.
    • Combine with a cache: coalescing protects the backend during a cache miss; the cache then serves later requests.

    Where it helps most:

    • Popular cache entries expiring under heavy traffic.
    • Expensive aggregations or external API calls that many users trigger together.
    • Startup, when many requests arrive before caches are warm.

    Coalescing works within one process. Across processes, use a short-lived lock in a shared store, or accept one load per process, which is usually small.

    Example

    Three concurrent requests for profile 7 share one load, and profile 8 gets its own.

    function singleFlight(load) {
      const inFlight = new Map();
      return (key) => {
        if (inFlight.has(key)) return inFlight.get(key);
        const promise = load(key).finally(() => inFlight.delete(key));
        inFlight.set(key, promise);
        return promise;
      };
    }
    
    let loads = 0;
    const getProfile = singleFlight(async (id) => {
      loads++;
      await new Promise((resolve) => setTimeout(resolve, 20));
      return { id, name: `user-${id}` };
    });
    
    Promise.all([getProfile(7), getProfile(7), getProfile(7), getProfile(8)]).then((profiles) => {
      console.log(profiles.map((profile) => profile.name).join(',')); // user-7,user-7,user-7,user-8
      console.log(loads); // 2
    });
  2. Q42.How do you propagate a request ID through asynchronous calls in Node.js?

    Use AsyncLocalStorage from the async_hooks module. You run each request's handling inside storage.run(context, callback), and any code called from it, across awaits, timers and callbacks, can read that context with storage.getStore().

    • Isolation: concurrent requests each see their own store, even though they share one thread.
    • No parameter threading: loggers, database wrappers and HTTP clients read the request ID themselves, so it does not have to be passed through every function.
    • Outside a request, getStore() returns undefined, so code must handle that case.

    Typical setup:

    1. Middleware reads the incoming request ID header, or generates one.
    2. It runs the rest of the request inside storage.run({ requestId, userId }, next).
    3. The logger adds the ID to every line, and outgoing HTTP calls copy it into a header for downstream services.

    Cautions:

    • Store small, immutable context, not whole request objects.
    • Work started outside the request flow, such as a shared connection's callbacks, may not carry the context, so test that logs from every layer include the ID.

    Example

    Two concurrent requests keep their own ID across an await, and code outside any request sees no store.

    const { AsyncLocalStorage } = require('node:async_hooks');
    
    const context = new AsyncLocalStorage();
    const lines = [];
    
    function log(message) {
      const store = context.getStore();
      lines.push(`[${store ? store.requestId : '-'}] ${message}`);
    }
    
    async function loadUser() {
      await new Promise((resolve) => setTimeout(resolve, 10));
      log('user loaded');
    }
    
    function handle(requestId) {
      return context.run({ requestId }, async () => {
        log('start');
        await loadUser();
      });
    }
    
    Promise.all([handle('req-1'), handle('req-2')]).then(() => {
      log('outside any request');
      for (const line of lines.sort()) console.log(line);
    });
    // Output:
    // [-] outside any request
    // [req-1] start
    // [req-1] user loaded
    // [req-2] start
    // [req-2] user loaded
  3. Q43.How do you run background jobs and queues alongside a Node.js API?

    Keep slow or unreliable work out of the request: the API records a job on a durable queue and responds immediately, and separate worker processes consume jobs, retry failures and report results.

    flowchart LR
      A["API request"] -->|enqueue small job message| B["Durable job queue"]
      B --> C["Worker processes"]
      C --> D{"Succeeded?"}
      D -->|yes| E["Acknowledge and record result"]
      D -->|no| F{"Retries left?"}
      F -->|yes| G["Delay and requeue"]
      F -->|no| H["Dead-letter queue and alert"]
      G --> B

    Design points:

    • Separate processes: run workers apart from the web servers, so heavy jobs never slow API responses and each side scales on its own.
    • Small messages: enqueue IDs, and load current data in the worker.
    • At-least-once delivery: a job can run twice after a crash, so make handlers idempotent.
    • Concurrency per worker: each worker can process several I/O-bound jobs at once, but limit it to what the dependencies can take.
    • Repeatable jobs: schedule them through the queue or a single scheduler, not a timer in every API process.
    • Visibility: track queue depth, job age and failure counts, and alert when jobs pile up.
  4. Q44.How do you rate-limit requests in a Node.js API?

    Count requests per client over a time window and reject the excess with status 429, plus a header saying when to retry. With several processes, keep the counters in a shared store, or each process allows the full limit on its own.

    Common algorithms:

    • Fixed window: count per calendar minute. Simple, but it allows double the limit across a window boundary.
    • Sliding window counter: weight the previous window's count by how much of it still overlaps the last minute. Smooth and memory-light.
    • Token bucket: tokens refill at a steady rate and allow short bursts.

    Choosing the key: use an API key or user ID where possible; IP addresses are shared by many users behind proxies and can be rotated by attackers.

    Practical details:

    1. Apply limits at the edge or gateway for coarse protection, and in the service for business limits such as "5 password resets per hour".
    2. Update counters atomically in the shared store, so concurrent requests cannot both slip under the limit.
    3. Return limit, remaining and reset information in response headers.
    4. Exempt health checks and internal traffic.

    Example

    A limit of 100 requests per minute using a sliding window counter.

    1. The estimate is current_count + previous_count × (1 − elapsed_fraction).
    2. Fifteen seconds into the current minute, the previous minute had 80 requests and the current one has 30.
    3. The estimate is 30 + 80 × (1 − 15/60) = 30 + 60 = 90, so the next request is allowed.
    4. After 10 more requests the estimate reaches 100, and further requests get status 429 until older requests age out of the window.
  5. Q45.How do you stream large responses from a Node.js server?

    Pipe a readable source, such as a file stream or a database cursor, into the HTTP response with stream.pipeline. The response is itself a writable stream, so backpressure from a slow client automatically slows the source.

    • Files: fs.createReadStream piped to the response sends a large file with a small, constant memory footprint.
    • Database results: use the driver's cursor or row stream, transform rows into output, such as CSV or newline-delimited JSON, and pipe it to the response.
    • Generated data: an async generator can produce chunks and be turned into a stream with Readable.from.
    • Compression: add a compression stage in the pipeline when clients accept it.

    Handle the edge cases:

    1. Client disconnects: pipeline destroys the source when the response closes early, which stops a database query from running for nobody.
    2. Errors mid-stream: the status code is already sent, so log the error and destroy the response; the client sees a truncated download.
    3. Headers first: set content type and download filename before the first chunk.

    For very large exports, building the file in a background job and returning a download link is more reliable than one long-lived request.

  6. Q46.How do you handle file uploads in Node.js without exhausting memory?

    Parse multipart uploads as streams and write file data straight to its destination, such as disk or object storage, while enforcing limits on size, count and type. Buffering whole files in memory lets a few large uploads crash the process.

    flowchart TD
      A["Client upload"] --> B["Streaming multipart parser"]
      B --> C{"Within size, count and type limits?"}
      C -->|No| D["Reject with 413 and delete the partial file"]
      C -->|Yes| E["Write to a temporary location"]
      E --> F["Move into place after all checks"]
      F --> G["Queue background processing"]
    1. Stream the parts: use a streaming multipart parser that gives each file as a readable stream.
    2. Limit everything: maximum file size, number of files, number of fields and total request size, rejecting with status 413 when exceeded.
    3. Check the content: inspect the first bytes for the expected file signature, rather than trusting the file name or the client's declared type.
    4. Write to a temporary location with a generated name, and move it into place only after all checks pass.
    5. Upload directly when possible: for large files, let the client upload straight to object storage using a short-lived signed URL, so the Node.js process never handles the bytes.
    6. Clean up on failure: delete partial files when the client disconnects or a limit is hit.

    Heavy processing, such as generating thumbnails or scanning for malware, belongs in a background job triggered after the upload completes.

  7. Q47.What are the security risks of the Node.js package ecosystem, and how do you reduce them?

    A typical Node.js service pulls in hundreds of indirect dependencies, each maintained by different people. Any of them can contain a vulnerability, or be compromised to ship malicious code, and some run install scripts on every machine that installs them.

    Main risks:

    • Known vulnerabilities in outdated packages.
    • Malicious releases published after a maintainer account is taken over.
    • Look-alike names that trick developers into installing the wrong package.
    • Install scripts that run arbitrary code on developer laptops and build servers.
    • Abandoned packages that no longer receive fixes.

    How to reduce them:

    1. Commit the lock file and use clean installs, so builds get exactly the reviewed versions and integrity hashes.
    2. Run vulnerability audits in the pipeline, and fix high-severity findings quickly.
    3. Review new dependencies: maintenance activity, download history, number of transitive packages and whether you really need them.
    4. Disable install scripts by default in builds, allowing them only for packages that genuinely need them.
    5. Use a private registry mirror for production builds.
    6. Update regularly in small batches with automated pull requests and tests.
  8. Q48.How do you prevent regular expression denial of service in Node.js?

    Some regular expressions backtrack exponentially on certain inputs, and because matching runs on the main thread, one crafted string can block the event loop for seconds or minutes. This is called regular expression denial of service.

    Risky patterns:

    • Nested quantifiers such as (a+)+ or (\w+\s?)*.
    • Alternatives that overlap, such as (a|a)*, followed by something that fails to match at the end.
    • Patterns written for email addresses, URLs or HTML, which are often more complex than they look.

    How to prevent it:

    1. Limit input length before matching; most backtracking blowups need long strings.
    2. Simplify patterns: remove nested quantifiers and overlapping alternatives, and anchor patterns where possible.
    3. Use parsers instead: dedicated parsing functions, such as new URL() for URLs, avoid hand-written patterns entirely.
    4. Test with hostile input, and use a lint rule that flags unsafe patterns.
    5. Never build regular expressions from user input; if you must, escape it first.
    6. Isolate untrusted patterns in a worker thread with a time limit when users genuinely need to supply them.

    Interview tip: connect this to the event loop; the danger in Node.js is not just one slow request, but every request on the process stalling.

  9. Q49.Can you safely run untrusted code with the Node.js `vm` module?

    No. The vm module runs code in a separate global context, but it is not a security boundary; the documentation says so explicitly. Well-known techniques let code inside a vm context reach the host's objects and run anything the process can.

    What vm is useful for:

    • Evaluating trusted code with a controlled set of globals, such as templates or configuration written by your own team.
    • Running code with a timeout to catch accidental infinite loops.

    If you must run code from users:

    1. Separate process: run it in its own process with a dedicated, unprivileged user.
    2. Operating system isolation: a container or sandbox with no network access, a read-only file system and no secrets in its environment.
    3. Resource limits: cap CPU time, memory and output size, and kill the process when exceeded.
    4. Narrow interface: pass only the data it needs as input, and validate its output.

    Worker threads are not isolation either: they share the process, its permissions and its file access. Many products avoid the problem entirely by letting users choose from predefined rules instead of writing code.

  10. Q50.How do you keep a Node.js service's runtime version up to date?

    Run a supported long-term support release, track when it reaches end of life, and upgrade on a planned schedule with tests and a gradual rollout, rather than waiting until a security patch forces a rushed jump.

    • Release lines: even-numbered major versions become long-term support releases with years of fixes; odd-numbered ones are short-lived and suit experimentation, not production.
    • Pin the version in every place it is defined: the container image, local development tools and the engines field in package.json.
    • Patch releases contain security fixes, so apply them promptly; automated image rebuilds make this routine.

    Upgrading major versions safely:

    1. Read the release notes for removed APIs, changed defaults and newly fatal behaviors.
    2. Run the test suite on both versions; running with deprecations turned into errors reveals hidden problems.
    3. Rebuild native add-ons, which are compiled against a specific runtime version.
    4. Compare performance, memory and startup time under load.
    5. Canary a few instances in production, then roll out fully.

    Upgrading one major version at a time, regularly, keeps each step small and keeps dependency upgrades possible.

  11. Q51.What happens when an HTTP request reaches a Node.js server?

    The operating system accepts the connection, Node.js parses the HTTP request off the socket, and the server emits a request event. Your framework's middleware and route handler then run as callbacks on the event loop, and the response is streamed back over the same socket.

    flowchart LR
      A["Client connection"] --> B["Socket accepted by the event loop"]
      B --> C["HTTP parser builds request object"]
      C --> D["request event fires"]
      D --> E["Middleware chain"]
      E --> F["Route handler"]
      F --> G["Database and service calls"]
      F --> H["Response written and streamed"]
    1. Connection: the socket is accepted without blocking; with keep-alive, later requests reuse it.
    2. Parsing: headers are parsed before your code runs; the body arrives later as a stream.
    3. Middleware: shared steps such as logging, body parsing, authentication and error handling.
    4. Handler: validates input, awaits I/O and builds the response.
    5. Response: res.end or a piped stream sends the data, and the socket returns to idle or closes.

    Why the details matter: the body is a stream, so it must be read, limited in size and parsed; and everything after the parser shares one thread, so any synchronous work delays other requests.

  12. Q52.How does the built-in `http` module work, and why do frameworks sit on top of it?

    http.createServer takes a callback that receives a request and a response object for every incoming request. The request is a readable stream with the method, URL and headers; the response is a writable stream where you set the status and headers and write the body.

    What the core module gives you:

    • A fast, standards-compliant HTTP server and client.
    • Full control over streaming, headers and connection handling.
    • No dependencies, which suits small services and health endpoints.

    What frameworks add on top:

    • Routing with path parameters, instead of comparing req.url by hand.
    • Body parsing with size limits and content type checks.
    • Middleware composition and central error handling.
    • Conveniences such as JSON responses, cookies and validation integration.

    Knowing the core module matters even when you use a framework: timeouts, keep-alive settings, streaming and backpressure all come from it, and many production issues trace back to its defaults. Listening on port 0 asks the operating system for a free port, which is handy in tests.

    Example

    A minimal server answers a health route and returns 404 for anything else; the test client connects on a random free port.

    const http = require('node:http');
    
    const server = http.createServer((req, res) => {
      const ok = req.method === 'GET' && req.url === '/health';
      res.writeHead(ok ? 200 : 404, { 'content-type': 'application/json' });
      res.end(JSON.stringify(ok ? { status: 'ok' } : { error: 'not found' }));
    });
    
    function get(port, path) {
      return new Promise((resolve, reject) => {
        http.get({ host: '127.0.0.1', port, path, agent: false }, (res) => {
          let body = '';
          res.setEncoding('utf8');
          res.on('data', (chunk) => (body += chunk));
          res.on('end', () => resolve({ status: res.statusCode, body }));
        }).on('error', reject);
      });
    }
    
    server.listen(0, async () => {
      const { port } = server.address();
      const health = await get(port, '/health');
      console.log(health.status, health.body); // 200 {"status":"ok"}
      const missing = await get(port, '/jobs');
      console.log(missing.status); // 404
      server.close();
    });
  13. Q53.How does middleware work in Node.js web frameworks, and why does order matter?

    Middleware are functions that run in sequence for every request, each receiving the request context and a way to call the next one. Many frameworks use an "onion" model: code before await next() runs on the way in, and code after it runs on the way out.

    Why order matters:

    • Error handling and logging go first, outermost, so they wrap everything and see failures from every later layer.
    • Body parsing must come before handlers that read the body.
    • Authentication must come before authorization checks and route handlers.
    • A middleware that responds without calling next stops the chain, so later layers never run.

    Common mistakes:

    1. Calling next() twice, or after sending a response, which causes "headers already sent" errors.
    2. Forgetting to await next(), so the outer layer finishes before the inner ones and misses their errors.
    3. Doing slow work in global middleware that only a few routes need.
    4. Registering an error handler too late, so it never sees errors from earlier layers.

    Example

    A small onion-style compose function shows the order of layers for a successful, a rejected and a failing request.

    function compose(middlewares) {
      return (ctx) => {
        const dispatch = (i) => {
          const fn = middlewares[i];
          if (!fn) return Promise.resolve();
          return Promise.resolve(fn(ctx, () => dispatch(i + 1)));
        };
        return dispatch(0);
      };
    }
    
    const trace = [];
    const app = compose([
      async (ctx, next) => {
        trace.push('log in');
        try {
          await next();
        } catch (error) {
          ctx.status = 500;
        }
        trace.push(`log out ${ctx.status}`);
      },
      async (ctx, next) => {
        trace.push('auth');
        if (!ctx.token) {
          ctx.status = 401;
          return;
        }
        await next();
      },
      async (ctx) => {
        trace.push('handler');
        if (ctx.fail) throw new Error('database down');
        ctx.status = 200;
      },
    ]);
    
    async function main() {
      await app({ token: 'abc' });
      await app({});
      await app({ token: 'abc', fail: true });
      console.log(trace.join(' > '));
    }
    
    main();
    // Output:
    // log in > auth > handler > log out 200 > log in > auth > log out 401 > log in > auth > handler > log out 500
  14. Q54.How do you validate request bodies and parameters in a Node.js API?

    Define a schema for every endpoint's body, path parameters and query string, validate against it before the handler's logic runs, and work only with the validated, typed result afterwards.

    • Types and formats: query string and path values always arrive as strings, so convert numbers and booleans explicitly and reject anything that does not convert cleanly.
    • Unknown keys: reject or strip them; passing a whole body into a database update lets clients set fields such as role or isAdmin.
    • Type confusion: JSON can send objects where you expect strings, such as {"$gt": ""} in a password field, so check types, not just presence.
    • Limits: cap body size in the parser, string lengths, array lengths and nesting depth.
    • Content type: accept only the media types the endpoint supports.
    • Clear errors: respond with 400 or 422 and a list of invalid fields.

    Keep schemas reusable: the same schema can drive validation, static types and API documentation, so the three never drift apart.

    Validation complements, but never replaces, authorization checks, parameterized queries and output encoding.

  15. Q55.How do you structure a Node.js backend as it grows?

    Separate HTTP concerns, business rules and data access, and group code by feature as the service grows. Wire dependencies together in one place at startup, so modules receive what they need instead of importing global singletons.

    flowchart LR
      A["Routes and controllers"] --> B["Services with business rules"]
      B --> C["Repositories"]
      B --> D["Clients for other services"]
      C --> E["Database"]
      F["Startup composition"] --> A
      F --> B
      F --> C
    • Routes and controllers: parse and validate the request, call a service, shape the response.
    • Services: business rules and transactions, independent of HTTP so they can be reused by jobs and scripts.
    • Repositories and clients: all database queries and outgoing calls, easy to replace with fakes in tests.
    • Startup composition: creates the database pool, clients, services and the HTTP server, and passes each its dependencies through factory functions or constructors.

    As it grows:

    1. Organize folders by feature, such as orders and billing, each with its own routes, service and repository.
    2. Keep configuration loading and validation in one module.
    3. Share types and schemas across layers, and enforce import boundaries with lint rules.
  16. Q56.How do you hash passwords and compare secrets safely in Node.js?

    Hash passwords with a slow, salted key-derivation function such as crypto.scrypt, store the salt with the hash, and compare results with crypto.timingSafeEqual. Never store passwords in plain text, with reversible encryption, or with a fast hash such as SHA-256 alone.

    • Use the async version: crypto.scrypt runs on the thread pool, while scryptSync blocks the event loop for every login.
    • Random salt per password from crypto.randomBytes, so identical passwords produce different hashes.
    • Store parameters too, so you can raise the cost later and rehash users when they next log in.
    • Constant-time comparison: timingSafeEqual needs Buffers of equal length; comparing strings with === can leak information through timing.
    • Tokens: generate session IDs and reset tokens with crypto.randomBytes or crypto.randomUUID, never Math.random.

    Capacity planning: each hash deliberately takes tens of milliseconds and uses a thread-pool slot, so login bursts can queue other thread-pool work. Rate-limit login attempts, and consider a larger thread pool or a separate authentication service under heavy load.

    Example

    Hashing with a random salt, verifying the correct and a wrong password, and showing that salts make equal passwords hash differently.

    const crypto = require('node:crypto');
    const { promisify } = require('node:util');
    
    const scrypt = promisify(crypto.scrypt);
    
    async function hashPassword(password) {
      const salt = crypto.randomBytes(16);
      const key = await scrypt(password, salt, 64);
      return `${salt.toString('hex')}:${key.toString('hex')}`;
    }
    
    async function verifyPassword(password, stored) {
      const [saltHex, keyHex] = stored.split(':');
      const expected = Buffer.from(keyHex, 'hex');
      const actual = await scrypt(password, Buffer.from(saltHex, 'hex'), expected.length);
      return crypto.timingSafeEqual(actual, expected);
    }
    
    async function main() {
      const stored = await hashPassword('blue-harbor-19');
      console.log(stored.split(':')[0].length); // 32
      console.log(await verifyPassword('blue-harbor-19', stored)); // true
      console.log(await verifyPassword('Blue-harbor-19', stored)); // false
      console.log((await hashPassword('same')) === (await hashPassword('same'))); // false
    }
    
    main();
  17. Q57.How do you prevent SQL injection and query operator injection in Node.js?

    Never build queries by concatenating input into query text. Use placeholders that the database driver fills separately, and validate types so that objects cannot sneak operators into queries for document databases.

    SQL databases:

    • Use the driver's placeholders, such as $1 or ?, with a separate array of values.
    • Watch tagged template helpers: a tag that parameterizes values is safe, but plain template literals passed as query text are not.
    • Pick identifiers such as column names and sort direction from an allowlist, since they cannot be parameters.

    Document databases: a JSON body can carry an object with query operators where a string was expected. If code passes it straight into a filter, the operator changes the query's meaning:

    {"email": "admin@example.com", "password": {"$ne": ""}}

    Here a "not equal to empty" condition replaces the password check. Prevent it by validating that each field is the expected primitive type, and by rejecting keys that start with operator characters.

    Defense in depth: use a database account with only the permissions the service needs, and never log full queries with sensitive values.

  18. Q58.How do you size and manage database connection pools in Node.js?

    Create one pool per process at startup and share it, borrow a connection only for the duration of a query or transaction, and size pools so the total across every process stays below what the database can handle.

    • Total connections: pool_size × processes_per_instance × instances must stay below the database's connection limit, with room for migrations, admin tools and failover.
    • Small pools are often faster: a database does not run hundreds of queries truly in parallel; extra connections mostly add contention.
    • Acquire timeout: fail fast with a clear error when no connection is free, instead of waiting forever.
    • Release on every path: release in a finally block, or use helpers that do it for you; a leaked connection on an error path slowly drains the pool.
    • Transactions pin a connection: every query in a transaction must use the same client, not the shared pool.

    Monitor: waiting requests, active versus idle connections and acquire time. Rising wait time with a busy database means the database is the bottleneck; with an idle database, it means the pool is too small or connections are leaking.

    Example

    A database allows 200 connections, and 20 are reserved for administration and migrations.

    1. Usable connections: 200 − 20 = 180.
    2. The service runs 6 instances with 4 processes each, so 24 pools.
    3. A pool size of 10 would need 24 × 10 = 240 connections, which exceeds the limit during peaks.
    4. A pool size of 7 needs 24 × 7 = 168, which fits; if autoscaling may add instances, the pool size must be reduced further, or a connection pooler placed in front of the database.
  19. Q59.How do you run a database transaction correctly with a pooled client in Node.js?

    Check out one client from the pool, run BEGIN, every statement and COMMIT on that same client, roll back if anything throws, and always release the client in a finally block. A helper that takes the work as a callback guarantees this pattern everywhere.

    sequenceDiagram
      participant H as Handler
      participant P as Pool
      participant C as Checked-out client
      H->>P: Check out a client
      P-->>H: Client
      H->>C: BEGIN
      H->>C: Every statement of the transaction
      H->>C: COMMIT, or ROLLBACK if anything threw
      H->>P: Release the client in finally
    • Same client: queries sent through the pool may land on different connections, so they would not be part of the transaction.
    • Rollback on error: catch any failure, issue ROLLBACK, then rethrow so callers see the error.
    • Release always: release in finally, so neither success nor failure leaks the connection.
    • Short transactions: do not call slow external services inside one, since locks and the connection stay held.
    • Retries: on deadlocks or serialization failures, retry the whole callback, which only works if the callback has no external side effects.

    Many drivers and query builders offer a built-in transaction helper with this exact shape; use it when available rather than writing your own.

    Example

    A failing statement inside the transaction triggers a rollback, and the client is released exactly once.

    function createFakePool() {
      const log = [];
      let released = 0;
      return {
        log,
        get released() {
          return released;
        },
        async connect() {
          return {
            async query(sql) {
              log.push(sql);
              if (sql.startsWith('UPDATE stock')) throw new Error('out of stock');
            },
            release() {
              released++;
            },
          };
        },
      };
    }
    
    async function withTransaction(pool, work) {
      const client = await pool.connect();
      try {
        await client.query('BEGIN');
        const result = await work(client);
        await client.query('COMMIT');
        return result;
      } catch (error) {
        await client.query('ROLLBACK');
        throw error;
      } finally {
        client.release();
      }
    }
    
    async function main() {
      const pool = createFakePool();
      try {
        await withTransaction(pool, async (client) => {
          await client.query('INSERT INTO orders VALUES (...)');
          await client.query('UPDATE stock SET quantity = quantity - 1');
        });
      } catch (error) {
        console.log(error.message); // out of stock
      }
      console.log(pool.log.join(' | ')); // BEGIN | INSERT INTO orders VALUES (...) | UPDATE stock SET quantity = quantity - 1 | ROLLBACK
      console.log(pool.released); // 1
    }
    
    main();
  20. Q60.How do you avoid many small queries when resolving related data in Node.js?

    Use a batching loader: every call to load(id) within the same tick is queued, and on the next tick the loader runs one query for all the queued IDs, then hands each caller its own result. Code can still look up records one at a time, but the database sees a single query.

    Why it fits Node.js: handlers that resolve related records in parallel, such as an author for each of 50 posts, all call the loader within one turn of the event loop, so they naturally fall into one batch.

    Design points:

    • Deduplicate keys, so the same ID is requested once.
    • Return results in key order, with a clear value, such as null, for missing records.
    • One loader per request: creating loaders per request prevents one user's cached results leaking to another and keeps memory short-lived.
    • Cap batch size, splitting very large batches to avoid huge IN lists.

    This pattern is standard in GraphQL servers, where nested fields would otherwise trigger one query per item, but it helps any code that loads related data in loops.

    Example

    Four lookups for authors become one deduplicated query.

    function createBatchLoader(batchFn) {
      let queue = [];
      return function load(key) {
        return new Promise((resolve, reject) => {
          queue.push({ key, resolve, reject });
          if (queue.length === 1) {
            process.nextTick(async () => {
              const batch = queue;
              queue = [];
              try {
                const results = await batchFn(batch.map((item) => item.key));
                batch.forEach((item, i) => item.resolve(results[i]));
              } catch (error) {
                batch.forEach((item) => item.reject(error));
              }
            });
          }
        });
      };
    }
    
    const queries = [];
    const authors = { 1: 'Asha', 2: 'Ravi', 3: 'Meera' };
    const loadAuthor = createBatchLoader(async (ids) => {
      const unique = [...new Set(ids)];
      queries.push(`SELECT id, name FROM authors WHERE id IN (${unique.join(', ')})`);
      return ids.map((id) => authors[id] ?? null);
    });
    
    const posts = [{ author: 1 }, { author: 2 }, { author: 1 }, { author: 3 }];
    Promise.all(posts.map((post) => loadAuthor(post.author))).then((names) => {
      console.log(names.join(',')); // Asha,Ravi,Asha,Meera
      console.log(queries.length); // 1
      console.log(queries[0]); // SELECT id, name FROM authors WHERE id IN (1, 2, 3)
    });