Technical SEO · Website troubleshooting
Find the request that failed, identify the waiting server, and verify the action’s result.
By Rahul S., founder and SEO consultant at Search Counsel Co. Updated .
What does 504 Gateway Timeout mean?
A 504 Gateway Timeout means a gateway or proxy did not receive a timely upstream response. The status identifies a timeout, not its cause. Visitors can retry a normal page once. Website owners should capture the failing request and correlate gateway and application evidence. Before repeating a save, import, or purchase, check whether the original operation completed.
Visitor
Retry a normal page once; check a submission’s status first.
Website owner
Capture the exact URL, method, timestamp, duration, and request ID.
Hosting or developer
Match the request to the waiting layer and verify the original action.
Capture the request that actually failed
A page can open while its save, search, upload, or checkout request fails. Record the action and timestamp first. In browser developer tools, open Network before repeating the action when safe, then select the failed request. Capture its URL, HTTP method, status, response, duration, and request or Ray ID when present. Chrome’s Network reference explains these panels.
- Compare the affected scope: one action, logged-in sessions, particular URLs, a region, or the whole site.
- Record the timestamp with timezone so hosting can locate the same request.
- For a purchase or submission, check confirmation, account history, or the application’s job record before repeating it.
- Review nearby deployments, plugin changes, imports, scheduled work, migrations, traffic, and access-rule changes as leads to test.
Visitor checks when you cannot access server logs
Wait briefly and refresh a normal read-only page once. Try another page on the website. If many websites fail only on your connection, compare another network or device. If this website keeps failing across networks, report the affected URL and time to its owner. Clearing a browser cannot repair an overloaded or unreachable upstream server.
For a checkout, booking, or form, resolve the original outcome before resubmitting. A timeout does not establish cancellation. The website’s operator can check its recorded state.
Capture a public GET with curl
The following command uses a reserved example domain; replace the URL with the affected public page. It records response headers, HTTP status, and total time. It does not reproduce an authenticated POST or checkout.
curl --silent --show-error --output /dev/null --dump-header - \
--max-time 30 \
--write-out '\nhttp_code=%{http_code} time_total=%{time_total}s\n' \
'https://example.com/'
Here, 30 seconds is a client limit. The client may stop before the gateway returns a status. curl exit code 28 is an operation timeout; report whether an HTTP response was actually received. Without a fail option, curl can exit successfully after receiving an HTTP error. Choose any longer observation window with the incident owner.
Identify the waiting layer before changing settings
The HTTP 504 specification defines a gateway waiting too long for an upstream response. The status does not prove an upstream connection succeeded or identify a plugin, database, or API. AWS CloudFront documents blocked origin access as well as response delays. Inspect the responsible gateway’s evidence.
Scroll horizontally to compare all columns.
| Signal | Meaning | First investigation |
|---|---|---|
| 504 | A gateway did not receive a timely upstream response. | Connection, sending, response wait, workload, and active limit. |
| 502 | A gateway received an invalid upstream response. | Use the 502 diagnosis to investigate resets, invalid responses, and upstream connectivity. |
| 503 | The responding service cannot handle the request temporarily. | Use the 503 guide for availability, overload, and maintenance handling. |
| Cloudflare 524 | Cloudflare connected to the origin, then hit a documented read or write limit. | Exact limit and origin operation; see the Cloudflare section. |
| Client timeout | The client stopped waiting; it may receive no HTTP status. | Client deadline, received bytes, and server-side record. |
Decode the log stage
Match the same URL, method, timestamp, and request ID across available logs. The phrases below describe stages; they do not establish the application’s root cause.
Scroll horizontally to compare all columns.
| Observed evidence | What it establishes | Next responsible check |
|---|---|---|
| Timeout while connecting to upstream | The connection was not established within that timer. | Hosting checks the selected address or socket, listener, routing, access rules, and capacity. |
| Timeout while sending the request upstream | A request-write stage stalled. | Hosting/development check whether the upstream accepts the request body and whether the relevant send limit applies. |
| Timeout while reading response headers | The gateway waited for headers. It does not prove a worker immediately began executing. | Check application queue, worker availability, dependency waits, and the matching read limit. |
| Response began, then transfer stalled | Some response data may already have been sent. A later failure need not appear as a new 504. | Check body completeness, upstream read gaps, buffering, and application progress. |
| Cached public pages work; save or checkout fails | The working response may have bypassed the failing application path. | Test the actual uncached/authenticated request and verify its stored result. |
| Failures coincide with jobs or busy periods | There is a workload correlation to investigate. | Compare queue depth, worker pressure, resource use, dependencies, and overlapping jobs. |
Nginx upstream variables can separate upstream connection, header, and response timings; connection time includes a TLS handshake where applicable, and retries can produce multiple values. Browser TTFB is the client’s waiting view, including latency and response preparation. It does not expose database or API spans by itself.
Use correlated application logs or instrumented traces when deeper timing is available. A single PHP slowlog snapshot is an investigation lead, not a measurement of the entire request or proof that the first plugin file caused it.
Diagnose the WordPress action that fails
WordPress may be the application behind the gateway, but the status alone does not identify a faulty component. Find whether the failing request loads a public page, saves a draft, calls an API, runs an import, or completes a checkout.
Scroll horizontally to compare all columns.
| Request path | Useful clue | What to collect next |
|---|---|---|
/wp-admin/admin-ajax.php |
A shared AJAX handler. | Action parameter, initiator, timestamp, and correlated application evidence. The endpoint alone does not name a culprit. |
A /wp-json/ route |
A WordPress REST operation. | Exact namespace, method, authentication context, response, and stored result. |
wp-cron.php |
A scheduler request. | Job/hook, execution window, concurrent workload, queue evidence, and completion state. |
| A normal public URL | The page-serving request. | Cache behavior, application work, origin response, and affected scope. |
The official AJAX handbook and REST API handbook explain the first two routes. For scheduled work, normal cron spawning uses a non-blocking request; visits can trigger work without waiting for the full job. Filters and alternate configurations can change behavior. Correlate shared workload rather than infer a synchronous wait from the cron URL alone.
Worked example: a draft save times out
- A cached public page works, but updating a draft through
POST /wp-json/wp/v2/posts/42returns 504. The owner records the request, incident time, and response. - Hosting locates the same request and a response-header wait. This is evidence of the gateway’s wait; a busy worker queue remains possible.
- A slowlog points to an outbound call. Development checks correlated dependency timing to establish whether that call accounts for the delay. One snapshot alone is insufficient.
- The owner checks whether the draft persisted before repeating the update.
- Suppose tracing confirms a nonessential external synchronization is blocking the save. Development moves that follow-up work into a tracked background job while retaining the save’s required checks.
- Verification repeats the same authenticated update, checks the complete response and stored draft, then confirms the background job’s recorded outcome.
Read the simplified illustrative incident record
Example type: illustrative WordPress incident
Request: POST /wp-json/wp/v2/posts/42
Client status: 504
Gateway stage: waiting for upstream response headers
Operation state: check the stored draft before retrying
Application lead: outbound dependency in slowlog
Confirmation needed: matching request and dependency timing
Repair hypothesis: queue nonessential follow-up work
Verification: same authenticated update, full response,
stored draft, and background job result
When the issue is worker pressure or a heavy job
Ask hosting for worker and queue evidence alongside CPU, memory, and database contention. WP Engine documents its own PHP-backlog eviction behavior; other hosts use different policies. More workers require resources to run them and can move a bottleneck rather than remove it.
For imports, backups, or external synchronization, investigate overlapping runs, batch size, locks, and dependency waits. Smaller batches or tracked background work may fit the job better than an interactive request. Keep essential action semantics and duplicate-prevention rules intact.
If measurements confirm slow application work, the site speed guide helps organize performance follow-up. Selective staging tests and scoped rollback can isolate a recent change. Avoid disabling every plugin, removing all cron events, or clearing every cache as a substitute for diagnosis.
Separate Cloudflare 504, 524, and internal log entries
Capture the exact status, response, headers, Ray ID when present, URL, and time. Cloudflare’s troubleshooting guide distinguishes origin-generated and Cloudflare-generated 502/504 errors. Page appearance helps route the investigation; confirm with logs. A direct-origin comparison must preserve the intended hostname and TLS routing.
As checked on October 14, 2026, Cloudflare’s 524 documentation specifies a default 125-second read limit for 524 and a separate 30-second write limit, with 6.5 seconds for Cloudflare Images writes. These are different limits. Identify the affected product and operation before selecting a change.
A log entry can also describe an internal cache miss. Use the documented request-source fields and the visitor’s actual response to classify it. Origin status zero alone does not establish a harmless miss.
Scroll horizontally to compare all columns.
| RequestSource | Documented context | Action |
|---|---|---|
earlyHintsCache |
An internal Early Hints lookup can log 504 on a cache miss. | Separate the lookup from the visitor response. |
edgeWorkerCacheAPI |
A Workers Cache API miss can create an internal 504; the script receives undefined. | Check the Worker and final visitor outcome. |
| End-user or unexplained source | The benign classification has not been established. | Investigate the request and origin/edge path. |
Change the timer that belongs to the failed path
A CDN, proxy, application, and dependency can have different deadlines. Confirm the active route and inherited values. Increasing PHP execution time does not change a CDN limit, and an HTTP proxy directive does not configure a FastCGI route.
Scroll horizontally to compare all columns.
| Setting | Meaning | Interpretation limit |
|---|---|---|
proxy_read_timeout |
Nginx HTTP-upstream read gaps; default 60 seconds. | Between successive reads, not a fixed total-response budget. Reference |
fastcgi_read_timeout |
Nginx FastCGI-upstream read gaps; default 60 seconds. | Use the settings for the FastCGI route. Reference |
| PHP execution-time limit | Platform-specific accounting of PHP execution time. | Certain external-operation time is excluded on non-Windows systems; Windows uses elapsed time. Reference |
request_slowlog_timeout |
PHP-FPM backtrace threshold. | An execution snapshot; zero means off. Reference |
request_terminate_timeout |
PHP-FPM request-serving termination limit. | Separate from the slowlog and proxy read gap; default zero means off. Reference |
Use connection evidence for connection waits and request/response evidence for the relevant send or read stage. Make a scoped change, validate the responsible configuration, use the host’s supported reload process, and preserve the previous value for rollback. A larger waiting budget permits more waiting; it does not accelerate the work.
Once data has been sent to the client, a late failure has different consequences. Nginx’s upstream retry behavior requires nothing to have been sent before passing the request to another upstream. Check response completeness as well as status.
Measured examples: status and operation outcome can differ
Three local Python standard-library models were prepared for this guide on October 14, 2026. They used synthetic localhost origin/gateway requests. The first two retained 33 requests and 12 passed checks; the third added nine requests and six passed checks. These are demonstrations, not WordPress, PHP, Nginx, Cloudflare, payment, or hosting-production benchmarks.
A timeout before the work completes
In the continuation model, 400 ms of synthetic work exceeded a 150 ms gateway budget. All three requests received 504, while the origin recorded completion afterward. A 650 ms budget allowed the same work to return 200; reducing work to 30 ms succeeded within the original budget. Waiting longer did not make the work faster.
Scroll horizontally to compare all columns.
| Synthetic setup | Observed result | Median client time |
|---|---|---|
| 40 ms work; 150 ms gateway budget | 200, complete work | 43.4 ms |
| 400 ms work; 150 ms gateway budget | 504, then origin work completed | 152.9 ms |
| 400 ms work; 650 ms gateway budget | 200, same amount of work | 404.1 ms |
| 30 ms work; 150 ms gateway budget | 200, less work | 33.8 ms |
| 250 ms work; 500 ms gateway budget; 60 ms client limit | Client timeout; no received HTTP status | 60.7 ms |
Queue policy changes the operation outcome
A continuation queue ran three concurrent 120 ms jobs through one worker with a 280 ms budget, for three rounds. Each round produced two 200s and one 504; all nine jobs eventually completed. Median client time across those requests was 246.3 ms.
The second model discarded work whose budget had expired before it started. With three concurrent 120 ms jobs, one worker, and a 180 ms budget, each of three rounds produced one 200, one 504 followed by completion, and one 504 discarded before starting. Median client time was 183.6 ms.
Scroll horizontally if needed to inspect the timeline labels.
- Work continued: gateway response at 150.7 ms; origin completion at 401.5 ms.
- Queued work continued: gateway response at 181.4 ms; completion at 241.1 ms.
- Expired queued work: gateway response at 180.7 ms; discarded before starting at 240.4 ms.
A 200 response can still be incomplete
The third model used an 80 ms upstream read gap and a ten-byte expected origin body, ready-DONE. In one scenario the origin sent 200 and the six-byte prefix ready-, then stalled for 200 ms. The gateway closed the response after already forwarding 200. All three clients recorded an incomplete-read error.
Scroll horizontally to compare all columns.
| Case | Received status | Body result | Median client time |
|---|---|---|---|
| Healthy control | 200 in all three | Complete ten-byte origin body | 13.3 ms |
| 200 ms stall before headers | 504 in all three | Complete gateway error body; origin payload not delivered | 81.5 ms |
| 200 ms stall after headers and prefix | 200 in all three | Six of ten expected origin bytes; IncompleteRead | 82.5 ms |
The practical rule is to inspect the application result. A timeout can accompany completed, pending, or discarded work; a successful status can accompany an incomplete transfer. HTTP retry guidance restricts automatic retries of non-idempotent requests unless their semantics or known application state make repeating them appropriate.
The expandable method appendix contains the portable scripts, recorded values, and reproduction instructions on this page.
Verify the original action and prevent recurrence
- Status: the affected request returns the intended status. Check the actual method, path, authentication, and cache context.
- Complete response: the expected content or payload fully arrives. A 200 error page or truncated body does not establish recovery.
- Application result: the save persisted, the export is complete, or the order/job has the intended recorded state. Resolve the original attempt before creating another.
- Same conditions: repeat the actual journey with the earlier batch size or relevant workload. A public GET or HEAD request is not a substitute for a failing authenticated POST.
- Recurrence: review logs and resource evidence during the previously failing job or busy period. Coordinate controlled workload checks with hosting.
- Record the cause, change, responsible owner, verification, and rollback. Monitor affected routes, queue time, worker pressure, and slow dependencies.
If a change merely moves the timeout to another layer, revisit the request path. Long jobs benefit from explicit progress and recorded completion state; overlapping heavy work needs deliberate scheduling.
Copy an incident worksheet
Use this record to keep the owner, host, and developer on the same incident. Select the text and copy it into your support ticket or incident notes. Redact authentication headers, session cookies, and customer data before sharing attachments.
This is a reusable blank record. It contains no client data.
Copy the hosting support request
Subject: Investigate a reproducible 504 Gateway Timeout
I have attached the incident record with the exact request,
timezone, received response, duration, scope, and operation state.
Please correlate it with the gateway and application records.
Which hop and stage timed out: connection, request sending,
response headers, response body, or another deadline?
Did the application start, finish, queue, or discard the work?
Is there a slow dependency, worker pressure, or a resource limit?
Please propose a scoped correction and verify the same action,
including its complete response and recorded application result.
Note the prior setting or rollback and remaining limits.
Search impact: restore availability, then check crawl evidence
Persistent public-page errors can interfere with crawling and indexing. Google’s crawler documentation says 5xx and 429 responses slow crawling temporarily and persistent failures can affect indexing. It does not establish a universal six-hour penalty or guaranteed recovery deadline.
Restore the intended response first. Then review affected URLs, incident dates, crawl/indexing evidence, and live canonical/directives. The crawl-error guide helps distinguish ongoing failures from older reporting. Admin-only job failures and repeated public product-page failures have different exposure.
Use the technical SEO guide to organize availability alongside internal discovery and indexing. A successful response check is a prerequisite, not a promise of immediate ranking recovery.
Method appendix: inspect and reproduce the local evidence
The examples use Python 3 and the standard library. Copy each script into its own .py file and run it from a writable local folder. Scripts start ephemeral localhost servers and write adjacent JSON records. Reruns replace those files; preserve originals separately. The recorded values below remain the guide’s original measurements.
Model 1: origin work continues after a gateway timeout
"""Owned local HTTP-gateway demonstration. Not Nginx, PHP, CDN, or a client site."""
import concurrent.futures, http.client, json, pathlib, socket, statistics, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = pathlib.Path(__file__).resolve().parent
events=[]; lock=threading.Lock(); worker=threading.Semaphore(1); started=time.monotonic()
def log(kind,job,**details):
with lock: events.append({'elapsed_s':round(time.monotonic()-started,4),'event':kind,'job':job,**details})
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def payload(self):return json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))
def reply(self,status,data):
body=json.dumps(data).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)));self.end_headers()
try:self.wfile.write(body)
except (BrokenPipeError,ConnectionResetError):pass
class Origin(Handler):
def do_POST(self):
v=self.payload();job=v['job'];log('origin_received',job)
if v.get('queued'):worker.acquire()
try:
log('origin_work_started',job);time.sleep(v['delay']);log('origin_work_completed',job)
self.reply(200,{'job':job,'completed':True})
finally:
if v.get('queued'):worker.release()
class Gateway(Handler):
def do_POST(self):
v=self.payload();job=v['job'];log('gateway_received',job,budget_s=v['budget'])
c=http.client.HTTPConnection('127.0.0.1',origin.server_port,timeout=v['budget'])
try:
c.request('POST','/work',body=json.dumps(v),headers={'Content-Type':'application/json'});r=c.getresponse();body=json.loads(r.read());log('gateway_response',job,status=r.status);self.reply(r.status,body)
except (TimeoutError,socket.timeout):
log('gateway_response',job,status=504);self.reply(504,{'job':job,'completed':'unknown at gateway','error':'upstream response timeout'})
finally:c.close()
origin=ThreadingHTTPServer(('127.0.0.1',0),Origin);gateway=ThreadingHTTPServer(('127.0.0.1',0),Gateway)
for server in [origin,gateway]:threading.Thread(target=server.serve_forever,daemon=True).start()
def request(job,delay,budget,queued=False,client_timeout=2):
v={'job':job,'delay':delay,'budget':budget,'queued':queued};c=http.client.HTTPConnection('127.0.0.1',gateway.server_port,timeout=client_timeout);t=time.monotonic()
try:
c.request('POST','/work',body=json.dumps(v),headers={'Content-Type':'application/json'});r=c.getresponse();body=json.loads(r.read());status=r.status
except (TimeoutError,socket.timeout):status='client timeout, no HTTP status';body={}
finally:c.close()
return {'job':job,'origin_service_delay_s':delay,'gateway_read_budget_s':budget,'queued':queued,'client_timeout_s':client_timeout,'observed_status':status,'elapsed_s':round(time.monotonic()-t,4),'response':body}
results=[]
try:
for repeat in range(1,4):
for name,delay,budget in [('healthy',.04,.15),('slow',.4,.15),('longer_gateway_budget',.4,.65),('faster_origin',.03,.15)]:
results.append(request(f'{name}-{repeat}',delay,budget));time.sleep(.45 if name=='slow' else .03)
results.append(request(f'client_timeout-{repeat}',.25,.5,client_timeout=.06));time.sleep(.30)
# Three requests share one application worker. Each service takes .12 s;
# the third also waits behind the first two before its own work can start.
for repeat in range(1,4):
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
jobs=[pool.submit(request,f'queued-{repeat}-{n}',.12,.28,True) for n in range(1,4)]
results.extend(j.result() for j in jobs)
time.sleep(.45)
time.sleep(.2)
completed={e['job']:e['elapsed_s'] for e in events if e['event']=='origin_work_completed'}
replies={e['job']:(e['elapsed_s'],e['status']) for e in events if e['event']=='gateway_response'}
for r in results:
r['origin_eventually_completed']=r['job'] in completed
r['origin_completed_after_gateway_response']=r['job'] in replies and r['job'] in completed and completed[r['job']]>replies[r['job']][0]
bycase={}
for r in results:
case=r['job'].split('-')[0];bycase.setdefault(case,[]).append(r)
summaries=[{'case':case,'requests':len(rows),'statuses':[r['observed_status'] for r in rows],'median_elapsed_s':round(statistics.median(r['elapsed_s'] for r in rows),4),'all_origin_work_eventually_completed':all(r['origin_eventually_completed'] for r in rows)} for case,rows in bycase.items()]
checks={
'healthy_returns_200':all(r['observed_status']==200 for r in bycase['healthy']),
'slow_returns_504':all(r['observed_status']==504 for r in bycase['slow']),
'slow_work_completes_after_504':all(r['origin_completed_after_gateway_response'] for r in bycase['slow']),
'longer_budget_waits_for_200':all(r['observed_status']==200 for r in bycase['longer_gateway_budget']),
'faster_origin_restores_200':all(r['observed_status']==200 for r in bycase['faster_origin']),
'client_timeout_has_no_http_status':all(r['observed_status']=='client timeout, no HTTP status' for r in bycase['client_timeout']),
'every_queue_round_has_two_successes_one_timeout':all(sorted(r['observed_status'] for r in bycase['queued'] if r['job'].startswith('queued-'+str(n)+'-'))==[200,200,504] for n in range(1,4)),
'all_queued_work_eventually_completes':all(r['origin_eventually_completed'] for r in bycase['queued'])
}
payload={'scope':'Python standard-library origin and gateway on localhost; synthetic delays; no WordPress/Nginx/Cloudflare production testing','repetitions_per_case':3,'summaries':summaries,'checks':checks,'results':results,'events':events}
(ROOT/'timeout-lab-results.json').write_text(json.dumps(payload,indent=2));print(json.dumps({'scope':payload['scope'],'summaries':summaries,'checks':checks},indent=2))
if not all(checks.values()):raise SystemExit('A lab expectation failed; inspect results before using')
finally:
gateway.shutdown();origin.shutdown();gateway.server_close();origin.server_close()
Model 2: expired queued work is discarded before starting
"""Local queue-policy contrast: discard expired work before it starts. Not WP Engine."""
import concurrent.futures, http.client, json, pathlib, socket, statistics, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = pathlib.Path(__file__).resolve().parent
events=[]; lock=threading.Lock(); worker=threading.Semaphore(1); started=time.monotonic()
def log(kind,job,**details):
with lock: events.append({'elapsed_s':round(time.monotonic()-started,4),'event':kind,'job':job,**details})
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def payload(self):return json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))
def reply(self,status,data):
body=json.dumps(data).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)));self.end_headers()
try:self.wfile.write(body)
except (BrokenPipeError,ConnectionResetError):pass
class Origin(Handler):
def do_POST(self):
v=self.payload();job=v['job'];log('origin_received',job)
if v.get('queued'):worker.acquire()
try:
if time.monotonic()-v['sent_at'] >= v['budget']:
log('origin_work_discarded_before_start',job);self.reply(504,{'job':job,'completed':False,'discarded':True});return
log('origin_work_started',job);time.sleep(v['delay']);log('origin_work_completed',job)
self.reply(200,{'job':job,'completed':True})
finally:
if v.get('queued'):worker.release()
class Gateway(Handler):
def do_POST(self):
v=self.payload();job=v['job'];log('gateway_received',job,budget_s=v['budget'])
c=http.client.HTTPConnection('127.0.0.1',origin.server_port,timeout=v['budget'])
try:
c.request('POST','/work',body=json.dumps(v),headers={'Content-Type':'application/json'});r=c.getresponse();body=json.loads(r.read());log('gateway_response',job,status=r.status);self.reply(r.status,body)
except (TimeoutError,socket.timeout):
log('gateway_response',job,status=504);self.reply(504,{'job':job,'completed':'unknown at gateway','error':'upstream response timeout'})
finally:c.close()
origin=ThreadingHTTPServer(('127.0.0.1',0),Origin);gateway=ThreadingHTTPServer(('127.0.0.1',0),Gateway)
for server in [origin,gateway]:threading.Thread(target=server.serve_forever,daemon=True).start()
def request(job,delay,budget,queued=False,client_timeout=2):
v={'job':job,'delay':delay,'budget':budget,'queued':queued,'sent_at':time.monotonic()};c=http.client.HTTPConnection('127.0.0.1',gateway.server_port,timeout=client_timeout);t=time.monotonic()
try:
c.request('POST','/work',body=json.dumps(v),headers={'Content-Type':'application/json'});r=c.getresponse();body=json.loads(r.read());status=r.status
except (TimeoutError,socket.timeout):status='client timeout, no HTTP status';body={}
finally:c.close()
return {'job':job,'origin_service_delay_s':delay,'gateway_read_budget_s':budget,'queued':queued,'client_timeout_s':client_timeout,'observed_status':status,'elapsed_s':round(time.monotonic()-t,4),'response':body}
results=[]
try:
for repeat in range(1,4):
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
jobs=[pool.submit(request,f'policy-{repeat}-{n}',.12,.18,True) for n in range(1,4)]
results.extend(j.result() for j in jobs)
time.sleep(.4)
time.sleep(.2)
completed={e['job']:e['elapsed_s'] for e in events if e['event']=='origin_work_completed'}
replies={e['job']:(e['elapsed_s'],e['status']) for e in events if e['event']=='gateway_response'}
for r in results:
r['origin_eventually_completed']=r['job'] in completed
r['origin_completed_after_gateway_response']=r['job'] in replies and r['job'] in completed and completed[r['job']]>replies[r['job']][0]
bycase={}
for r in results:
case=r['job'].split('-')[0];bycase.setdefault(case,[]).append(r)
summaries=[{'case':case,'requests':len(rows),'statuses':[r['observed_status'] for r in rows],'median_elapsed_s':round(statistics.median(r['elapsed_s'] for r in rows),4),'all_origin_work_eventually_completed':all(r['origin_eventually_completed'] for r in rows)} for case,rows in bycase.items()]
discarded={e['job'] for e in events if e['event']=='origin_work_discarded_before_start'}
begun={e['job'] for e in events if e['event']=='origin_work_started'}
checks={
'each_round_one_200_two_504':all(sorted(r['observed_status'] for r in results if r['job'].startswith('policy-'+str(n)+'-'))==[200,504,504] for n in range(1,4)),
'each_round_has_both_timeout_outcomes':all(sorted(r['origin_eventually_completed'] for r in results if r['observed_status']==504 and r['job'].startswith('policy-'+str(n)+'-'))==[False,True] for n in range(1,4)),
'three_expired_jobs_discarded':len(discarded)==3,
'discarded_jobs_never_started':not (discarded & begun)
}
payload={'scope':'Owned Python gateway and single-worker synthetic origin; expired queued work discarded before start; not WP Engine or a production test','repetitions_per_case':3,'summaries':summaries,'checks':checks,'results':results,'events':events}
(ROOT/'queue-policy-lab-results.json').write_text(json.dumps(payload,indent=2));print(json.dumps({'scope':payload['scope'],'summaries':summaries,'checks':checks},indent=2))
if not all(checks.values()):raise SystemExit('A lab expectation failed; inspect results before using')
finally:
gateway.shutdown();origin.shutdown();gateway.server_close();origin.server_close()
Model 3: response-header timeout versus incomplete 200 body
"""Synthetic HTTP/1.1 model: deadline before headers vs stall after forwarding 200.
Python standard library only. This is not Nginx, PHP, WordPress, or production.
Reruns replace the adjacent JSON file. Original measured output is packaged.
"""
import http.client
import json
import pathlib
import socket
import statistics
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
ROOT = pathlib.Path(__file__).resolve().parent
events = []
event_lock = threading.Lock()
started = time.monotonic()
PAYLOAD = b'ready-DONE'
PREFIX = b'ready-'
READ_GAP = 0.08
def record(event, job, **fields):
with event_lock:
events.append({'elapsed_s': round(time.monotonic()-started, 6), 'event': event, 'job': job, **fields})
class Base(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args): pass
def send_body(self, status, body):
self.send_response(status)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.send_header('Connection', 'close')
self.end_headers()
try:
self.wfile.write(body);self.wfile.flush()
except (BrokenPipeError, ConnectionResetError): pass
self.close_connection = True
class Origin(Base):
def do_GET(self):
q = parse_qs(urlsplit(self.path).query)
case = q['case'][0];job = q['job'][0]
record('origin_received', job)
if case == 'before_headers': time.sleep(0.20)
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(PAYLOAD)))
self.send_header('Connection', 'close')
self.end_headers();record('origin_headers_sent', job, status=200)
try:
self.wfile.write(PREFIX);self.wfile.flush()
record('origin_prefix_sent', job, bytes=len(PREFIX))
if case == 'after_headers': time.sleep(0.20)
else: time.sleep(0.01)
self.wfile.write(PAYLOAD[len(PREFIX):]);self.wfile.flush()
record('origin_body_sent', job, bytes=len(PAYLOAD))
except (BrokenPipeError, ConnectionResetError): record('origin_client_connection_closed', job)
self.close_connection = True
class Gateway(Base):
def do_GET(self):
q = parse_qs(urlsplit(self.path).query);job = q['job'][0]
record('gateway_received', job, read_gap_s=READ_GAP)
conn = http.client.HTTPConnection('127.0.0.1', origin.server_port, timeout=READ_GAP)
sent_headers = False
try:
conn.request('GET', self.path)
upstream = conn.getresponse()
self.send_response(upstream.status)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', upstream.getheader('Content-Length'))
self.send_header('Connection', 'close')
self.end_headers();sent_headers = True
record('gateway_headers_forwarded', job, status=upstream.status)
sent = 0
while True:
chunk = upstream.read(1)
if not chunk: break
self.wfile.write(chunk);self.wfile.flush();sent += len(chunk)
record('gateway_body_byte_forwarded', job, bytes=sent)
record('gateway_body_complete', job, bytes=sent)
except (socket.timeout, TimeoutError):
if not sent_headers:
record('gateway_timeout_before_headers', job)
self.send_body(504, b'gateway deadline before response headers')
record('gateway_headers_forwarded', job, status=504)
else:
record('gateway_timeout_after_headers', job, sent_status=200)
# The existing 200 headers cannot be replaced. Close the body.
except (BrokenPipeError, ConnectionResetError): record('gateway_client_connection_closed', job)
finally:
conn.close();self.close_connection = True
origin = ThreadingHTTPServer(('127.0.0.1', 0), Origin)
gateway = ThreadingHTTPServer(('127.0.0.1', 0), Gateway)
for server in (origin, gateway):
threading.Thread(target=server.serve_forever, daemon=True).start()
results = []
try:
for repetition in range(1, 4):
for case in ('healthy', 'before_headers', 'after_headers'):
job = f'{case}-{repetition}'
conn = http.client.HTTPConnection('127.0.0.1', gateway.server_port, timeout=2)
tick = time.monotonic();status = None;body = b'';error = None
try:
conn.request('GET', f'/work?case={case}&job={job}')
response = conn.getresponse();status = response.status
try: body = response.read()
except http.client.IncompleteRead as e:
body = e.partial;error = 'IncompleteRead'
finally: conn.close()
results.append({'job':job,'case':case,'observed_status':status,'client_body':body.decode(),'received_body_bytes':len(body),'expected_origin_body_bytes':len(PAYLOAD),'expected_origin_payload_received':case != 'before_headers' and body == PAYLOAD,'http_response_framing_complete':error is None,'client_error':error,'elapsed_s':round(time.monotonic()-tick,6)})
# Join outstanding synthetic origin delays before retaining the event record.
time.sleep(0.25)
finally:
for server in (gateway, origin):server.shutdown();server.server_close()
by_case = {case:[r for r in results if r['case']==case] for case in ('healthy','before_headers','after_headers')}
checks = {
'healthy_returns_200_in_all_three':all(r['observed_status']==200 for r in by_case['healthy']),
'healthy_full_body_in_all_three':all(r['expected_origin_payload_received'] for r in by_case['healthy']),
'before_headers_returns_504_in_all_three':all(r['observed_status']==504 for r in by_case['before_headers']),
'after_headers_preserves_200_in_all_three':all(r['observed_status']==200 for r in by_case['after_headers']),
'after_headers_body_is_incomplete_in_all_three':all(r['client_error']=='IncompleteRead' and r['received_body_bytes']==len(PREFIX) for r in by_case['after_headers']),
'each_body_timeout_follows_forwarded_200':all(next(e['elapsed_s'] for e in events if e['job']==r['job'] and e['event']=='gateway_headers_forwarded') < next(e['elapsed_s'] for e in events if e['job']==r['job'] and e['event']=='gateway_timeout_after_headers') for r in by_case['after_headers'])
}
summary = [{'case':case,'requests':len(rows),'statuses':[r['observed_status'] for r in rows],'median_elapsed_ms':round(statistics.median(r['elapsed_s'] for r in rows)*1000,1),'complete_origin_payload_count':sum(r['expected_origin_payload_received'] for r in rows),'client_errors':[r['client_error'] for r in rows]} for case,rows in by_case.items()]
output = {'scope':'Owned Python HTTP/1.1 origin/gateway on localhost with an 80ms read gap; synthetic timing only, not Nginx/PHP/WordPress/Cloudflare or production.','repetitions_per_case':3,'summary':summary,'checks':checks,'results':results,'events':events}
(ROOT/'header-body-timeout-results.json').write_text(json.dumps(output,indent=2))
print(json.dumps({'summary':summary,'checks':checks},indent=2))
if not all(checks.values()):raise SystemExit('A defined experiment check failed; inspect retained data.')
Inspect selected recorded requests and all plotted values
{
"scope": "Owned synthetic localhost models; individual request records, not production tests.",
"selected_continuation_request": {
"job": "slow-1",
"origin_service_delay_s": 0.4,
"gateway_read_budget_s": 0.15,
"queued": false,
"client_timeout_s": 2,
"observed_status": 504,
"elapsed_s": 0.1529,
"response": {
"job": "slow-1",
"completed": "unknown at gateway",
"error": "upstream response timeout"
},
"origin_eventually_completed": true,
"origin_completed_after_gateway_response": true
},
"plot_values": [
{
"job": "slow-1",
"label": "Work continued; 150 ms gateway budget",
"gateway_budget_ms": 150.0,
"origin_received_ms": 1.3,
"work_started_ms": 1.3,
"work_completed_ms": 401.5,
"work_discarded_ms": null,
"gateway_response_ms": 150.7,
"observed_status": 504,
"source_file": "timeout-lab-results.json"
},
{
"job": "policy-1-2",
"label": "Queued work continued; 180 ms gateway budget",
"gateway_budget_ms": 180.0,
"origin_received_ms": 1.1,
"work_started_ms": 121.0,
"work_completed_ms": 241.1,
"work_discarded_ms": null,
"gateway_response_ms": 181.4,
"observed_status": 504,
"source_file": "queue-policy-lab-results.json"
},
{
"job": "policy-1-3",
"label": "Expired queued work discarded; 180 ms gateway budget",
"gateway_budget_ms": 180.0,
"origin_received_ms": 0.5,
"work_started_ms": null,
"work_completed_ms": null,
"work_discarded_ms": 240.4,
"gateway_response_ms": 180.7,
"observed_status": 504,
"source_file": "queue-policy-lab-results.json"
}
],
"header_body_summary": [
{
"case": "healthy",
"requests": 3,
"statuses": [
200,
200,
200
],
"median_elapsed_ms": 13.3,
"client_errors": [
null,
null,
null
],
"complete_origin_payload_count": 3
},
{
"case": "before_headers",
"requests": 3,
"statuses": [
504,
504,
504
],
"median_elapsed_ms": 81.5,
"client_errors": [
null,
null,
null
],
"complete_origin_payload_count": 0
},
{
"case": "after_headers",
"requests": 3,
"statuses": [
200,
200,
200
],
"median_elapsed_ms": 82.5,
"client_errors": [
"IncompleteRead",
"IncompleteRead",
"IncompleteRead"
],
"complete_origin_payload_count": 0
}
],
"header_body_checks": {
"healthy_returns_200_in_all_three": true,
"healthy_full_body_in_all_three": true,
"before_headers_returns_504_in_all_three": true,
"after_headers_preserves_200_in_all_three": true,
"after_headers_body_is_incomplete_in_all_three": true,
"each_body_timeout_follows_forwarded_200": true
}
}
504 Gateway Timeout: common questions
Is a 504 caused by my internet or the website?
HTTP 504 comes from a gateway on the request path. If one website fails across networks, report it to its operator. If many websites fail only on your connection, compare another network. A client timeout without an HTTP response is a different observation.
Will clearing the cache fix a 504?
Browser cache clearing cannot repair an upstream timeout. Server caching can reduce some application work, but authenticated saves, API calls, or checkout may bypass it. Diagnose the affected path before changing cache behavior.
Should I raise the WordPress timeout?
First identify the active timer and why it expires. Longer waiting can accommodate legitimate work; a blocked connection, slow dependency, or saturated queue still needs attention. Make scoped changes and verify the actual action.
Can I submit a purchase or form again?
Check the original operation’s state first. A 504 does not establish whether work finished, remains pending, or never began. Use the application’s documented retry and duplicate-prevention rules.
Why do I see 504 in Cloudflare logs while visitors get 200?
Some documented internal cache misses log 504. Inspect RequestSource and the final visitor response. Origin status zero alone does not establish the benign case.
Does a 200 prove the website recovered?
No. Check the complete intended response and the application’s recorded result. A 200 error page, incomplete transfer, or failed save can still leave the original task unresolved.
How long will SEO recovery take?
There is no universal deadline. Restore reliable public responses, then monitor recrawling and affected-page evidence. The duration and scope of the incident matter; immediate ranking recovery is not guaranteed.
The one thing to do next
Capture one failed request and its operation state, then ask hosting to identify the waiting hop. Use the incident worksheet to keep the evidence connected. After availability is restored, recurring crawl or indexing consequences can justify a technical SEO review with a URL-level diagnosis and follow-up plan.
