A cleanup pass through a delete-confirmation modal in a Rails + React app surfaced a leftover $.getJSON from the jQuery era. The component polls a background job until it completes, then redirects. Small function, but the conversion to fetch + async/await is a good lens on a few things worth saying out loud.
The original
const checkForCompletion = (jobId: number, cb: () => void) => {
$.getJSON(`/jobs/${jobId}`)
.done((data) => {
setStatus(data.status)
if (data.completed_at) {
cb()
} else {
setTimeout(checkForCompletion, 2000, jobId, cb)
}
})
.fail((_jqxhr) => cb())
}
$.getJSON returns a jqXHR (jQueryâs Promise-shaped object). .done runs on HTTP success, .fail on anything thatâs not. Either way, a callback fires.
The fetch version
const checkForCompletion = async (jobId: number, cb: () => void) => {
try {
const response = await fetch(`/jobs/${jobId}`, {
headers: { Accept: "application/json" },
})
if (!response.ok) {
cb()
return
}
const data = await response.json()
setStatus(data.status)
if (data.completed_at) {
cb()
} else {
setTimeout(checkForCompletion, 2000, jobId, cb)
}
} catch {
cb()
}
}
Three things to notice â none surprising on their own, but worth saying out loud.
Gotcha 1: fetch only rejects on network failure
$.getJSONâs .fail fires for any non-2xx response and network errors. fetch only rejects the promise for network-level failure (DNS, offline, CORS preflight). A 500 from the server resolves the promise normally, with response.ok === false.
So a try/catch alone isnât enough. Both checks are needed:
if (!response.ok) cb() // app-level failure (4xx, 5xx)
} catch { cb() } // transport-level failure (offline, DNS)
Skip the response.ok check and a 500 will silently parse a JSON error body and break further down.
// pick a scenario and click runGotcha 2: JSON parsing is manual
$.getJSON auto-parses the body. fetch doesnât â response.json() is its own awaited call, and it can throw if the body isnât valid JSON. Thatâs a third failure path the catch already covers, but worth knowing it exists.
Adding Accept: application/json makes Railsâ respond_to block pick the JSON branch unambiguously, regardless of routing or default-format quirks.
Gotcha 3: recursive setTimeout still works fine
Async functions can call themselves through setTimeout. The next call kicks off a new promise; the original one has already resolved by the time setTimeout fires. No leaks, no chained-promise weirdness. The polling pattern survives the rewrite untouched.
- services: processing...
- people: processing...
- check-ins: processing...
- giving: processing...
The demo above runs the same shape â poll, update status, recurse via setTimeout â against a mock endpoint. The status ticks from processing to complete.
What about CSRF?
Worth checking mid-conversion. No â CSRF tokens are only required for state-changing methods (POST/PUT/PATCH/DELETE). Rails skips the check on GET. The $.ajax DELETE in the same file still needs X-CSRF-Token; the GET poll does not.
What about Stimulus or Turbo?
Also no. Stimulus and Turbo shine when the view is server-rendered ERB with light JS sprinkled on. This component is React â the polling state drives useState-backed UI. Turbo Streams could replace polling with a server-pushed update over ActionCable, but thatâs a bigger refactor and still wouldnât change the in-component request layer.
Takeaway: fetch looks like a one-for-one swap, but response.ok is the line that prevents silent application errors. jQuery hid that distinction inside .fail. Vanilla forces it back into the open.