Callback vs Promise vs Webhook: Where Does Your Async Result Actually Come Back?

Callback, promise, and webhook get treated like three flavors of the same thing — they aren't. The real difference is WHERE the result lives. Callback and promise hand a result back to the same program in memory in milliseconds; a webhook is another machine calling you back over the network, seconds to minutes later. Here's the mental model, the trade-offs, code for each, and a clear rule for which to reach for — including what it means for async LLM jobs.

Banner

Prefer to watch? ▶ Callback vs Promise vs Webhook in 90 seconds ✈ Telegram

You fired off an async request. The work takes a while — a file read, a slow API, a payment that clears minutes later. So here's the real question: how does the answer come back to you?

There are three classic answers — callback, promise, and webhook — and they get argued about like they're three syntaxes for the same idea. They aren't. The difference that actually matters isn't .then() vs a function argument. It's where the result lives, and how far it has to travel to reach you.

The one-line mental model:

  • Callback + promise = the same program handing a result back to itself, in memory, in milliseconds.
  • Webhook = another machine calling you back over the network, seconds to minutes later.

It's in-process vs across the network. Everything else follows from that.


Callback: hand over a function to be called later

The oldest answer. You call some async operation and pass it a function — "here, call me back when you're done."

readFile("data.json", (err, data) => {
  // this runs later, when the read finishes
  if (err) return handle(err);
  process(data);
});

Simple for a single step. The trouble starts when you chain them — each result feeds the next call, and you drift rightward into the pyramid of doom:

getUser(id, (err, user) => {
  getOrders(user, (err, orders) => {
    getInvoice(orders[0], (err, invoice) => {
      // ...callback hell
    });
  });
});

That's callback hell — deep nesting that's hard to read and hard to error-handle. And there's a subtler cost: you handed your function to someone else's code, so they decide when (and whether, and how many times) it runs. That loss of control has a name: inversion of control.


Promise: an object standing in for a value that isn't ready yet

A promise flips the ergonomics. Instead of giving the async operation your function, it gives you an object back — a placeholder for a value that will exist later. You attach what happens next to that:

fetchUser(id)
  .then(user => fetchOrders(user))
  .then(orders => fetchInvoice(orders[0]))
  .catch(handleError);       // one place for failures

Or, with async/await, it reads like plain sequential code:

const user    = await fetchUser(id);
const orders  = await fetchOrders(user);
const invoice = await fetchInvoice(orders[0]);

Flat, readable, no nesting pyramid, and one clean path for rejections. This is the modern in-process default — reach for it first. Just remember what it isn't: a promise still runs inside your one program. It doesn't survive a restart, it doesn't cross machines, and you're still the one who handles the reject.


Webhook: the result comes back over the network

Now the model changes completely. With a webhook, the result doesn't come back in your memory at all. You don't hold a function or an object waiting — you give the other service a public URL, and when its work is done, it makes an HTTP POST to your endpoint.

POST /webhooks/payments HTTP/1.1
Host: your-app.com
X-Signature: t=1720099200,v1=8f3a...   ← verify this!

{ "event": "payment.succeeded", "id": "pay_42", "amount": 1999 }
@app.post("/webhooks/payments")
def payment_hook(req):
    if not verify_signature(req):        # anyone can POST you — check it
        return 401
    mark_paid(req.json["id"])
    return 200                           # 2xx or they retry

The classic case: a payment that clears minutes later, an email that bounces, a video that finishes transcoding. Your original request already returned; the real answer arrives on its own schedule, from a different machine.

That power has a price the in-process options never pay:

  • A public URL the other service can reach.
  • Retries + idempotency — networks fail, so senders retry; your handler must tolerate the same event twice.
  • Signature verification — your endpoint is public, so anyone could POST it. You must prove the call is genuine.

The reframe: this is exactly how you talk to slow AI jobs

Here's where the same three shapes show up in a very modern place — calling an LLM. Generation is async and often slow, so you face the identical "how does the result come back?" decision:

  • In-process await — for a normal chat completion you just await the call. That's the promise model: same program, holds the connection, result comes back in memory. Fine for a few seconds of latency.
  • Streaming (token by token) — instead of one final value, the server pushes tokens as they're generated. This is the callback shape wearing new clothes: you register an on_token handler and the library calls you back for each chunk. Great for perceived latency (first token fast), but you've handed over control of when your code runs.
  • Batch / async jobs → webhook — submit a large batch API job or a long fine-tune and the result may take minutes to hours. You do not hold a connection open for an hour. You either poll for status, or — better — register a webhook so the provider POSTs your endpoint the moment the job finishes. Same public-URL-plus-retries-plus-verification contract as the payment example.

So the "in-process vs across the network" split isn't academic trivia — it's the exact reason a chat call is an await but a batch job is a webhook. Latency dictates the shape. Sub-second → hold it in memory (promise). Token stream → callback. Long-running or cross-service → webhook.


The differences side by side

CallbackPromiseWebhook
Where the result livesIn memoryIn memoryOver the network
ScopeSame programSame programAnother machine
LatencyMillisecondsMillisecondsSeconds–minutes
ErgonomicsNesting → callback hellFlat `.then()` / `await`HTTP endpoint you host
ControlInverted (they call you)You drive itInverted + public
The costDeep nesting, IoCYou handle rejectsPublic URL, retries, signature
Survives a restart?NoNoYes (it re-POSTs / retries)

Which should you reach for?

  • Same program, async work?promise / async-await. It's the modern default. Use plain callbacks only for old, event-style APIs (event emitters, streaming chunks) where a callback is genuinely the right shape.
  • Another service, or a long-running job that outlives your request?webhook. Anything that finishes seconds-to-minutes later on someone else's machine — payments, transcodes, batch AI jobs — should call you back over the network, not make you hold a connection open.

The part most "vs" arguments miss: these aren't competitors. A real system uses all threeawait for the fast in-process calls, a callback for the streaming ones, and a webhook for the slow cross-service ones. The skill isn't picking a favorite; it's recognizing where the result lives and reaching for the shape that matches.

Want the 90-second visual version? Watch the reel.

Callback vs Promise vs Webhook: Where Does Your Async Result Actually Come Back? | Vahid Aghajani