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.
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
awaitthe 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_tokenhandler 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
| Callback | Promise | Webhook | |
|---|---|---|---|
| Where the result lives | In memory | In memory | Over the network |
| Scope | Same program | Same program | Another machine |
| Latency | Milliseconds | Milliseconds | Seconds–minutes |
| Ergonomics | Nesting → callback hell | Flat `.then()` / `await` | HTTP endpoint you host |
| Control | Inverted (they call you) | You drive it | Inverted + public |
| The cost | Deep nesting, IoC | You handle rejects | Public URL, retries, signature |
| Survives a restart? | No | No | Yes (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 three — await 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.