Before this lesson

Complete Fetch and promises. Understand debouncing and loading states.

What you will be able to do

Protect the UI with request identity and distinguish loading, success, empty, failure and cancellation.

Understand the mechanism

An earlier query can finish after a later query and overwrite the desired result. Debounce reduces request frequency but does not fix already-started work finishing out of order. Define ownership of state updates first.

Assign an increasing ID and allow updates only from the latest request. Apply that rule to success, catch and finally; an old finally can clear a newer loading state even when stale data is ignored.

Cancellation saves resources where supported but cannot cancel every asynchronous operation or roll back server work. Request identity protects consistency, while cancellation manages resources. Treat intentional abortion differently from genuine failure.

The example uses controlled delays to reproduce ordering without external services. A real fetch adds HTTP, parsing and schema checks. Deliberately slowing the old request provides stronger evidence than repeatedly clicking under normal network conditions.

Runnable example

Run HTML examples as separate .html files in a modern browser. Prepare resource files and multi-file modules as described in the lesson.

<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Honeysuck practice</title></head><body>
<button id="old">Slow HTML request</button><button id="new">Fast CSS request</button><button id="fail">Failing request</button><p id="status" role="status">Ready</p><pre id="result"></pre>
<script>
let latest=0;
const status=document.querySelector('#status'),result=document.querySelector('#result');
function service(label,delay,fail=false){return new Promise((resolve,reject)=>setTimeout(()=>fail?reject(new Error('Demo failure')):resolve([label]),delay));}
async function search(label,delay,fail=false){const id=++latest;status.textContent='Loading '+label;result.textContent='';
 try{const items=await service(label,delay,fail);if(id!==latest)return;result.textContent=items.join(', ');status.textContent=items.length?'Loaded':'No matches';}
 catch(error){if(id!==latest)return;status.textContent='Could not load. Try again.';}
 finally{if(id===latest)document.body.dataset.busy='false';}
}
for(const [selector,label,delay,fail] of [['#old','HTML',700,false],['#new','CSS',100,false],['#fail','Failure',150,true]])document.querySelector(selector).addEventListener('click',()=>{document.body.dataset.busy='true';search(label,delay,fail);});
</script>
</body></html>

Trace and verify

Click Slow then Fast; CSS remains after the slow delay expires. An old failing request cannot overwrite a newer success. A standalone failure displays an error and a subsequent success recovers. Remove guards temporarily to prove the test reproduces the defect, then restore them.

Common mistakes and boundaries

The busy data attribute is only demo state; apply appropriate aria-busy and text feedback in a real interface. Never declare success unconditionally in finally.

Practice challenge

Add an empty-result request and synchronize aria-busy with the current request only.

Reference solution and reasoning

Set busy at start and clear only for the latest completion. Test a slow old query followed by an empty new query; the empty state must persist.

Further reading