jcardena.com Blog The art of the progress bar before AJAX existed
145 posts
EN ES

The art of the progress bar before AJAX existed

Web

Explore how we managed long-running server tasks pre-AJAX with HTTP redirects and meta-refresh for responsive UIs. This historical web architecture pattern of decoupling work and providing status feed

The user clicked “Generate Report,” and the browser just… stalled. The little icon in the corner stopped spinning. The tab title said “Connecting…” but there was no actual progress. Was it working? Was it broken? Should they click it again? This blocking UI experience defined the early web, and as an architect, I was driven to find a way to provide user feedback. Today, I rely on techniques like long polling with `fetch` or WebSockets for such scenarios. But back then, without mature JavaScript frameworks or `XMLHttpRequest`, my team and I had to get creative with the raw materials of the web: HTML, HTTP headers, and server-side trickery. I learned to build asynchronous feedback loops out of synchronous parts.

The Anatomy of a Blocking Page

The core problem was the single, blocking request-response cycle of HTTP. A user submits a form, the browser sends a POST request, and then it waits. For a three-second database query, this was acceptable. But for a two-minute report generation or a complex data processing job, it led to a poor user experience. The user had no idea what was happening. They’d get impatient, hit refresh, and potentially trigger the expensive operation all over again, sometimes overwhelming the server. I needed to give them two things: an immediate sign that their request was received, and a persistent signal that work was still in progress. The first part was straightforward. The second part, without a persistent connection or client-side scripting, presented a significant architectural challenge.

User requestServer starts jobRedirect to statusPolling page loadsWork done?Redirect to result
Pre-AJAX Async UI Flow

The Polling Page and the Meta-Refresh Trick

My team's solution involved a state machine built out of page loads. Instead of making the user wait on the initial request, I aimed to immediately accept the job and then redirect them into a holding pattern. The core architecture worked like this:

  1. Initial Request: The user submits the form. The server receives it, validates the input, creates a job record in a database table (with a status like ‘QUEUED’), starts a background worker process, and immediately returns an HTTP 302 Redirect response. Crucially, it did not do the work in-line.
  2. The Redirect: The 302 pointed the user’s browser to a new URL, something like /check-status?job_id=123. Their browser would dutifully follow this, loading a new page.
  3. The Polling Page: This page was the heart of the illusion. Its server-side logic was simple: look up job #123 in the database. If the status was still ‘PROCESSING’, it would render a simple HTML page. That page would say “Your report is being generated. Please wait…” and, of course, feature a prominent animated "loading" GIF.

The key mechanism relied on a single HTML tag in the <head> of that polling page: <meta http-equiv="refresh" content="5;url=/check-status?job_id=123">. This tag, specified in the W3C HTML 4.01 Recommendation, instructed the browser to automatically reload the exact same URL after a 5-second delay. The user saw a loading page, and every five seconds, the page would blink as it reloaded, reinforcing the idea that something was happening. While some teams used a hidden `

'; } else if (kind === 'clip' || kind === 'video') { // build the player WITHOUT the autoplay attribute, then call play() explicitly below so the // playback is tied to the click gesture -> the browser allows audio (clip = music bed; video = narration). // 'clip' loops (8s ambient); 'video' is the narrated cinema (no loop). stage.innerHTML = ''; var cv = stage.querySelector('video'); if (cv) { cv.muted = false; cv.volume = 1.0; var pr = cv.play(); if (pr && pr.catch) pr.catch(function(){ /* blocked: user can press play */ }); } } else { stage.innerHTML = ''; } } thumbs.forEach(function(t){ t.addEventListener('click', function(){ thumbs.forEach(function(x){ x.classList.remove('active'); x.setAttribute('aria-selected','false'); }); t.classList.add('active'); t.setAttribute('aria-selected','true'); render(t.getAttribute('data-kind'), t.getAttribute('data-val')); }); }); })(); // Diagrams — build a rail index from the inline .post-diagram figures, add a per-figure expand // button, and a vector pan/zoom lightbox. Pure client-side: works for every post, zero per-post work. (function(){ var figs = Array.prototype.slice.call(document.querySelectorAll('.post-diagram')); if (!figs.length) return; function esc(s){ return String(s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); } var items = []; figs.forEach(function(fig, i){ var n = i + 1; if (!fig.id) fig.id = 'fig-' + n; var cap = fig.querySelector('figcaption'), svg = fig.querySelector('svg'); var title = (cap && cap.textContent.trim()) || (svg && (svg.getAttribute('aria-label')||'').split(':')[0]) || ('Figure ' + n); items.push({ id: fig.id, n: n, title: title }); fig.classList.add('is-zoomable'); var btn = document.createElement('button'); btn.className = 'diagram-expand'; btn.type = 'button'; btn.setAttribute('aria-label', 'Expand diagram: ' + title); btn.innerHTML = ''; fig.appendChild(btn); btn.addEventListener('click', function(e){ e.stopPropagation(); openZoom(fig, title); }); fig.addEventListener('click', function(e){ if (e.target.closest('a,button')) return; openZoom(fig, title); }); }); var rail = document.getElementById('diagramRail'); if (rail) { var h = '
' + (window.__DIAGRAMS_LABEL || 'Diagrams') + '
    '; items.forEach(function(it){ h += '
  1. '+it.n+''+esc(it.title)+'
  2. '; }); rail.innerHTML = h + '
'; rail.hidden = false; rail.addEventListener('click', function(e){ var a = e.target.closest('a[data-fig]'); if (!a) return; var fig = document.getElementById(a.getAttribute('data-fig')); if (!fig) return; e.preventDefault(); fig.scrollIntoView({ behavior: 'smooth', block: 'center' }); fig.classList.remove('diagram-flash'); void fig.offsetWidth; fig.classList.add('diagram-flash'); }); } // vector pan/zoom lightbox (clones the SVG so it stays sharp at any zoom) var dlb, inner, scale=1, tx=0, ty=0, drag=false, sx=0, sy=0, pdist=0; function apply(){ if (inner) inner.style.transform = 'translate('+tx+'px,'+ty+'px) scale('+scale+')'; } function ensure(){ if (dlb) return; dlb = document.createElement('div'); dlb.className = 'diagram-lightbox'; dlb.innerHTML = ''+ '
'+ '
'+ ''+ '
'; document.body.appendChild(dlb); inner = dlb.querySelector('.dlb-inner'); var stage = dlb.querySelector('.dlb-stage'); dlb.querySelector('.dlb-close').addEventListener('click', close); dlb.addEventListener('click', function(e){ if (e.target === dlb) close(); }); dlb.querySelector('.dlb-controls').addEventListener('click', function(e){ var z = e.target.getAttribute('data-z'); if (!z) return; if (z==='in') scale=Math.min(scale*1.3,8); else if (z==='out') scale=Math.max(scale/1.3,0.4); else { scale=1; tx=0; ty=0; } apply(); }); stage.addEventListener('wheel', function(e){ e.preventDefault(); scale=Math.min(Math.max(scale*(e.deltaY<0?1.12:0.89),0.4),8); apply(); }, {passive:false}); stage.addEventListener('mousedown', function(e){ drag=true; sx=e.clientX-tx; sy=e.clientY-ty; e.preventDefault(); }); window.addEventListener('mousemove', function(e){ if (!drag) return; tx=e.clientX-sx; ty=e.clientY-sy; apply(); }); window.addEventListener('mouseup', function(){ drag=false; }); stage.addEventListener('touchstart', function(e){ if (e.touches.length===1){ drag=true; sx=e.touches[0].clientX-tx; sy=e.touches[0].clientY-ty; } }, {passive:true}); stage.addEventListener('touchmove', function(e){ if (e.touches.length===2){ var dx=e.touches[0].clientX-e.touches[1].clientX, dy=e.touches[0].clientY-e.touches[1].clientY, d=Math.sqrt(dx*dx+dy*dy); if (pdist) { scale=Math.min(Math.max(scale*(d/pdist),0.4),8); apply(); } pdist=d; } else if (e.touches.length===1 && drag){ tx=e.touches[0].clientX-sx; ty=e.touches[0].clientY-sy; apply(); } }, {passive:true}); stage.addEventListener('touchend', function(e){ if (e.touches.length<2) pdist=0; if (e.touches.length===0) drag=false; }); document.addEventListener('keydown', function(e){ if (e.key==='Escape' && dlb && dlb.classList.contains('open')) close(); }); } function openZoom(fig, title){ ensure(); var svg = fig.querySelector('svg'); if (!svg) return; var clone = svg.cloneNode(true); clone.removeAttribute('style'); clone.style.maxWidth='none'; clone.style.width='min(1400px,92vw)'; clone.style.height='auto'; inner.innerHTML=''; inner.appendChild(clone); dlb.querySelector('.dlb-cap').textContent = title || ''; scale=1; tx=0; ty=0; apply(); dlb.classList.add('open'); } function close(){ if (dlb) dlb.classList.remove('open'); } })(); // Reading progress (post pages only) var bar = document.getElementById('readingProgress'); if (bar && article) { var update = function(){ var rect = article.getBoundingClientRect(); var total = rect.height - window.innerHeight; var scrolled = -rect.top; var pct = Math.max(0, Math.min(100, (scrolled / total) * 100)); bar.style.width = pct + '%'; }; window.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); update(); } // TOC scrollspy var tocLinks = document.querySelectorAll('.toc-rail a'); if (tocLinks.length && 'IntersectionObserver' in window) { var headings = Array.from(tocLinks).map(function(a){ return document.getElementById(a.getAttribute('href').slice(1)); }).filter(Boolean); var setActive = function(id){ tocLinks.forEach(function(a){ a.classList.toggle('is-active', a.getAttribute('href') === '#' + id); }); }; var io = new IntersectionObserver(function(entries){ entries.forEach(function(e){ if (e.isIntersecting) setActive(e.target.id); }); }, { rootMargin: '-80px 0px -70% 0px', threshold: 0 }); headings.forEach(function(h){ io.observe(h); }); } })();