Smart Auto Refresh with PHP
A page should update when something changed, and it should not reload itself just because a timer fired.
The browser can schedule the refresh, pause hidden tabs, and update the visible component. PHP should answer a narrower question: has the underlying data changed, and if it has, what is the smallest response the browser needs?
Think of the endpoint as a mailbox flag. The browser checks the flag first and opens the mailbox only when the flag changed. In a current PHP app, the flag comes from a database timestamp, an incrementing message ID, an ETag, or a tiny JSON endpoint.
Treat refresh as a cost instead of a reflex, then make the first check cheaper than fetching and rendering the full panel.
Guide Path
Read this after building a mini chat app with PHP if the next feature is a fresher message list, queue count, dashboard panel, or activity feed. This page covers both the PHP endpoint and the small browser-side Fetch loop.
The HTML tutorials hub remains useful when the surrounding page structure needs more attention.
Start with a Version Token
The cheapest refresh endpoint does not return the whole panel. It raises or lowers the mailbox flag by returning a version token the browser can compare with the last token it saw.
For a database-backed widget, use a monotonic revision that advances on every visible insert, update, and delete. Keep one revision per authorization scope so each user sees a token derived from the same records as the protected data endpoint:
<?php
function currentActivityVersion(PDO $pdo, int $userId): string
{
$statement = $pdo->prepare(
"SELECT revision
FROM activity_feed_revisions
WHERE user_id = :user_id",
);
$statement->execute(["user_id" => $userId]);
$value = $statement->fetchColumn();
return $value === false ? "0" : (string) $value;
} Bump the revision in the same transaction as every write that changes the user's visible feed, including deletes. Apply the data endpoint's authentication and authorization before querying this token because even a changing version can reveal private activity.
MAX(updated_at) and MAX(id) are acceptable approximations for append-only data. They can miss deletions, updates that preserve the maximum timestamp, and changes to rows below the maximum ID. A cache version can be reliable only when every relevant write advances it.
Return 304 when Nothing Changed
HTTP already has a language for "you have the current version." An ETag lets the browser ask whether a resource changed without downloading the body again.
<?php
require __DIR__ . "/bootstrap.php";
$userId = requireAuthenticatedUserId();
$version = currentActivityVersion($pdo, $userId);
$etag = '"' . hash("sha256", $version) . '"';
header("Content-Type: application/json; charset=utf-8");
header("Cache-Control: private, no-cache");
header("ETag: {$etag}");
if (($_SERVER["HTTP_IF_NONE_MATCH"] ?? "") === $etag) {
http_response_code(304);
exit;
}
echo json_encode([
"version" => $version,
], JSON_THROW_ON_ERROR); The browser can poll this endpoint and skip the heavier data request when it receives 304 Not Modified. The status code carries the answer, so no body has to be sent or parsed.
JSON_THROW_ON_ERROR is available in PHP 7.3 and newer. It turns encoding failures into JsonException instead of requiring a later json_last_error() check.
For private user data, keep Cache-Control: private, no-cache or an equivalent policy. The browser may revalidate the response, but shared caches should not reuse another user's status token.
Return Only the New Rows
For a chat box or activity feed, the browser usually needs the rows after the last item it rendered. An incrementing ID makes the cursor logic straightforward.
<?php
require __DIR__ . "/bootstrap.php";
$afterId = filter_input(INPUT_GET, "after_id", FILTER_VALIDATE_INT) ?: 0;
$statement = $pdo->prepare(
"SELECT id, message, created_at
FROM activity_items
WHERE id > :after_id
ORDER BY id ASC
LIMIT 50",
);
$statement->execute([
"after_id" => $afterId,
]);
$items = $statement->fetchAll(PDO::FETCH_ASSOC);
$lastSeenId = $afterId;
foreach ($items as $item) {
$lastSeenId = max($lastSeenId, (int) $item["id"]);
}
header("Content-Type: application/json; charset=utf-8");
echo json_encode([
"items" => $items,
"last_seen_id" => (string) $lastSeenId,
], JSON_THROW_ON_ERROR); That endpoint has a boring contract, which is exactly what you want. It validates the cursor, uses a prepared statement, returns bounded results, and gives the browser a new cursor.
Do not ship raw HTML from this endpoint unless you have a clear sanitization plan. JSON keeps the boundary plain: PHP returns data, and the browser renders it with text nodes or a small template.
File Markers
A timestamp file still has a narrow use: a tiny app with no database, one server, and a piece of generated content that changes occasionally. In that case, a marker file can be a simple version token.
<?php
function touchRefreshMarker(string $path): void
{
file_put_contents($path, (string) time(), LOCK_EX);
}
function readRefreshMarker(string $path): string
{
if (! is_file($path)) {
return "empty";
}
return trim((string) file_get_contents($path));
} That code should not be stretched into a distributed system. A marker file works badly across multiple servers unless the filesystem is shared and locking semantics are clear. Once you have more than one app server, a database, Redis, or the actual data store should own the version.
A marker file is a useful teaching model and a fragile production architecture, and the difference between the two is usually the second app server.
Make the Browser Polite
The PHP endpoint is only half the design. The browser should avoid overlapping requests, slow down when errors happen, pause hidden tabs, and update only the affected component. Checking the mailbox flag is cheap only when the browser remains polite.
This small loop pauses in hidden tabs and prevents a slow request from overlapping the next interval. It reloads only after a later ETag differs from the first successful response:
let version = null;
let inFlight = false;
let delay = 5000;
async function pollForChanges() {
if (document.visibilityState !== "visible" || inFlight) {
setTimeout(pollForChanges, delay);
return;
}
inFlight = true;
try {
const headers = version === null ? {} : { "If-None-Match": version };
const response = await fetch("/api/activity/version", {
credentials: "same-origin",
headers,
});
if (response.status === 304) {
delay = 5000;
return;
}
if (!response.ok) {
throw new Error(`Version check failed with status ${response.status}`);
}
const nextVersion = response.headers.get("ETag");
if (version !== null && nextVersion !== null && nextVersion !== version) {
window.location.reload();
return;
}
version = nextVersion;
delay = 5000;
} catch (error) {
console.error("Refresh check failed", error);
delay = Math.min(delay * 2, 60000);
} finally {
inFlight = false;
setTimeout(pollForChanges, delay);
}
}
void pollForChanges(); Keep the response shape boring and stable over time. If you change field names every time the UI changes, the refresh loop becomes a bug magnet.
Polling, SSE, or WebSockets
Polling is a good default when a delay of a few seconds is acceptable. It works through normal PHP request handling, logs cleanly, and fails in ways a developer can inspect.
Server-Sent Events make sense when PHP or a worker process can keep a one-way stream open and the browser mostly listens. WebSockets make sense when the browser and server need a persistent two-way conversation, such as presence, collaborative editing, or fast chat state.
Most small PHP apps should not start with WebSockets. The goal is not to make everything real time. It is to avoid expensive refresh work when nothing changed, and polling with a cheap version token does that.
What Changed
Keep the core idea: compare what the browser last saw with what the server currently has, then refresh only when the data changed.
The parts to retire are the full-page reload and the cookie-as-state shortcut. The browser should own the timer and visibility checks. PHP should return a cheap token, a 304, or a small JSON delta from a permission-checked endpoint.
Common Pitfalls & Debugging
A 304 Response Includes a Body
Symptom: browsers or proxies handle the conditional response inconsistently. Cause: the endpoint sends JSON after setting status 304. Fix: send the cache headers, set the status, and exit without a response body.
Polling Requests Overlap
Symptom: the panel renders old data after new data or creates bursts of requests. Cause: a timer starts another fetch before the previous one finishes. Fix: schedule the next poll only after the current request settles, then back off after failures.
The Version Token Leaks Private State
Symptom: an unauthenticated caller can infer when a private account changed. Cause: the cheap status endpoint skipped the authorization used by the data endpoint. Fix: apply the same access boundary before calculating the token and keep private responses out of shared caches.
Frequently Asked Questions
Should a polling endpoint return the whole panel?
Return a cheap version token first, then fetch the panel or a small data delta only after that token changes. This keeps unchanged polls small and lets the data endpoint retain its normal validation, authorization, and response contract.
Should a refresh endpoint use an ETag or a row ID?
Use an ETag when the browser is revalidating one representation. Use a row ID when the client needs records after a known cursor. A page can use both: an ETag for a summary and an ID for new rows.
When should polling become Server-Sent Events?
Move to Server-Sent Events when the server must push frequent one-way updates and the hosting setup can keep connections open reliably. Keep polling when a delay of several seconds is acceptable and ordinary PHP requests are easier to operate.
Can a marker file work across multiple servers?
Only when every server sees the same filesystem and the locking behavior is understood. A database row, Redis key, or version in the real data store is a clearer source of truth for a multi-server application.
Next Steps
Start with the mini chat tutorial when you need a concrete PHP message table, then use this page to make that panel refresh cleanly. The HTML tutorials hub covers the surrounding document structure when the refreshed panel needs a larger page.
This example has been updated from the original CodeWalkers Smart Auto Refresh tutorial, which compared a cookie against a server-side timestamp file and reloaded the entire page. The version token, the 304 response, and the browser-owned timer replace that mechanism.
Sources
-
[1]
PHP json_encode(php.net)
-
[2]
PHP header(php.net)
-
[3]
PDO::prepare(php.net)
-
[4]
PHP file_put_contents(php.net)
-
[5]
ETag(developer.mozilla.org)
-
[6]
Last-Modified(developer.mozilla.org)
-
[7]
PHP JSON Constants(php.net)
-
[8]
304 Not Modified(developer.mozilla.org)
-
[9]
Using the Fetch API(developer.mozilla.org)
-
[10]
Document: visibilityState property(developer.mozilla.org)
Read Next
Modernize the old PHP mini-chat pattern with PDO, sessions, CSRF protection, escaped output, and a clear polling or SSE boundary.
Finish the PHP web-app path with a small JSON API, HTTP methods, status codes, validation, PDO, and an OpenAPI-ready contract.
Use PDO prepared statements to connect PHP to MySQL safely and keep SQL input handling clear.