Message Queues (RabbitMQ & Kafka)
5 questions found
How would you publish and consume messages using RabbitMQ in a Node.js application via the amqplib package?
Intermediate
amqplib provides a client for connecting to RabbitMQ, declaring queues (or more commonly, exchanges that route messages to queues based on rules), publishing messages to an exchange, and consuming messages from a queue via a callback invoked for each incoming message -- with the consumer responsible for explicitly acknowledging (ack) each message once it's been successfully processed, which is what allows RabbitMQ to guarantee at-least-once delivery even if a consumer crashes mid-processing.
const amqp = require('amqplib');
const connection = await amqp.connect('amqp://localhost');
const channel = await connection.createChannel();
await channel.assertQueue('orders');
// Publisher
channel.sendToQueue('orders', Buffer.from(JSON.stringify({ orderId: 123 })));
// Consumer
channel.consume('orders', (msg) => {
const order = JSON.parse(msg.content.toString());
processOrder(order);
channel.ack(msg); // acknowledge only after successful processing
});
Real-world example
An order-processing service publishes a message to a RabbitMQ queue whenever a new order is placed, with a separate, independently scalable worker process consuming from that queue and only acknowledging each message after successfully saving the order to the database, ensuring a worker crash mid-processing results in the message being redelivered rather than silently lost.
Common follow-ups: What happens to an unacknowledged message if the consumer that received it crashes before calling ack()?;How do RabbitMQ exchanges (direct, topic, fanout) differ in how they route published messages to queues?
Background Jobs & Queues;Microservices Architecture with Node.js
How does Kafka's architecture (topics, partitions, consumer groups) differ fundamentally from RabbitMQ's queue-based model?
Advanced
Kafka organizes messages into topics, each split into multiple partitions for parallelism, with messages retained on disk for a configurable period (or indefinitely) rather than being removed once consumed -- this log-based, retained-message model lets multiple independent consumer groups each read the same topic at their own pace, and lets a consumer replay historical messages by resetting its offset, fundamentally different from RabbitMQ's queue model where a message is typically removed once acknowledged and consumed by (usually) one consumer.
const { Kafka } = require('kafkajs');
const kafka = new Kafka({ clientId: 'my-app', brokers: ['localhost:9092'] });
const producer = kafka.producer();
await producer.send({ topic: 'orders', messages: [{ key: String(orderId), value: JSON.stringify(order) }] });
const consumer = kafka.consumer({ groupId: 'order-processors' });
await consumer.subscribe({ topic: 'orders' });
await consumer.run({ eachMessage: async ({ message }) => processOrder(JSON.parse(message.value)) });
Real-world example
An analytics platform uses Kafka rather than RabbitMQ specifically because it needs both a real-time consumer processing events as they arrive and a separate batch-processing job that periodically replays the last 24 hours of the same event stream from the beginning -- a capability RabbitMQ's queue-based, consume-once model doesn't naturally support.
Common follow-ups: Why is message ordering only guaranteed within a single Kafka partition, not across an entire topic?;How does the choice of a message's partition key affect both ordering guarantees and load distribution across consumers?
Architecture & Design Patterns;Microservices Architecture with Node.js
What is the difference between message acknowledgment modes (auto-ack vs manual ack) in a message queue, and why does manual ack matter for reliability?
Intermediate
Auto-acknowledgment marks a message as successfully processed the instant it's delivered to a consumer, before any actual processing logic has even run -- if the consumer then crashes while processing, that message is permanently lost since the broker already considers it delivered. Manual acknowledgment defers this until the consumer explicitly confirms successful processing, so if a crash happens mid-processing, the broker detects the missing acknowledgment (typically via a connection loss) and redelivers the message to another consumer, providing genuine at-least-once delivery guarantees.
// Risky: auto-ack, message is 'confirmed received' before processing even starts
channel.consume('orders', processOrder, { noAck: true });
// Reliable: manual ack, only confirmed after processing genuinely succeeds
channel.consume('orders', async (msg) => {
await processOrder(JSON.parse(msg.content.toString()));
channel.ack(msg); // only now does the broker consider it handled
}, { noAck: false });
Real-world example
A payment-processing consumer that used auto-ack for simplicity lost several in-flight payment confirmation messages during a deployment restart; switching to manual acknowledgment, only calling ack() after the payment was fully and successfully recorded, ensured any interrupted messages were automatically redelivered and retried instead of silently disappearing.
Common follow-ups: What's the tradeoff of manual acknowledgment in terms of the possibility of processing the same message twice (at-least-once rather than exactly-once)?;How does this relate back to the job idempotency concept discussed for background job queues?
Background Jobs & Queues;Error Handling
How would you implement a dead-letter exchange in RabbitMQ to handle messages that repeatedly fail processing?
Advanced
A dead-letter exchange (DLX) is configured on a queue to automatically receive messages that are rejected (nack'd without requeue), expire (via a message TTL), or exceed a queue's maximum length -- routing these problematic messages to a separate queue for inspection rather than losing them or endlessly retrying them in the main queue, mirroring the dead-letter queue concept discussed earlier for job queues but implemented at the message-broker level.
await channel.assertExchange('dlx', 'direct');
await channel.assertQueue('failed-orders');
await channel.bindQueue('failed-orders', 'dlx', '');
await channel.assertQueue('orders', {
arguments: { 'x-dead-letter-exchange': 'dlx' },
});
channel.consume('orders', (msg) => {
try {
processOrder(msg);
channel.ack(msg);
} catch (err) {
channel.nack(msg, false, false); // false, false = don't requeue -- routes to the DLX instead
}
});
Real-world example
An order-processing queue configured with a dead-letter exchange automatically routes any order message that fails processing (due to a malformed payload, say) to a separate 'failed-orders' queue, where it's inspected and can be manually corrected and republished, rather than being silently dropped or endlessly reprocessed and failing in a loop.
Common follow-ups: How do you distinguish between a transient failure worth retrying automatically versus a permanent failure that should go straight to the dead-letter queue?;What monitoring would you set up to alert when messages start accumulating in a dead-letter queue?
Error Handling;Logging & Monitoring
When would you choose a message queue/broker (RabbitMQ or Kafka) over simply making direct HTTP calls between Node.js microservices?
Intermediate
Direct HTTP calls create tight temporal coupling -- both services must be available simultaneously, and the calling service blocks waiting for a response, with a failure in the downstream service directly propagating as a failure to the caller. A message broker decouples services in time (a consumer doesn't need to be running the instant a message is published; it'll process it whenever it comes back online) and in failure mode (a downstream service being temporarily down doesn't fail the publisher's operation, since the message simply waits in the queue), at the cost of added infrastructure and eventual (rather than immediate) consistency.
// Tightly coupled: caller blocks and fails if inventory-service is down
await fetch('http://inventory-service/reserve', { method: 'POST', body: JSON.stringify(order) });
// Decoupled: publish and move on; inventory-service processes whenever it's available
await channel.sendToQueue('inventory-reservations', Buffer.from(JSON.stringify(order)));
Real-world example
An order-placement service publishes an 'OrderPlaced' event to a queue rather than directly calling the inventory service's API synchronously, meaning a temporary outage in the inventory service doesn't prevent customers from placing orders -- inventory reservation simply happens a few seconds later once that service recovers and processes the backlog.
Common follow-ups: What's the cost of the eventual consistency this decoupling introduces, and in what scenarios is that unacceptable (like needing an immediate inventory check before confirming an order)?;How would you combine synchronous calls for time-sensitive checks with asynchronous messaging for everything else in the same order flow?
Architecture & Design Patterns;Microservices Architecture with Node.js