Guide
Polling
This page shows how to ask an endpoint again on an interval: poll keeps data fresh, such as a count of unread notifications, and pollUntil waits until something is done, such as an export the server is building. Both take any endpoint, from createApi or createGraphQL.
Keeping data fresh with poll
import { createApi, defineRequest, poll } from 'liaise'
const getUnread = defineRequest<{ unread: number }>()({ method: 'GET', path: '/notifications/count' })
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUnread } })
// Ask now, then 5 seconds after each answer, until you call stop().
const stop = poll(api.getUnread, {}, ({ data, error }) => {
if (error) showOffline()
else setCount(data.unread)
}, { every: 5000 })
setCount and showOffline stand for your own code. poll sends GET /notifications/count at once and again 5 seconds after each answer, and hands each Result to the callback, until you call stop().
- The wait starts after each answer. A slow answer delays the next request instead of overlapping it, so a poll never has two requests in flight.
- The callback gets every
Result, errors included. Polling goes on after a failure, with longer waits (When a poll fails). stop()ends it, and so does aborting thesignalyou pass in the options. A request still in flight is cancelled, and its answer is never delivered, unless anotherpollorpollUntilis asking the same thing (Several callers, one poll). With a signal that is already aborted,pollsends nothing and never calls the callback.- Per-request options go with every request. Any of the
CallOptions, such astimeout,headersormiddleware, applies to each request, as it does withpaginate.signalis the exception: it stops thispoll, not one request. - An
everybelow 1, or one that isn’t finite, such as0orInfinity, asks once and never repeats. The callback gets oneResult, and then that caller leaves on its own, even when it shares a poll that repeats.
Waiting until something is done with pollUntil
import { createApi, defineRequest, pollUntil } from 'liaise'
type Export = { id: string; status: 'queued' | 'running' | 'done'; url?: string }
const getExport = defineRequest<Export>()({ method: 'GET', path: '/exports/:id' })
const api = createApi({ baseUrl: 'https://api.example.com', requests: { getExport } })
async function waitForExport(id: string) {
// Ask every 2 seconds until the export is done; give up after a minute.
const { data, error } = await pollUntil(api.getExport, { id }, {
every: 2000,
until: r => r.data.status === 'done',
giveUpAfter: 60_000,
})
if (error) return showError(error.kind)
download(data.url!)
}
download and showError stand for your own code. waitForExport('e1') asks for /exports/e1 every 2 seconds. When an answer has status: 'done', pollUntil resolves with that Result and the polling stops. until is only called with successful results, so r.data is always there.
pollUntil never rejects. It resolves once, with a Result, in one of these ways:
| It resolves with | When |
|---|---|
the success Result | until returns true for it. |
that error Result | The request fails in a way waiting can’t fix: an HTTP 4xx other than 408 and 429, a GraphQL error response (kind: 'http' with a 2xx status), kind: 'parse', kind: 'middleware', or kind: 'abort' from the request itself, such as the endpoint’s own dedupe cancelling it. |
an error with kind: 'timeout' | giveUpAfter milliseconds pass first. |
an error with kind: 'abort' | The signal in its options aborts. |
the first Result it gets | Its every is below 1 or isn’t finite, so it asks once, and there is no second answer to wait for. |
Every other failure keeps it polling, with the longer waits described under When a poll fails: a network error, a single request’s own timeout, a 5xx, a 408, a 429, and any other status, such as a 304.
In the example, a 404 for an export that doesn’t exist ends the wait at once with kind: 'http', and an export still queued after a minute ends it with kind: 'timeout'.
- The
timeoutfromgiveUpAfterand theabortfrom itssignalare built bypollUntil. Theirdataandresponsearenull,error.requestnames the most recent request that was sent, andretry()runs the wholepollUntilagain. What pollUntil resolves with has the details. - If
untilthrows, that answer counts as not done, and the error is reported the way a callback’s is (When a poll fails).
Several callers, one poll
Each poll() or pollUntil() call is a caller. Callers that ask the same thing share one poll: one request per interval, with every Result handed to each of them. Five components that show the same count send one request every 5 seconds, not five.
Polls are shared in browsers and React Native. On a server (no window) each call gets its own poll, because sharing is decided before your middleware runs and a server handles many users. A middleware that adds the current user’s token would otherwise hand one user’s answers to another. The check is typeof window. A server that defines a global window, such as Deno 1.x or a global jsdom, shares like a browser. There, pass the user’s token in the poll’s headers option, which is part of what decides sharing.
Callers ask the same thing when they use:
- The same endpoint, on the same client. Two clients never share a poll, even with the same
baseUrl. Copies fromwithHeaders()that add the same headers count as the same client, except that a copy made with{ dedupe: false }polls separately from one without it. - The same params, compared by value.
{ id: '7' }and another{ id: '7' }share. - The same per-request options, compared by value, such as equal
headersobjects or the sametimeout. Per-requestmiddleware,skipMiddlewareor aHeadersinstance can’t be compared, so a caller that passes one gets a shared poll of its own.
every, inBackground, maxEvery, signal, until and giveUpAfter aren’t compared, so callers with different intervals still share. Inside one shared poll:
- The shortest
everywins. It is read again before each wait, so when the caller with the shortest one leaves, the poll goes back to the next shortest once the wait already started has passed. A caller that joins with a shortereverydoesn’t wait out the longer wait already started: the next request goes itseveryafter the last answer. A longer wait after a failure stays as it is. - A caller that joins late gets the last answer without a new request. It arrives in a microtask, after
poll()has returned, never inside the call. If the first answer hasn’t come yet, the new caller waits for it like the others. ApollUntilwhoseuntilholds for that last answer resolves without sending anything. - One caller leaving doesn’t end the others.
stop(), an abortedsignalor apollUntilthat resolves removes only that caller. The shared poll stops when its last caller leaves, and a request in flight at that moment is cancelled, unlesssharehas joined another call to it. - A plain-object
paramsis copied at the call, so changing its top-level fields afterwards changes nothing. Nested values aren’t copied, so don’t change params after the call, nested objects included. Any other params value is sent as it is, without a copy.URLSearchParams,FormData, aBlobor anArrayBuffercan’t be compared, so each caller that passes one gets a shared poll of its own.
dedupe works per endpoint, not per params. Two polls of a dedupe: true endpoint with different params, such as two job ids, cancel each other’s requests, and a pollUntil among them can end with kind: 'abort'.
Polls and share
share: true on the endpoint goes further than polls being shared. Polls are matched before your middleware runs. share compares what is about to be sent, after it. So with share, polls that aren’t shared still send one request when they ask the same thing at the same moment, and each poll gets its own Result:
- On a server, where each
poll()call is a poll of its own,shareis the safe way to send fewer requests. Polls that send different users’ tokens never share. - Polls that a per-request option keeps apart share one request when the option isn’t sent, such as a different
timeout. - A direct call made while a poll’s request is in flight joins that request.
- Such polls stay in step while every answer succeeds. After a failure, each one waits its own randomized backoff, so they usually drift apart. They share again only if one’s next request starts while the other’s is still in flight.
- A poll that stops cancels its request only when no other call shares it.
Polls that are already shared don’t need share: they send one request per interval anyway.
When a poll fails
A failed request doesn’t stop poll. The callback gets the error Result, and the next request waits longer:
- The wait grows with each failure in a row. After the nth failure in a row, it is a random time between
everyandevery × 2ⁿ. It is never shorter thanevery, and the randomness keeps many clients from asking again at the same moment. maxEverycaps it. The default ismax(every, 60000): one minute, oreveryif that is longer.- A success resets it to
every. - A
Retry-Afterheader on a 429 or a 503 makes the wait at least that long, still capped bymaxEvery.
pollUntil waits the same way through the failures it keeps polling through.
A callback that throws doesn’t stop the poll, and the other callers’ callbacks still run. In a browser the error goes to reportError, which reports it like an uncaught error. Everywhere else, such as on a server or in React Native, it goes to console.error, with a message that says what failed. A throw from until is reported the same way. poll gets only the endpoint function, not the client, so these errors can’t reach the client’s onError.
Hidden tabs
In a browser, polling pauses while the tab is hidden, so a page in the background doesn’t keep asking:
- No request starts while the tab is hidden. A poll started in a hidden tab sends its first request when the tab is visible.
- When the tab is visible again, it asks at once, then goes back to its interval. If the last answer was a 429 or a 503 whose
Retry-Afterhasn’t passed yet, it waits until it has instead. inBackground: truekeeps it polling. One caller that sets it keeps the shared poll going while the tab is hidden. When the last such caller leaves, the poll pauses again.pollUntil’sgiveUpAfterkeeps counting while the poll is paused.- On a server or in React Native, it never pauses. There is no
documentthere, so there is no hidden tab. On a server, polls aren’t shared either (Several callers, one poll).
With React
Call poll in useEffect, and return its stop as the effect’s cleanup:
import { useEffect, useState } from 'react'
import { poll } from 'liaise'
function UnreadBadge() {
const [count, setCount] = useState(0)
useEffect(() => {
const stop = poll(api.getUnread, {}, ({ data, error }) => {
if (!error) setCount(data.unread)
}, { every: 5000 })
return stop // runs when the component unmounts
}, [])
return <span>{count}</span>
}
api is the client from the first example. Every badge on the page shares one poll, and the last one to unmount stops it. In development, Strict Mode runs the effect, its cleanup and the effect again, and that leaves one poll running, with one delivery per answer.
Next: GraphQL covers the second client, for GraphQL backends. poll and pollUntil take its operations too.