DEV Community

Cover image for From Chained REST Calls to Events: Kafka Microservices in Node.js
Devlogix
Devlogix

Posted on

From Chained REST Calls to Events: Kafka Microservices in Node.js

A checkout looks simple. The customer clicks “Place order” and sees a confirmation. Behind that click, the order service calls inventory, then payments, then notifications, all inside one HTTP request. That works until the email provider slows down and checkout slows with it, or a payment timeout triggers a client retry and a double charge.
This guide restructures that flow as event-driven microservices using Apache Kafka, Node.js, and KafkaJS, and covers duplicates, poison messages, ordering, and lost events. The code is simplified: a starting point, not a production service.

Why synchronous chains hurt

Chained calls create two kinds of coupling. Temporal coupling means every service must be healthy at the moment of the click, so a slow notification service can degrade your most important path. Knowledge coupling means the order service must know every downstream consumer. Retries add risk: after a timeout, the caller cannot tell whether the callee ever got the request.
Synchronous calls are not wrong: a price quote needs an answer now. Events fit work that is really a notification of something that already happened.
Event-driven basics
An event is an immutable record that something happened, named in the past tense: OrderCreated, PaymentFailed. A command asks a service to act, such as ChargeCard. In event-driven design, services publish facts and let others decide how to react. Three roles appear in every design:
• A producer publishes events without knowing who listens.
• A consumer subscribes to events and reacts.
• A broker stores events durably and serves them to consumers. Here, that is Kafka.
Because the producer finishes when the broker acknowledges the write, the system is eventually consistent: POST /orders should return 202 Accepted with a PENDING order.
Why Kafka and Node.js work well together
Kafka is a distributed, append-only log, not a queue. Consumers track their own position, called an offset, and reading never deletes an event. Many consumer groups can read one topic independently, a new service can replay history, and partitions provide parallelism.
Most handlers are I/O-bound, which Node.js handles well. The caveat is CPU-heavy work: it blocks the event loop, delays heartbeats, and can trigger rebalances. Move it to worker threads or another service. If you are still shaping the API side, DevLogix’s full-stack web development overview shows how its team pairs typed Node.js back-ends with modern front-ends.
This guide uses KafkaJS, a pure JavaScript client whose latest stable release is 2.2.4. Release activity has been slow, so for production also evaluate Confluent’s @confluentinc/kafka-javascript, whose API is designed to feel familiar to KafkaJS users.
The example: an order flow
Four services take part: order, inventory, payment, and notification. None calls another directly.
order-service --OrderCreated--> orders.events
inventory-service --StockReserved | StockRejected--> inventory.events
payment-service --PaymentCompleted | PaymentFailed--> payments.events
order-service confirms or cancels; notification-service emails
This style is choreography: each service reacts to what it observes. Failures use compensating events (a saga): if payment fails, the order is cancelled and stock released. Three decisions come first:
• One topic per domain stream, keyed by orderId, so one order’s events stay in sequence.
• A consistent envelope: eventId, type, version, occurredAt, and data.
• No sensitive data in events. Publish a payment reference, never a card number. Isolating payments also limits compliance scope, as DevLogix’s guide to PCI-DSS compliant fintech architecture explains.
Building the flow with KafkaJS
You need Node.js 18+ and Docker. Start a local single-node broker (no auth, no TLS, development only) and install the client:
docker run -d -p 9092:9092 apache/kafka:3.9.0
`npm install kafkajs
Create topics explicitly with admin.createTopics rather than relying on auto-creation. Then publish from the order service:
const { Kafka } = require('kafkajs');
const { randomUUID } = require('node:crypto');

const kafka = new Kafka({ clientId: 'order-service', brokers: ['localhost:9092'] });
const producer = kafka.producer({ idempotent: true, maxInFlightRequests: 1 });

async function publish(topic, type, orderId, data) {
const event = { eventId: randomUUID(), type, version: 1,
occurredAt: new Date().toISOString(), orderId, data };
await producer.send({ topic, acks: -1,
messages: [{ key: orderId, value: JSON.stringify(event) }] });
}
The key sends every event for one order to one partition. acks: -1 waits for all in-sync replicas, and idempotent: true avoids duplicates when the producer retries internally. Now consume in the inventory service:
const consumer = kafka.consumer({ groupId: 'inventory-service' });
await consumer.connect();
await consumer.subscribe({ topics: ['orders.events'], fromBeginning: true });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString());
if (event.type === 'OrderCreated') await reserveStock(event);
},
});
KafkaJS commits the offset after eachMessage resolves. If the handler throws, the message is retried and may arrive again. That is at-least-once delivery, and it shapes everything below. Restart a stopped consumer and it resumes where it left off, because offsets live in Kafka.
Concepts that shape your design
Partitions and keys. Kafka guarantees order within a partition, not across a topic. Key by the entity that must be processed in order: here the order, for a ledger the account. Partition count caps parallelism, and adding partitions later changes key mapping.
Consumer groups. Each partition goes to one group member, so with three partitions, five instances leave two idle. Separate groups are independent: the notification service receives every event without affecting inventory.
Commits. Committing before processing risks lost messages; committing after risks repeats. Most systems choose repeats and tolerate them.
Reliability and failure handling
Make duplicates harmless. For events you cannot lose, use acks: -1, replication factor 3, and min.insync.replicas=2. Then make consumers idempotent by recording processed event IDs in the same database transaction as the business change:
const claim = await client.query(
INSERT INTO processed_events (event_id, consumer)
VALUES ($1, 'inventory-service') ON CONFLICT DO NOTHING, [event.eventId]);
if (claim.rowCount === 0) return; // duplicate: skip
// update stock in the same transaction, then COMMIT
For external calls such as card charges, pass a stable idempotency key to the provider. Expected outcomes like out-of-stock are business results: publish StockRejected rather than throwing.
Poison messages. A message that can never succeed, such as malformed JSON, blocks its partition if retried forever. Retry transient failures a bounded number of times with backoff, then publish the message to a dead-letter topic (orders.events.dlq) with the original topic, offset, and error in headers. Alert on DLQ volume, or nobody will look.
Ordering. There is none across partitions or topics, so check current state, such as ignoring PaymentFailed for an already cancelled order.
The dual-write problem. If the order service saves an order and then publishes, a crash between the two leaves an order with no event. The transactional outbox fixes this: write the business row and an outbox row in one transaction, and let a relay process publish outbox rows to Kafka.
await client.query('BEGIN');
const { rows } = await client.query(
INSERT INTO orders (customer_id, status) VALUES ($1, 'PENDING') RETURNING id,
[customerId]);
await client.query(
INSERT INTO outbox (message_key, topic, payload) VALUES ($1, 'orders.events', $2)`,
[String(rows[0].id), event]);
await client.query('COMMIT');
The relay may crash after publishing and send a row again, which idempotent consumers absorb. Several relays can reorder events, so run one or shard by key. Debezium-style change data capture is an alternative.
Exactly-once? Kafka transactions only cover Kafka-to-Kafka flows. Once a handler touches a database or email provider, aim for at-least-once delivery plus idempotent processing.
Security and production readiness
Never run production in plaintext. Use TLS and SASL authentication, loading credentials from a secrets manager. Give each service its own identity and least-privilege ACLs: inventory reads orders.events and writes inventory.events, nothing more.
Event-driven systems fail quietly, so watch consumer lag (alert on sustained growth), DLQ volume, and rebalance frequency. Copy a correlation ID through event headers to trace one order across services.
Events outlive their code: add optional fields, never rename existing ones, and keep a version in the envelope. As teams multiply, a schema registry with Avro, Protobuf, or JSON Schema can enforce compatibility in CI.
Operationally: replication factor 3, auto-creation off, deliberate retention, graceful SIGTERM shutdown, and replicas scaled by lag up to the partition count. If your consumers run on Kubernetes, DevLogix’s cloud and DevOps services page outlines its approach to Kubernetes, CI/CD, and Terraform.
When not to use Kafka
Pick something simpler for background jobs with delays or priorities (BullMQ, RabbitMQ, SQS), synchronous questions (HTTP or gRPC), a small team with one deployable (a modular monolith), or fan-out without replay (SNS with SQS, Pub/Sub). If you cannot name which Kafka property you need, start simpler.
Conclusion
Use events for facts and calls for questions. Choose keys and partitions on purpose. Assume at-least-once delivery and make every consumer idempotent. Use an outbox instead of dual writes, bound retries with a monitored DLQ, and treat security, lag monitoring, and schemas as part of the design.
Next, publish OrderCreated, consume it, kill the consumer mid-message to watch redelivery, then add processed_events and the outbox.

Top comments (0)