which strategy when, and why
Not every asynchronous requirement needs webhooks. Polling is sometimes the more sensible choice. And REST callbacks exist as a hybrid approach that many teams don't know about. Whoever understands the difference makes better architecture decisions, and doesn't build webhook infrastructure where a simple 30-second poll would be the cleaner solution.
Table of contents
- 1. The core problem: asynchronous events in synchronous APIs
- 2. Polling: simple, controlled, underrated
- 3. Long polling: latency without webhook complexity
- 4. Webhooks: server-initiated, event-driven
- 5. REST callbacks: webhooks with a structured contract
- 6. Server-sent events: stream instead of push
- 7. Decision guide: which strategy when?
- 8. Comparison matrix: all strategies at a glance
- 9. Summary
- 10. FAQ
1. The core problem: asynchronous events in synchronous APIs
REST is a synchronous protocol: the client sends a request, the server responds. For long-running operations, payment processing, image processing, order status updates, this model is problematic. The client can't wait 5 minutes for an HTTP response. What's needed is a mechanism that informs the client once the result is available, without the client waiting continuously.
Three fundamental approaches exist: the client asks periodically (polling). The server notifies the client (webhooks, SSE). Or a hybrid: the client gives the server a callback URL, and the server informs the client once the result is ready (REST callbacks). Each approach has a use case where it's the best choice, and none is universally superior.
2. Polling: simple, controlled, underrated
Polling has a bad reputation that isn't deserved in many cases. The model: the client sends a request, receives a job ID back (202 Accepted), and periodically polls GET /jobs/{id} until the status is completed. This is simple to implement, simple to test, simple to debug, and works behind every proxy and firewall.
Polling is the right choice when: event frequency is low (rarer than every 30 seconds), the consumer is active on a 5-minute cadence anyway, the consumer-side network infrastructure doesn't allow inbound HTTP connections (firewalls, NAT), or when no reliable webhook delivery infrastructure should be built. In scenarios with few clients and non-time-critical events, polling is often the most maintainable and cost-effective solution.
<?php
// src/Controller/Api/JobController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Service\JobService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Job status controller implementing the polling pattern.
* Clients poll GET /api/jobs/{id} until status is 'completed' or 'failed'.
*/
#[Route('/api/jobs', name: 'api_jobs_')]
final class JobController extends AbstractController
{
public function __construct(
private readonly JobService $jobService,
) {}
/**
* Start a long-running job and return 202 with job ID.
* Client polls GET /api/jobs/{id} for status updates.
*/
#[Route('', name: 'create', methods: ['POST'])]
public function create(): JsonResponse
{
$jobId = $this->jobService->dispatch();
return $this->json([
'job_id' => $jobId,
'status' => 'queued',
'poll_url' => '/api/jobs/' . $jobId,
'poll_interval_seconds' => 5,
], Response::HTTP_ACCEPTED, [
'Location' => '/api/jobs/' . $jobId,
]);
}
/**
* Poll job status. Returns 200 with current status.
* Clients should stop polling when status is 'completed' or 'failed'.
*/
#[Route('/{id}', name: 'status', methods: ['GET'])]
public function status(string $id): JsonResponse
{
$job = $this->jobService->get($id);
$response = [
'job_id' => $job->id,
'status' => $job->status->value, // queued|processing|completed|failed
'created_at' => $job->createdAt->format(\DateTimeInterface::ATOM),
'updated_at' => $job->updatedAt->format(\DateTimeInterface::ATOM),
];
if ($job->isCompleted()) {
$response['result_url'] = '/api/jobs/' . $id . '/result';
}
if ($job->isFailed()) {
$response['error'] = $job->errorMessage;
}
// Hint: retry after X seconds while pending
$headers = $job->isPending()
? ['Retry-After' => '5']
: [];
return $this->json($response, Response::HTTP_OK, $headers);
}
}
3. Long polling: latency without webhook complexity
Long polling is a middle ground between polling and webhooks: the client sends an HTTP request, and the server holds the connection open until an event occurs or a timeout is reached. Then the server responds with the event or with 304 Not Modified on timeout, and the client immediately sends the next request. That yields near real-time latency with server infrastructure that only needs inbound connections, no outbound HTTP requests to consumer endpoints.
Long polling has a downside in modern Symfony applications: it ties up a PHP worker process for the entire wait time. With many concurrent long-polling clients, PHP-FPM workers can be exhausted before actual events are even processed. That makes long polling less practical in PHP environments than in Node.js or Go. An alternative: the Mercure protocol (implemented via Symfony's Mercure component) uses server-sent events for the same outcome with a more suitable server architecture.
4. Webhooks: server-initiated, event-driven
Webhooks reverse the client-server relationship: the consumer registers a URL, and the API server sends an HTTP POST to that URL for every event. This enables near real-time reaction without continuous polling load. Latency is low, and the infrastructure load on the server side scales linearly with event frequency, not with the number of waiting clients.
The complexity sits on the consumer side and in the infrastructure: the consumer needs a publicly reachable HTTPS endpoint. Firewalls must allow inbound connections. The server needs retry logic, a dead letter queue for undeliverable events, monitoring of delivery rates, and subscription management. That's considerably more infrastructure than polling. Webhooks are the right choice when events are time-critical, the consumer infrastructure supports inbound connections, and the event load is high enough that polling would be uneconomical.
<?php
// src/Service/WebhookDispatchService.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\WebhookSubscription;
use App\Repository\WebhookSubscriptionRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* Dispatches webhook events to registered consumer endpoints.
* Uses exponential backoff for failed deliveries.
*/
final class WebhookDispatchService
{
private const MAX_RETRIES = 5;
public function __construct(
private readonly WebhookSubscriptionRepository $subscriptionRepo,
private readonly LoggerInterface $logger,
) {}
/**
* Dispatch an event to all subscriptions matching the event type.
*
* @param string $eventType e.g. 'order.created'
* @param array<string, mixed> $payload Event payload data
*/
public function dispatch(string $eventType, array $payload): void
{
$subscriptions = $this->subscriptionRepo->findActiveByEventType($eventType);
$client = HttpClient::create(['timeout' => 10]);
foreach ($subscriptions as $subscription) {
$body = json_encode($payload, JSON_THROW_ON_ERROR);
$signature = 'sha256=' . hash_hmac('sha256', $body, $subscription->getSecret());
try {
$response = $client->request('POST', $subscription->getUrl(), [
'headers' => [
'Content-Type' => 'application/json',
'X-Mironsoft-Signature' => $signature,
'X-Mironsoft-Event-Type' => $eventType,
'X-Mironsoft-Event-Id' => $payload['event_id'],
],
'body' => $body,
]);
if ($response->getStatusCode() >= 400) {
$this->scheduleRetry($subscription, $eventType, $payload, 1);
}
} catch (TransportExceptionInterface $e) {
$this->logger->error('Webhook delivery failed', [
'url' => $subscription->getUrl(),
'event_type' => $eventType,
'error' => $e->getMessage(),
]);
$this->scheduleRetry($subscription, $eventType, $payload, 1);
}
}
}
private function scheduleRetry(
WebhookSubscription $subscription,
string $eventType,
array $payload,
int $attempt,
): void {
if ($attempt > self::MAX_RETRIES) {
$this->logger->critical('Webhook delivery permanently failed', [
'subscription_id' => $subscription->getId(),
'event_type' => $eventType,
]);
// Move to dead letter queue / disable subscription
return;
}
// Exponential backoff: 30s, 60s, 120s, 240s, 480s
$delaySeconds = 30 * (2 ** ($attempt - 1));
// Schedule via Symfony Messenger with a delay
$this->logger->info(sprintf(
'Scheduling webhook retry #%d in %ds',
$attempt,
$delaySeconds
));
}
}
5. REST callbacks: webhooks with a structured contract
REST callbacks are a hybrid approach: the client provides a callback URL with the initial request (X-Callback-URL: https://client.example.com/hook), and the server sends the result via POST to that URL once the operation is complete. This is more flexible than fixed webhook subscriptions, because each individual operation defines its own callback URL. It's also documentable in OpenAPI 3.0+ with the callbacks keyword.
REST callbacks fit well for long-running single operations: PDF generation, video encoding, report creation. The caller doesn't need a permanent webhook subscription, only a reachable endpoint for this one request. The downside: the server must validate the callback URL (SSRF protection) and depends on the reachability of the consumer endpoint at the moment of completion, unlike polling, where the consumer itself determines when to check.
6. Server-sent events: stream instead of push
Server-sent events (SSE) are a lesser-known alternative: the client opens a single HTTP connection (text/event-stream), and the server sends events over this connection for as long as it stays open. Unlike WebSockets, SSE is unidirectional (server to client) and HTTP-native, no protocol upgrade needed. Browsers support SSE natively through the EventSource API.
Symfony offers a complete SSE implementation with the Mercure protocol via the symfony/mercure bundle. A hub server (free open source version: Caddy plus the Mercure module) receives events from the Symfony backend and distributes them via SSE to all connected clients. This scales well for broadcasting scenarios, but is less suited for private events to individual consumers than webhooks.
7. Decision guide: which strategy when?
Choosing the right strategy depends on several factors. The most important questions in the decision-making process:
Latency requirement: Is a reaction within seconds needed? Then webhooks or SSE. Within minutes? Polling with short intervals is enough. Hours or days? Polling with long intervals or email notification.
Consumer infrastructure: Can the consumer receive inbound HTTP requests? No (firewall, NAT, no public IP)? Then polling or long polling. Yes? Webhooks or REST callbacks.
Event frequency: Few events per day? Polling is simpler. Hundreds of events per hour per client? Polling becomes a server load, webhooks are more efficient.
Implementation complexity: Webhooks need retry logic, dead letter queues, subscription management and HMAC signature verification. Polling needs a job status endpoint and a 202 Accepted response. With limited development time, polling is often the better choice.
8. Comparison matrix: all strategies at a glance
| Strategy | Latency | Consumer needs | Implementation complexity | Ideal for |
|---|---|---|---|---|
| Polling | High (interval) | Outbound HTTP | Low | Non-time-critical jobs, firewall environments |
| Long Polling | Low (seconds) | Outbound HTTP | Medium | Time-critical, no webhook infrastructure |
| Webhooks | Very low | Public HTTPS endpoint | High | Many clients, high event frequency, real time |
| REST Callbacks | Low | Reachable HTTPS endpoint | Medium | Long-running single operations per request |
| Server-Sent Events | Very low | Browser / EventSource | Medium (hub needed) | Browser clients, broadcasting, dashboards |
Mironsoft
REST API architecture, webhook systems and asynchronous communication
Setting up asynchronous API architecture the right way?
We analyze your specific requirements and recommend the right strategy, polling, webhooks or REST callbacks, and implement the solution with full retry logic, monitoring and OpenAPI documentation.
Architecture review
Analysis of existing asynchronous communication and recommendation of the optimal strategy
Webhook implementation
HMAC signature, retry logic, dead letter queue and subscription management in Symfony
Documentation
OpenAPI 3.1 webhook documentation with complete payload schemas and security notes
9. Summary
Webhooks are powerful, but not universally the best solution. Polling is the pragmatically better choice in many scenarios: simpler to implement, simpler to debug, no public HTTPS endpoint needed on the consumer side. Long polling closes the gap for latency requirements when webhook infrastructure would be too complex. REST callbacks fit long-running single operations where each operation brings its own callback URL. Server-sent events are ideal for browser clients and broadcasting scenarios.
The decision should be made based on latency requirements, consumer infrastructure, event frequency and available development time, not based on trend or complexity preference. Building webhooks where polling would have sufficed is technical over-engineering. Using polling where real-time events would be needed is a product defect.
Webhooks vs. Polling vs. Callbacks - the essentials at a glance
Choose polling when
Events are rare, the consumer sits behind a firewall, implementation time is limited, or events aren't time-critical.
Choose webhooks when
Real-time reaction is needed, the consumer has a public HTTPS endpoint, and event frequency makes polling uneconomical.
Choose REST callbacks when
Long-running single operations with an individual callback endpoint. No subscription management needed, but SSRF protection is required.
Choose SSE / Mercure when
Browser clients, broadcasting to many recipients, or real-time dashboard updates. The Symfony Mercure bundle makes this easy.