Webhooks vs. Polling vs. REST Callbacks - when to use what?
AI generated
{ }
GET
REST API - Webhooks - Polling - Callbacks - Architecture
Webhooks vs. Polling vs. REST Callbacks
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.

15 min. read Webhooks - Polling - Long Polling - SSE - REST Callbacks - Symfony Symfony 6.x / 7.x - PHP 8.2+

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.

10. FAQ: Webhooks vs. Polling vs. REST Callbacks

1When is polling better than webhooks?
Consumer behind a firewall, rare events, limited implementation time, no real-time requirement. Polling is simpler and often sufficient.
2REST callbacks vs. webhooks?
REST callbacks: callback URL per request, no subscription management. Webhooks: fixed subscription for all events of a type, requiring permanent management.
3What is long polling?
The server holds an HTTP request open until an event occurs or a timeout is reached. Near real time without webhook infrastructure, but it ties up server resources.
4Implementing polling in Symfony?
POST returns 202 Accepted plus Location header plus job ID. GET /api/jobs/{id} returns status with a Retry-After header. When completed: include result_url.
5SSRF protection for REST callbacks?
Validate the callback URL: no private IPs (10.x, 192.168.x, 127.x), no localhost, HTTPS only. Configure Symfony HttpClient with SSRF protection.
6What retry strategy for webhooks?
Exponential backoff: 30s, 60s, 120s, 240s, 480s. Max 5 retries. Then dead letter queue. Make delivery status visible in monitoring.
7What are server-sent events?
HTTP standard for server-to-client streams (text/event-stream). Browsers: EventSource API. Ideal for dashboards and broadcasting. Symfony Mercure bundle for easy implementation.
8How many webhooks can Symfony send per second?
Dispatch webhooks via Symfony Messenger in a queue worker. With RabbitMQ or Redis as transport: hundreds of dispatches per second are possible.
9Offer polling and webhooks at the same time?
Yes, common practice for public APIs. Webhook consumers get real time. Firewall consumers poll the same status endpoint. Both paths lead to the same result.
10Document polling endpoints in OpenAPI?
202 response with Location header. GET endpoint with a status enum. Retry-After in the 200 response. links object with the operationId of the GET endpoint for automatic client awareness.