DRAFT System Design: Stepped Backoff Polling — A Smart Strategy for State Sync
Consider an e-commerce system that must keep order payment status in realtime sync with an upstream payment system: check once per second; if still unpaid, keep checking until the order completes.
Checking every second per order puts heavy load on the server and the upstream API.
If we can dynamically adjust check frequency — e.g. every 1 second for the first five attempts, then every 5 seconds for the next five, and so on — we cut server and external API load while still updating in time.
We call this stepped backoff polling.
A real example: Alipay’s async payment-success notifications gradually reduce request frequency toward merchants:
Alipay docs example During async notification, if Alipay does not receive
successas the response, it treats the notification as failed and retries on a schedule. Intervals: 4m, 10m, 10m, 1h, 2h, 6h, 15h.
This tutorial walks you through a second-level stepped backoff dequeue design.
Live effect:


Prerequisites
Create an empty Laravel 11 project. We will sync order status with the upstream API every few seconds while controlling frequency — not too many requests, and no duplicate requests in a short window.
Setting up the queue job
Create a base job class
First we need a base job that implements polling. Here is a BasePollingJob example with basic polling:
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\MaxAttemptsExceededException;
use Illuminate\Support\Facades\Log;
abstract class BasePollingJob implements ShouldQueue, ShouldBeUnique
{
use Queueable;
protected $jobDesc = '任务轮询队列';
protected $jobPayload;
const TIMEOUT_SECOND = 60 * 3;
public function retryUntil()
{
return now()->addSeconds(self::TIMEOUT_SECOND);
}We define a job description, payload, and retry timeout. retryUntil sets the maximum retry window; if the job is still unfinished past that time, laravel-queue throws MaxAttemptsExceededException. In business logic you can mark timeout in fail().
Schedule the next run
Next, scheduleNextRunning adjusts the next interval by attempt count:
protected function scheduleNextRunning()
{
$attempts = $this->job->attempts();
if ($attempts <= 5) {
$this->release(1); // 前5次,每隔1秒执行一次
} elseif ($attempts <= 10) {
$this->release(5); // 接下来5次,每隔5秒执行一次
} elseif ($attempts <= 20) {
$this->release(10); // 接下来10次,每隔10秒执行一次
} else {
$this->release(30); // 超过20次后,每隔30秒执行一次
}
}Based on current attempts, this sets the next delay — short intervals early, then gradually less frequent.
Handle job failure
On failure or timeout, log and run follow-up logic:
public function failed(\Throwable $exception)
{
if ($exception instanceof MaxAttemptsExceededException) {
Log::info($this->jobDesc . '超时退出执行', [
'payload' => $this->jobPayload,
'error' => $exception->getMessage(),
]);
$this->afterMaxAttemptsExceeded();
} else {
Log::error($this->jobDesc . '异常失败', [
'payload' => $this->jobPayload,
'error' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTrace()
]);
}
}Here we log errors and run whatever follow-up is needed.
Implementing the polling job
Create a concrete job PollingOrderStatusJob to check order status:
namespace App\Jobs;
use App\Enums\OrderStatus;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
class PollingOrderStatusJob extends BasePollingJob
{
protected $jobDesc = '订单同步队列';
private Order $order;
public function __construct(Order $order)
{
$this->order = $order;
$this->jobPayload = $order->toArray();
}We define the job description and order object. The constructor stores order data as the payload for later use.
To ensure the same order number is not enqueued twice, use the order number as the unique lock key.
// 使用订单号来获取唯一锁
public function uniqueId()
{
return $this->order->trade_no;
}Job logic
In handle, implement the concrete flow:
public function handle()
{
try {
Log::info($this->jobDesc . '开始执行', [$this->order->id, $this->order->trade_no]);
// 前置检测:订单状态如果已是终态,无需操作,退出队列
if (!$this->checkOrderBeforePolling($this->order)) {
$this->delete();
return;
}
// 查询订单状态:比如发起一个HTTP请求,获取最新状态
if (!$this->orderQuery($this->order)) {
$this->delete();
return;
}
// 调度下次运行的时机
$this->scheduleNextRunning();
} catch (\Throwable $e) {
$this->fail($e);
}
}We log start, then call checkOrderBeforePolling to see if polling should continue. If not, delete the job and return. Then orderQuery fetches status, and finally we schedule the next run.
Pre-check
In checkOrderBeforePolling, if the order is already terminal (timed out or completed), sync is unnecessary. This also guards against upstream business anomalies.
private function checkOrderBeforePolling(Order $order): bool
{
$orderStatus = OrderStatus::from($order->status);
if (!in_array($orderStatus, [OrderStatus::DEFAULT, OrderStatus::PAYING])) {
return false;
}
return true;
}Query order status
orderQuery simulates querying order status:
private function orderQuery(Order $order)
{
// 模拟订单查询请求
sleep(1);
// 支付成功返回
// $result = [
// 'amount' => 100,
// 'payment_no' => 'p123456',
// 'status' => 'SUCCESS'
// ];
// 正在支付中返回
$result = [
'amount' => null,
'payment_no' => null,
'status' => 'PENDING'
];
return $result['status'] == 'PENDING';
}This is a simple simulation; production would call an external API.
Handle timeout
public function afterMaxAttemptsExceeded()
{
try {
$this->order->status = OrderStatus::TIMEOUT->value;
$this->order->save();
Log::info($this->jobDesc . '-更新订单状态为超时', [$this->order->trade_no]);
} catch (\Throwable $e) {
Log::error($this->jobDesc . '-超时后置逻辑执行异常', [$this->order, $e->getMessage()]);
}
}Wrapping up
Full code — logging, DB bootstrap, step-by-step tutorial — is ready to use and open-sourced here: Github Gitee
Distilled from a project with hundred-million-scale GMV. Open source is hard — a star helps a lot. Your support keeps this going.
