Skip to content
System nominalUTC --:--:--Articles 041

Tracking Pages That Work When JavaScript Does Not Run

A tracking page should answer the question before any script runs. That means a real form, a stable URL and a server-rendered result, with JavaScript added on top.

Priya Raghunathan · @praghunathan

Frontend and accessibility

Topic
FE
Section
Frontend & Accessibility
Published
01 OCT 2026
Reading time
8 MIN
Words
2073W
Technical cover graphic for Tracking Pages That Work When JavaScript Does Not Run
Generated schematic — FE series, REV 2.6.

The complaints arrive in the same shape every time. Customers say the tracking page "does nothing" when they click the button. It is not a network outage, and it is not a database problem. A script tag failed to run, and the only thing the page ever knew how to do was run a script.

That failure takes several forms and they all end in the same blank rectangle. A content blocker with an aggressive blocklist. A corporate proxy that rewrites third-party requests and breaks the bundle. An older browser where a newer syntax feature in the bundle throws a parse error, so the whole file dies on line 1 and nothing executes. A phone on a slow connection where the user taps the button, watches a spinner that has not started spinning yet, and closes the tab before the script finishes downloading and hydrating.

Accessibility is the usual reason given for building a no-JavaScript path, and it is a good one. It is not the whole reason. Every item on that list is a person who wanted to check an order and could not, and none of them has a screen reader.

The fix is not a fallback page bolted on afterwards. It is building the server-rendered path first and treating the scripted one as an optimisation. That order is much cheaper than it sounds, because the server side is the part that has to exist anyway.

A failed script is a delivery failure, not an edge case

Front-end work tends to assume the bundle runs. The assumption is reasonable in a local environment and wrong on production traffic. Scripts fail for reasons you do not control: the user's extensions, the network between you and them, the ageing device they have not replaced, and the proxy your B2B customers sit behind. When a script fails, nothing catches it, nothing reports it, and the page keeps rendering as if everything were fine. That last part is the problem. There is no error state, because the author never wrote one.

The people this hits hardest are not a niche. Retailers with a large share of older customers, any store whose traffic comes through workplace networks, and anyone selling to regions where the average phone is several years behind the current release. These are also exactly the orders that generate the most support tickets per order, which is why the tracking page is a bad place to be clever.

The accessibility argument still matters and it points the same direction. A keyboard user navigating a real form gets the browser's own submit handling, the browser's own error states, and the browser's own back button. A custom scripted button has to rebuild all three and usually rebuilds two.

A GET form is already a working application

The most reliable version of the search is the one the browser has supported for thirty years. A form with method="get", an action pointing at the tracking route, and one named input produces a submittable URL with no script involved.

<form class="tracking-search" method="get" action="/tracking/">
  <label for="tracking-id">Tracking number</label>
  <input
    type="text"
    id="tracking-id"
    name="tracking_id"
    inputmode="latin"
    autocomplete="off"
    autocapitalize="characters"
    spellcheck="false"
    required
  >
  <button type="submit">Track order</button>
</form>

Four details in that markup do more work than they look like they do. The name attribute is what puts the value in the query string, so a form without it submits and discards everything. The label with a matching for gives the input an accessible name, which no placeholder provides. inputmode and autocapitalize are about the on-screen keyboard, because tracking numbers are usually typed on a phone. And the required attribute lets the browser refuse an empty submit before a request is even made, which is the same validation you would otherwise write in JavaScript.

The browser's default behaviour is already good. Hitting Enter in the field submits the form. The button is a real button and receives focus in the normal tab order. The resulting URL appears in the address bar, so the customer can bookmark it, text it to a partner, or paste it into the support thread. You get all of that by not interfering.

A stable URL is what makes the result shareable and cacheable

A form submitting to /tracking/?tracking_id=SF-8842-QK works, and it is enough to ship. WordPress will happily resolve that request against the front page unless you intervene, which is the one thing to check. It is also an ugly URL that leaks the raw parameter name, and it gives you no clean place to hang a canonical redirect.

A rewrite rule gives you /tracking/SF-8842-QK/ instead, and the clean form is worth the twenty lines of plugin code. Register the route and its query var on init:

add_action( 'init', function () {
    add_rewrite_tag( '%tracking_id%', '([A-Za-z0-9-]+)' );

    add_rewrite_rule(
        '^tracking/([A-Za-z0-9-]+)/?$',
        'index.php?tracking_id=$matches[1]',
        'top'
    );
} );

Then read the value back inside your template or shortcode with get_query_var( 'tracking_id' ), and render the timeline from the order it identifies. Two operational details matter here. Rewrite rules are stored in the database, so a new rule does nothing until permalinks are flushed; do that once on plugin activation rather than on every request. And the ID in the URL is allowed to be wrong, so the lookup has to handle the miss explicitly. Return a real 404 with a message the customer can act on, not a 200 page that says nothing was found. A 200 would let a search engine index a page of missing orders, and it would tell your own monitoring that everything was healthy.

The URL shape also decides what you can do with a reverse proxy. A path with the parameter in it is a distinct cache key by default, while a query string depends on how the proxy and the page cache are configured to treat parameters. On a launch day, when everyone with an order is refreshing the same page, that difference shows up as backend load. Set a short cache lifetime on tracking pages, a minute or two rather than an hour, because a stale tracking page is worse than no cache at all.

Layer the enhancement on top, and keep the form underneath

Once the no-script path is solid, the script has a clear job: stop the full page reload when it can. Keep the form exactly as it is and intercept the submit event. If anything about the interception fails, let the browser do what it would have done anyway.

const form = document.querySelector( '.tracking-search' );
const results = document.querySelector( '[data-tracking-results]' );

form.addEventListener( 'submit', async ( event ) => {
  if ( ! window.fetch ) {
    return; // No fetch means the browser handles the submit.
  }

  event.preventDefault();

  const id = new FormData( form ).get( 'tracking_id' );
  results.setAttribute( 'aria-busy', 'true' );

  try {
    const response = await fetch( `/tracking/${ encodeURIComponent( id ) }/`, {
      headers: { 'X-Requested-With': 'fetch' },
    } );

    if ( ! response.ok ) {
      throw new Error( `Tracking request failed: ${ response.status }` );
    }

    results.innerHTML = await response.text();
    history.pushState( {}, '', `/tracking/${ encodeURIComponent( id ) }/` );
  } catch ( error ) {
    // Never leave the container empty. Fall back to the form's own action.
    form.submit();
  } finally {
    results.removeAttribute( 'aria-busy' );
  }
} );

The X-Requested-With header is the cheap way to tell the server which response to send. Without it you are rendering the full theme around a fragment, wasting bandwidth and creating a layout that has to be reconciled by hand. With it, a small branch in the template returns just the timeline.

There is a second reason to keep that branch small. Every fragment the script can request is a second rendering path, and a second rendering path is a second place for the two versions to disagree. When the markup differs between the no-script page and the fetched fragment, the page looks inconsistent depending on how the customer arrived. Share one template partial between them and the disagreement cannot happen.

Cache interaction

If a page cache sits in front of the site, the fragment response needs the same cache rules as the full page, or the request flag has to bypass the cache. On one store running a page cache with the fragment cached for ten minutes, a status change took that long to appear for customers who arrived through the scripted path while the no-script path showed it immediately.

The empty container is the failure mode to design against

The common shape of a broken tracking page is a scripted search that renders results into an empty div. When the fetch fails, the container stays empty. The customer sees the form they just submitted, no error, and no explanation. Nothing on the page tells them whether the order does not exist or the request never left the browser.

Three things have to be true instead. A failed request produces a visible error message in the container, written for the customer and not for the console. The customer gets a way to retry without reloading the page. And the underlying URL still works, so opening /tracking/SF-8842-QK/ directly in a new tab returns the same result the script was trying to fetch. That third property is what turns a bug into an inconvenience.

CapabilityWorks without JavaScriptWorks with JavaScript
Submit a tracking numberYes, native form submitYes, intercepted, no reload
See the current statusYes, server-rendered on loadYes, same markup
Share or bookmark the resultYes, path-based URLYes, via pushState
Extract the ID from a pasted linkYes, with an input handlerYes, with an input handler
Retry after a network errorResubmit the form or reloadIn-place retry button
Update without losing scroll positionNoYes
Poll for status changesNoYes

Test order matters and it follows from that table. Open the page with JavaScript disabled in the browser, submit the form, and confirm the result arrives. Then break the script deliberately, through the browser's request blocking or by renaming the bundle, and confirm the page still answers the question. If you only ever test with scripts on, the no-script path rots quietly in production until the day a customer finds it for you.

The limit: polling is the one thing you cannot do

A no-script path cannot poll. There is no way to ask the server for a new status on a timer without a script, and a meta refresh is a full page reload that throws away scroll position and interrupts whatever the customer was doing. Be honest about that in the design rather than pretending the fallback is equivalent.

The useful move is to decide which information is essential and which is a convenience. Essential means the customer can answer their own question from the first server-rendered response: which carrier, which tracking number, what the current status is, when it last changed, and the estimated delivery date if you have one. That set is small, it changes a few times over the life of a parcel, and it fits in one document. Convenience means everything that changes while the customer is watching: a live position on a map, a countdown, a step that flips from pending to done without a reload. Put that behind the script and let it be absent.

The same reasoning gives you a cheap middle option for the impatient case. If the status does change while the page is open, a short cache lifetime plus a visible "refresh this page" link costs nothing and works for every customer, scripted or not. It is not the same experience as polling. It is the correct amount of engineering for a page that most people open once.

What to do on Monday

  1. Disable JavaScript and submit the tracking form on staging. If nothing happens, the form is the problem, not the script.
  2. Check that the input has a name and a matching label, and that the form uses method="get" with an action that resolves.
  3. Add the rewrite rule and the rewrite tag, flush permalinks once, and confirm /tracking/{id}/ renders the result on a cold request.
  4. Make the missing-order case return a 404 with a readable message, and set a short cache lifetime on the route.
  5. Wrap the existing AJAX path so a failed request falls back to form.submit() and shows a visible error, then test it with the network throttled to something unusable.
  6. Write down which fields are essential. Anything that polls belongs in the convenience column, and the page has to read as complete without it.

Priya Raghunathan · @praghunathan

Frontend and accessibility

Reviews interfaces for what happens when JavaScript fails, the keyboard is the only input, and the connection is slow. Focused on Core Web Vitals, semantic markup and progressive enhancement.

Continue

Related in Frontend & Accessibility