DEV Community

Rahmon Toyeeb
Rahmon Toyeeb

Posted on

Service-to-Service Calls vs Event-Driven Flows: When to Use Which

Two common ways services talk to each other

When a system starts getting bigger, one question shows up pretty quickly:

How should one service trigger work in another service?

In most teams, the answer usually falls into one of these two patterns:

  • direct service-to-service calls
  • event-driven flows

Neither one is automatically better.

The real job is knowing when you need an immediate answer and when you do not.

There is also a third thing that often gets mixed into this conversation: jobs and workers.

They also handle work asynchronously, but they are not the same as events.

That sounds simple, but this is where a lot of systems get messy. Some teams push everything through synchronous calls. Others try to make everything event-driven because it feels more scalable or modern. And sometimes teams publish events when what they really needed was just a background job.

In practice, most healthy systems use all three where they fit.


Direct service-to-service calls

This is the easier pattern to understand.

One service calls another service and waits for the result.

For example:

  • the checkout service calls billing to charge a card
  • the order service calls inventory to reserve stock
  • an admin API calls the permissions service before allowing an action

This is usually a request-response flow. One service asks a question or sends a command, and it needs the answer right away.

const paymentResult = await billingClient.charge({
  customerId,
  amountInCents: total,
});

if (!paymentResult.success) {
  throw new Error("Payment failed");
}
Enter fullscreen mode Exit fullscreen mode

That is a good fit when:

  • the user cannot move forward without the result
  • the current request should fail if the downstream step fails
  • the steps need to happen in order (synchronous)
  • you want a clear yes-or-no answer immediately

If payment fails, you probably should not confirm the order. That is why a direct call makes sense there.


Event-driven flows

Event-driven flows work differently.

Instead of one service directly telling another service what to do, a service publishes an event saying that something already happened.

Examples:

  • order.created
  • payment.completed
  • user.signed_up

Other parts of the system can listen and react to that event when they are ready.

await eventBus.publish({
  type: "order.created",
  payload: {
    orderId,
    customerId,
    totalAmount,
  },
});
Enter fullscreen mode Exit fullscreen mode

After that event is published, other consumers might:

  • send a confirmation email
  • update analytics
  • notify a shipping system
  • notify a loyalty system

The important part is this: the original request does not need to sit there waiting for all of that work to finish.

That makes event-driven flows useful when:

  • the follow-up work can happen later
  • multiple systems need to react to the same event
  • you want services to be less tightly coupled
  • eventual consistency is acceptable

Jobs and workers are different from events

This is the part that often gets blurred.

Jobs and workers are also about async work, but the idea is different.

With a job, the system is saying:

"This task needs to be done, just not inside the current request."

Examples:

  • send this email
  • resize this uploaded image
  • generate this invoice PDF
  • retry this failed webhook

A worker then pulls that job from a queue and processes it in the background.

This is a good fit when:

  • one system already owns the task
  • the work can happen later
  • you want retries and queue-based processing
  • you do not need multiple systems reacting to the same fact

The short version is:

  • a job means "do this task later"
  • an event means "this thing happened"

That difference matters.

If you publish an event just to get one background task done, you may be making the system more complicated than it needs to be.


A simple checkout example

Let us use a checkout flow because it is easy to picture.

When a customer places an order, several things may need to happen:

  • check inventory
  • charge payment
  • send a confirmation email
  • update analytics
  • prepare shipping

Should all of those happen through direct service calls?

Usually not.

Some of those steps decide whether the order can even go through. Others are just reactions to a successful order.

That distinction matters a lot.

Checking inventory and charging payment are usually part of the critical path. If either one fails, the order should not be confirmed.

Sending the confirmation email might be better as a background job handled by a worker.

Updating analytics might be better as a reaction to an event like order.confirmed.

Those things matter, but they usually do not need to block the checkout response.

That leads to a very practical question:

What must happen before we respond, and what can safely happen after?


A simple rule that helps

Here is a practical way to think about it:

  • if the current action depends on the answer right now, use a direct call
  • if one known task should happen but not block the request chain, use a (background) job and worker
  • if multiple systems may react to something that happened, use an event

Another useful question is:

If the downstream step fails, should the original action fail too?

If the answer is yes, a direct call is often the right choice.

If the answer is no, then the next question is whether you need one background task or a broader event.

For example:

  • if fraud checking fails and you cannot approve the payment without it, use a direct call
  • if a receipt email should be sent later by one worker, use a job
  • if analytics, loyalty, and notifications may all react to a confirmed order, publish an event

That one question will save you from a lot of over-engineering.


Where teams get this wrong

Making everything synchronous

This usually starts with good intentions. Direct calls are easy to reason about, so teams keep adding one more call, then another, then another.

Before long, one user request depends on five other services being healthy at the same time.

That often leads to:

  • slower APIs
  • fragile request chains
  • harder incident recovery
  • failures spreading across services

Making everything event-driven

This goes wrong in the opposite direction.

Teams hear that event-driven systems scale well, so they start publishing events for almost every step, even simple ones.

Now a basic business flow is spread across queues, consumers, retries, dead-letter handling, and eventual consistency problems.

Sometimes that complexity is worth it. A lot of times, it is not.

Using events for work that is really just a job

Not every async task needs to become an event.

If the real need is simply "send this email in the background," a job queue and worker may be the cleaner solution.

Events are more useful when several parts of the system may need to respond independently.

Publishing vague events

An event should describe something meaningful that happened in the business.

user.registered is clear.

run-user-post-processing sounds more like a hidden remote procedure call.

Good events are usually named after facts, not internal tasks.


A balanced way to design it

A lot of real systems end up with a hybrid approach.

For example:

  1. Checkout directly calls inventory.
  2. Checkout directly calls billing.
  3. If both succeed, the order is confirmed.
  4. A job is queued to send the confirmation email.
  5. The system publishes order.confirmed.
  6. Analytics, loyalty, and other consumers react asynchronously.

That gives you:

  • immediate answers for the steps that actually decide success
  • background processing for tasks with clear ownership
  • faster user-facing responses for the steps that can wait
  • clearer boundaries between critical work and side effects

That is usually a much healthier setup than forcing everything into one model.


Final thought

Service-to-service calls, jobs and workers, and event-driven flows are not competing ideas. They are tools for different situations.

Use direct calls when the current action needs an answer right now.

Use jobs when a specific task should happen in the background.

Use events when the main action can finish first and other systems can react afterward.

If you keep that one distinction clear, architecture decisions become a lot less confusing.

Top comments (0)