Last Minute Notes

Node.js and Spring Boot

The two most common backend stacks: the Node.js runtime, event loop, worker threads and frameworks (Express, Fastify, NestJS), and Java Spring Boot 4 with dependency injection, MVC, JPA, @Transactional pitfalls and virtual threads. Runnable demos included.

📘 Node.js and Spring Boot

Most backend interviews use either JavaScript on Node.js or Java with Spring Boot. This page explains how each runtime executes your code, how the frameworks are organized, and the pitfalls that come up in questions: blocking the event loop, dependency injection, and why @Transactional sometimes does nothing. Two runnable demos show the event loop and the proxy mechanism behind Spring transactions.

Snippets for Express, Fastify, NestJS and Spring are illustrative (they need their frameworks), while the demos marked as executed were run.

Table of Contents

  1. Choosing a Stack
  2. Node.js Runtime
  3. Node.js Frameworks
  4. Spring Boot
  5. Spring Transactions and Proxies
  6. Spring Data JPA Pitfalls
  7. Comparing the Stacks
  8. Questions and Answers

1. Choosing a Stack

Node.jsJava with Spring BootOthers
LanguageJavaScript or TypeScriptJava (also Kotlin)Go, Python (FastAPI, Django), C# (.NET), Rust (Axum), Ruby (Rails)
ConcurrencySingle-threaded event loop, async I/OThread pool, or virtual threads (Java 21+)Goroutines (Go), asyncio (Python), async/await (.NET, Rust)
StrengthsOne language across the stack, huge ecosystem, fast startup, great for I/O-bound APIs and real-timeMature ecosystem, strong typing, tooling, enterprise integration, performance on CPU-bound and long-lived servicesGo: simple and fast services. Python: data and ML. .NET: enterprise on Microsoft stack
Watch out forCPU-bound work blocking the loop, dependency sprawl, loose typing without TypeScriptVerbose setup, heavier memory and startup (mitigated by native images), annotation magic

Pick by team skills and the problem. For an interview, be able to explain the runtime model of whichever you claim, because that is where the follow-ups go.


2. Node.js Runtime

2.1 Versions and Modern Features

  • Node.js 24 is the current long-term-support line (supported through April 2028), Node 22 is in maintenance, and Node 26 is the current release line as of 2026. Even-numbered releases become LTS.
  • Native TypeScript: modern Node can run .ts files directly by stripping types (no type checking), so simple scripts and servers need no build step. You still run tsc for type checking. Features that need code generation (enums, parameter properties) are not supported by stripping alone.
  • Built-ins that replace packages: fetch, AbortController, URL, crypto.randomUUID(), structuredClone, a test runner (node --test), --watch, --env-file, node:sqlite, and the permission model (--permission) to restrict file and network access.
  • ES modules (import) are the standard. CommonJS (require) still works, and recent versions can require() synchronous ES modules.

2.2 The Event Loop

Node runs your JavaScript on one thread. I/O (network, files, timers) is handed to the operating system or a small thread pool (libuv, default 4 threads), and callbacks are queued to run on the main thread when the work finishes.

Rules that follow:

  • Microtasks (process.nextTick, resolved promises) run between every callback, before the loop moves on. await continuations are microtasks.
  • Timers have a minimum delay, not an exact one.
  • Anything synchronous and slow blocks every request: a big JSON.parse, a for loop over millions of items, a regex with catastrophic backtracking, synchronous fs or crypto calls, bcrypt hashing done synchronously.
  • Node is great at I/O-bound work (waiting on databases and APIs) and needs care for CPU-bound work.

The demo below prints the order of events and then measures event-loop lag: the same CPU task stalls the loop on the main thread and does not in a worker thread. Deeper coverage of the loop and promises is in JavaScript notes.

// runnable
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');
const fs = require('node:fs');

function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }

if (!isMainThread) {
  parentPort.postMessage(fib(workerData));
} else {
  // Part 1: the order in which the event loop runs things
  console.log('1 sync');
  process.nextTick(() => console.log('2 process.nextTick'));
  Promise.resolve().then(() => console.log('3 promise microtask'));
  fs.readFile(__filename, () => {
    setTimeout(() => console.log('5 setTimeout 0'), 0);
    setImmediate(() => console.log('4 setImmediate'));      // inside an I/O callback, immediate always runs first
  });

  // Part 2: a CPU-bound task blocks the loop, a worker thread does not
  const measureLag = (work) => new Promise((resolve) => {
    let worst = 0;
    let last = Date.now();
    const timer = setInterval(() => {                        // should fire every 10 ms if the loop is healthy
      const now = Date.now();
      worst = Math.max(worst, now - last - 10);
      last = now;
    }, 10);
    setTimeout(async () => {
      await work();
      setTimeout(() => { clearInterval(timer); resolve(worst); }, 30);
    }, 50);
  });

  const onMainThread = async () => { fib(38); };
  const inWorker = () => new Promise((resolve, reject) => {
    const w = new Worker(__filename, { workerData: 38 });
    w.once('message', resolve);
    w.once('error', reject);
  });

  setTimeout(async () => {
    const blocked = await measureLag(onMainThread);
    const offloaded = await measureLag(inWorker);
    console.log('event loop stalled 100 ms or more with CPU work on the main thread:', blocked >= 100);
    console.log('event loop stayed responsive with the work in a worker thread:', offloaded < 100);
  }, 100);
}

2.3 Scaling Node

TechniqueUse
worker_threadsOffload CPU-bound work (image processing, hashing, parsing) to threads, with a worker pool (Piscina)
cluster or a process manager (PM2)One process per CPU core behind a shared port, uses all cores, restarts crashed workers
ContainersOne process per container, scale replicas with the orchestrator (the modern default)
StreamsProcess large data chunk by chunk with backpressure: pipeline(readable, transform, writable)
AsyncLocalStorageCarry request context (request id, user) through async calls without passing it everywhere
MemoryThe V8 heap has a default limit, raise with --max-old-space-size, watch for leaks (unbounded caches, listeners, closures)

2.4 Error Handling and Shutdown

  • Always handle promise rejections. An unhandled rejection can crash the process in modern Node. Use try/catch around await, and central error middleware.
  • On uncaughtException or unhandledRejection, log and exit, and let the supervisor restart a clean process, because state may be corrupt.
  • Handle SIGTERM for graceful shutdown: stop accepting requests, finish in-flight ones, close the database pool, then exit, see Deployment and Runtime.
  • Do not run untrusted or heavy synchronous code on the main thread.

3. Node.js Frameworks

FrameworkStyleNotes
Express 5Minimal, middleware chainMost widely known, huge ecosystem, Express 5 handles rejected promises from async handlers
FastifyFast, schema-based, plugin systemJSON Schema validation and serialization built in, strong TypeScript support, good default for new services
NestJSOpinionated, Angular-style modules, decorators, DIFamiliar to Spring developers, good for large teams
HonoTiny, Web-standard Request and ResponseRuns on Node, Bun, Deno and edge runtimes (Cloudflare Workers)
KoaMinimal successor to ExpressAsync middleware
Next.js route handlers, tRPCFull-stack integrationsBackend code colocated with a React frontend, tRPC gives end-to-end types without a REST layer

Middleware is the central idea: a request flows through a chain of functions, each can act, short-circuit, or pass on. The same pattern is in Design Patterns: chain of responsibility.

// Express: illustrative
import express from 'express';
import { z } from 'zod';

const app = express();
app.use(express.json());                                   // parse JSON bodies
app.use((req, res, next) => {                              // logging middleware
  const start = Date.now();
  res.on('finish', () => console.log(req.method, req.path, res.statusCode, Date.now() - start + 'ms'));
  next();
});

const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() });

app.post('/users', async (req, res) => {
  const parsed = CreateUser.safeParse(req.body);           // validate at the boundary
  if (!parsed.success) return res.status(422).json({ errors: parsed.error.flatten().fieldErrors });
  const user = await userService.create(parsed.data);
  res.status(201).location(`/users/${user.id}`).json(user);
});

app.use((err, req, res, next) => {                         // central error handler
  console.error(err);
  res.status(500).json({ error: 'internal error' });
});
// Fastify: schema drives validation, serialization and docs
app.post('/users', {
  schema: {
    body: { type: 'object', required: ['name'], properties: { name: { type: 'string', minLength: 1 } } },
    response: { 201: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } } },
  },
}, async (request, reply) => reply.code(201).send(await users.create(request.body)));

Practical points: use helmet for security headers, cors with an allowlist, pino for logging, a validation library at every entry point, and express-rate-limit or a gateway for throttling.


4. Spring Boot

Spring Boot builds production-ready Spring applications with auto-configuration and starters, so you write little setup code.

4.1 Versions

  • Spring Boot 4.0 was released in November 2025 on Spring Framework 7, with Jakarta EE 11 as the baseline, Jackson 3 as the default JSON library, and Java 17 as the minimum with Java 25 (the current LTS) supported. Spring Boot 4.1 followed in June 2026.
  • Virtual threads are supported on Java 21 and later: set spring.threads.virtual.enabled=true to run request handling, @Async and scheduled tasks on virtual threads, so blocking-style code scales to many concurrent requests.
  • Spring Boot 3.x remains widely deployed (Java 17 minimum, Jakarta EE 9+ namespaces jakarta.*).
  • GraalVM native images give fast startup and low memory at the cost of build time and reflection configuration.

4.2 Inversion of Control and Dependency Injection

Spring creates and wires objects (beans) for you. You declare what a class needs, and the container supplies it.

Prefer constructor injection (dependencies are final, required, and easy to unit test without Spring) over field injection with @Autowired.

// illustrative Spring Boot code
@RestController
@RequestMapping("/api/users")
class UserController {
    private final UserService users;

    UserController(UserService users) { this.users = users; }        // constructor injection

    @GetMapping("/{id}")
    UserResponse get(@PathVariable long id) { return users.find(id); }

    @PostMapping
    ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest req) {
        UserResponse created = users.create(req);
        return ResponseEntity.created(URI.create("/api/users/" + created.id())).body(created);
    }
}

record CreateUserRequest(@NotBlank String name, @Email String email) { }
record UserResponse(long id, String name, String email) { }

@Service
class UserService {
    private final UserRepository repo;
    UserService(UserRepository repo) { this.repo = repo; }

    @Transactional
    UserResponse create(CreateUserRequest req) {
        User saved = repo.save(new User(req.name(), req.email()));
        return new UserResponse(saved.getId(), saved.getName(), saved.getEmail());
    }
}

interface UserRepository extends JpaRepository<User, Long> {           // Spring Data generates the implementation
    Optional<User> findByEmail(String email);                           // derived query from the method name
}

@RestControllerAdvice
class ApiErrors {
    @ExceptionHandler(NotFoundException.class)
    ProblemDetail notFound(NotFoundException e) {                        // RFC 9457 support built in
        ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        p.setTitle("Resource not found");
        return p;
    }
}

4.3 What Spring Provides

AreaFeature
WebSpring MVC (servlet, thread-per-request or virtual threads) and WebFlux (reactive, non-blocking with Reactor)
Request flowDispatcherServlet finds the handler by URL and method, argument resolvers bind and validate input (@Valid), the method runs, message converters serialize the result, @ControllerAdvice handles exceptions
Configapplication.yml, profiles (dev, prod), environment variable overrides, type-safe @ConfigurationProperties
ValidationJakarta Bean Validation annotations on DTOs
DataSpring Data JPA, JDBC, MongoDB, Redis repositories, JdbcClient, Flyway or Liquibase migrations
SecuritySpring Security: filters, authentication, method security, OAuth2 resource server and client
OperationsActuator endpoints (/actuator/health, metrics, info), Micrometer metrics, OpenTelemetry tracing
Caching, scheduling, async@Cacheable, @Scheduled, @Async
Testing@SpringBootTest, slice tests (@WebMvcTest, @DataJpaTest), Testcontainers for real databases
MessagingKafka, RabbitMQ, JMS integrations

5. Spring Transactions and Proxies

@Transactional is not magic. Spring wraps your bean in a proxy that begins a transaction before the method, commits after it, and rolls back on failure. Because the mechanism is a proxy, several surprising behaviors follow.

The demo below reproduces the mechanism with java.lang.reflect.Proxy, so you can see exactly what happens:

// runnable
import java.lang.annotation.*;
import java.lang.reflect.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Transactional { }

interface OrderService {
    void placeOrder(String id);
    void placeOrderChecked(String id) throws Exception;
    void audit(String what);
}

class OrderServiceImpl implements OrderService {
    @Transactional
    public void placeOrder(String id) {
        System.out.println("    placing " + id);
        this.audit("placed " + id);                 // self-invocation: calls the target directly, NOT through the proxy
        if (id.startsWith("bad")) throw new IllegalStateException("payment failed");
    }
    @Transactional
    public void placeOrderChecked(String id) throws Exception {
        System.out.println("    placing " + id);
        throw new Exception("checked exception");
    }
    @Transactional
    public void audit(String what) { System.out.println("    audit: " + what); }
}

/** What Spring's @Transactional does: wrap the bean in a proxy that opens and closes a transaction around calls. */
class TxProxy implements InvocationHandler {
    private final Object target;
    private TxProxy(Object target) { this.target = target; }

    static <T> T wrap(T target, Class<T> iface) {
        return iface.cast(Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[] { iface }, new TxProxy(target)));
    }

    public Object invoke(Object proxy, Method m, Object[] args) throws Throwable {
        Method impl = target.getClass().getMethod(m.getName(), m.getParameterTypes());
        if (!impl.isAnnotationPresent(Transactional.class)) return m.invoke(target, args);
        System.out.println("  BEGIN    " + m.getName());
        try {
            Object result = m.invoke(target, args);
            System.out.println("  COMMIT   " + m.getName());
            return result;
        } catch (InvocationTargetException e) {
            Throwable cause = e.getCause();
            if (cause instanceof RuntimeException || cause instanceof Error)
                System.out.println("  ROLLBACK " + m.getName() + " (" + cause.getMessage() + ")");
            else
                System.out.println("  COMMIT   " + m.getName() + " despite the checked exception (" + cause.getMessage() + ")");
            throw cause;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        OrderService svc = TxProxy.wrap(new OrderServiceImpl(), OrderService.class);
        System.out.println("placeOrder(A1): audit() is called inside, but only ONE transaction opens");
        svc.placeOrder("A1");
        System.out.println("placeOrder(bad-2): a RuntimeException rolls back");
        try { svc.placeOrder("bad-2"); } catch (RuntimeException expected) { }
        System.out.println("placeOrderChecked(C3): a checked exception still commits by default");
        try { svc.placeOrderChecked("C3"); } catch (Exception expected) { }
        System.out.println("audit() called directly on the proxy: it does get its own transaction");
        svc.audit("direct call");
    }
}

Pitfalls this exposes:

PitfallWhyFix
Self-invocation ignores the annotationthis.audit() bypasses the proxy, so audit's own settings (REQUIRES_NEW, readOnly) do not applyMove the method to another bean and inject it, or restructure. Do not rely on annotations on internal calls
Checked exceptions do not roll back by defaultSpring mirrors EJB: only RuntimeException and Error trigger rollback@Transactional(rollbackFor = Exception.class), or throw unchecked exceptions
@Transactional on private or final methods, or non-Spring-managed objectsProxies cannot intercept themPublic methods on Spring beans
Swallowing an exception inside the methodThe proxy sees a normal return and commitsRethrow, or mark rollback-only
Wrong layerTransaction on a controller keeps it open during serialization and view renderingPut it on the service method that is the unit of work
Long transactionsLocks and connection held during remote callsKeep external calls outside the transaction

Propagation and isolation:

propagationBehavior
REQUIRED (default)Join the current transaction, or start one
REQUIRES_NEWSuspend the current one and start a new independent transaction (audit logs that must survive a rollback)
SUPPORTS, MANDATORY, NOT_SUPPORTED, NEVER, NESTEDLess common variants

readOnly = true is a hint (skips dirty checking in Hibernate, may route to a replica). isolation maps to database isolation levels, see Transactions. The transaction wraps a thread-bound connection, which is another reason not to spawn threads inside a transactional method and expect it to follow.


6. Spring Data JPA Pitfalls

JPA with Hibernate is powerful and the source of many production bugs.

PitfallSymptomFix
N+1 selectsLoading 100 orders triggers 100 extra queries for lazy customerJOIN FETCH in the query, @EntityGraph, batch fetching (hibernate.default_batch_fetch_size), or a DTO projection
LazyInitializationExceptionAccessing a lazy association after the session closedFetch what you need inside the transaction, return DTOs
Open Session in View (spring.jpa.open-in-view, on by default)Keeps a database connection open through the whole request and hides lazy loading in controllersTurn it off, load data explicitly in the service
Returning entities from controllersLeaks columns, lazy proxies, infinite recursion on bidirectional relationsMap to DTOs or records
EAGER fetching everywhereHuge joins for every queryDefault to LAZY, fetch per use case
findAll() on big tablesLoads everything into memoryPagination (Pageable), streaming, projections
equals and hashCode on entitiesBroken behavior in sets and collectionsBase on a business key or the id carefully
Bulk updates through entitiesLoads and updates rows one by oneJPQL or native bulk UPDATE, or JdbcClient
Schema generation in production (ddl-auto=update)Uncontrolled changesFlyway or Liquibase migrations, ddl-auto=validate

Enable SQL logging in development and count queries per request in tests. jOOQ and JdbcClient are good alternatives when you want to control SQL directly.


7. Comparing the Stacks

TopicNode.jsSpring Boot
ConcurrencyEvent loop, one thread per process, scale with processes and workersThread pool or virtual threads per request, many cores in one JVM
CPU-bound workWorker threads or another serviceNative threads, JIT-optimized, strong
Startup and memoryFast start, low memorySlower start and more memory (JIT warm-up), improved by native images and CDS
Type safetyTypeScript optional, runtime types erasedStatic typing, mature IDE refactoring
DI and structureManual or NestJSBuilt-in container, conventions
Ecosystemnpm, very large, uneven quality, supply chain riskMaven Central, mature enterprise libraries, Spring ecosystem
Best fitI/O-bound APIs, real-time, BFFs, full-stack JavaScript teamsLarge business systems, complex domains, heavy integration, long-lived services
Testingnode --test, Vitest, Jest, SupertestJUnit 5, Mockito, Spring test slices, Testcontainers
ObservabilityOpenTelemetry SDK, pinoMicrometer, Actuator, OpenTelemetry Java agent

Both scale horizontally as stateless services behind a load balancer. The database, not the framework, is usually the bottleneck, see Performance and Caching.


8. Questions and Answers

Q1. Why is Node.js good for I/O-bound but weak for CPU-bound work? One thread runs all JavaScript, and I/O is delegated to the OS. Waiting costs nothing, but a CPU-heavy synchronous task blocks every other request until it finishes.

Q2. How do you handle CPU-intensive work in Node? Offload to worker_threads (with a pool), a separate service or queue worker, or native addons, and keep the event loop free.

Q3. Order of process.nextTick, promises, setTimeout and setImmediate? Synchronous code first, then nextTick, then promise microtasks, then timers or check-phase callbacks according to the loop phase. Inside an I/O callback setImmediate runs before setTimeout(…, 0).

Q4. What is middleware? A function in a chain that receives the request and response and can modify them, end the response, or call next(). Used for logging, auth, parsing, rate limiting and error handling.

Q5. What is dependency injection and why use it? Supplying an object's dependencies from outside instead of creating them inside. It decouples classes, enables swapping implementations, and makes unit tests simple.

Q6. Why prefer constructor injection in Spring? Dependencies are explicit, final, and required, the object is valid after construction, and tests can create it without a container.

Q7. Why did my @Transactional method not roll back? Common causes: a checked exception (no rollback by default), the exception was caught inside, the method was called from the same class (proxy bypassed), or the method is not public on a Spring bean.

Q8. What is the N+1 problem in JPA and how do you fix it? One query loads the parents and one more per parent loads lazy children. Use fetch joins, entity graphs, batch fetching or DTO projections.

Q9. Spring MVC vs WebFlux? MVC uses blocking, thread-per-request style, simpler and fine with virtual threads. WebFlux is non-blocking and reactive, useful for streaming and very high concurrency with reactive drivers, at the cost of complexity.

Q10. What do virtual threads change? Blocking code no longer wastes an OS thread while waiting, so you can write simple synchronous code and still serve very many concurrent requests. Downstream limits (database pool size) still apply.

Q11. How do you run a Node app on all CPU cores? cluster or a process manager to run one process per core, or run several container replicas behind a load balancer.

Q12. What is Actuator? Spring Boot's production-ready features: health, metrics, environment info and other endpoints for monitoring and management, secured and selectively exposed.

Next: API Design.

On this page