All posts
DevOps

Node.js Graceful Shutdown in Production

Arif Iqbal·July 15, 2026·4 min read

A deploy to production shouldn't drop requests. But by default, when Kubernetes sends SIGTERM to a Node.js process to roll it over, the process exits immediately — killing any in-flight requests mid-response.

Graceful shutdown is the fix. Here's how to implement it properly in NestJS.

What Happens Without It

During a rolling deploy, Kubernetes:

  1. Sends SIGTERM to the old pod
  2. Starts routing traffic to the new pod
  3. Waits terminationGracePeriodSeconds (default: 30s), then sends SIGKILL

Between steps 1 and 2 there's a gap — traffic is still hitting the old pod while it's shutting down. Without graceful shutdown, those requests fail.

The NestJS Built-in

NestJS has a enableShutdownHooks() method that handles SIGTERM and SIGINT, calling onModuleDestroy() on each module before exiting:

src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
 
  // Required for graceful shutdown hooks to fire
  app.enableShutdownHooks();
 
  await app.listen(3000);
  console.log('Application started on port 3000');
}
bootstrap();

Any service can then implement OnModuleDestroy:

src/database/database.service.ts
@Injectable()
export class DatabaseService implements OnModuleDestroy {
  async onModuleDestroy() {
    await this.dataSource.destroy();
    this.logger.log('Database connections closed');
  }
}

Draining In-Flight Requests

The built-in hooks close the app, but they don't wait for in-flight HTTP requests to complete. For that, you need to stop accepting new connections first, then wait for active requests to drain:

src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();
 
  const server = await app.listen(3000);
 
  // On SIGTERM: stop accepting new connections, drain existing ones
  process.on('SIGTERM', async () => {
    console.log('SIGTERM received — starting graceful shutdown');
 
    // Stop accepting new HTTP requests
    server.close(async () => {
      console.log('HTTP server closed');
 
      // Let NestJS close modules (DB connections, queues, etc.)
      await app.close();
      console.log('Application closed');
      process.exit(0);
    });
 
    // Force exit if drain takes too long
    setTimeout(() => {
      console.error('Forced exit after timeout');
      process.exit(1);
    }, 25_000); // 5s before Kubernetes SIGKILL
  });
}
bootstrap();

Closing Queue Workers

BullMQ workers should finish their current job before shutting down — not abandon it mid-process:

src/queues/queue-shutdown.service.ts
@Injectable()
export class QueueShutdownService implements OnModuleDestroy {
  constructor(
    @InjectQueue('orders') private readonly ordersQueue: Queue,
  ) {}
 
  async onModuleDestroy() {
    // Close the queue — no new jobs accepted
    await this.ordersQueue.close();
 
    // Wait for active workers to finish their current job
    // Worker.close() waits for the current job to complete
    await this.ordersWorker.close();
 
    this.logger.log('Queue workers shut down cleanly');
  }
}

Health Check During Shutdown

Add a /health endpoint that returns 503 once shutdown has started. Kubernetes uses this (via readinessProbe) to stop routing traffic before the process exits:

src/health/health.controller.ts
@Controller('health')
export class HealthController {
  private isShuttingDown = false;
 
  constructor() {
    process.on('SIGTERM', () => {
      this.isShuttingDown = true;
    });
  }
 
  @Get()
  check() {
    if (this.isShuttingDown) {
      throw new ServiceUnavailableException('Shutting down');
    }
    return { status: 'ok', timestamp: new Date().toISOString() };
  }
}

Kubernetes Configuration

k8s/deployment.yaml
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                # Give the load balancer time to stop routing traffic
                # before SIGTERM is sent
                command: ["/bin/sh", "-c", "sleep 5"]

The preStop hook adds a 5-second sleep before SIGTERM. This gives the load balancer and Kubernetes enough time to remove the pod from the service endpoints — so no new traffic arrives before shutdown begins.

The preStop + terminationGracePeriodSeconds relationship

The preStop hook duration counts against terminationGracePeriodSeconds. With a 5s preStop and 30s grace period, your app has 25s to drain. Set your setTimeout in the shutdown handler to 25s, not 30s.

Unhandled Rejection Guard

One more thing — unhandled promise rejections can crash your process without graceful shutdown:

src/main.ts
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
  // In production, you may want to exit and let the orchestrator restart
  // process.exit(1);
});
 
process.on('uncaughtException', (err) => {
  console.error('Uncaught exception:', err);
  process.exit(1); // Always exit on uncaught exceptions
});

The complete checklist for zero-dropped-requests deploys:

  1. app.enableShutdownHooks() in NestJS
  2. server.close() on SIGTERM to stop accepting new HTTP connections
  3. readinessProbe returning 503 during shutdown
  4. preStop sleep to drain load balancer
  5. Queue workers closed with worker.close() (waits for current job)
  6. terminationGracePeriodSeconds set to at least 30s

All of these together — not just one or two.


node.jsnestjsdevopskubernetesproduction

Arif Iqbal

Senior Backend Engineer with 10+ years building high-traffic platforms. NestJS · Node.js · Laravel · AWS · PostgreSQL. Open to remote & relocation.

Enjoyed this post?

Get my technical deep-dives in your inbox. No spam, unsubscribe anytime.

Discussion