Skip to main content

react-query stops at the browser. alova/server handles your BFF.

· 4 min read
Alova Team

You built the frontend on react-query. Its cache and retry keep the browser happy. Then you stood up a Node BFF that calls three downstream services, and the retries and throttling for those calls live on the server. react-query never runs there. This post shows the exact line where react-query's job ends and where alova/server picks up, with the code for each side.

The line

react-query lives in the browser. Its useQuery retry, its cache, its dedup, all happen per browser tab, bound to React's render cycle. None of that executes inside your BFF when your BFF calls a downstream. So the question isn't "react-query or alova". It's "who handles the server-side calls".

Retry: browser vs BFF

react-query retries a failed query in the browser:

useQuery({
queryKey: ['resource', id],
queryFn: () => fetch(`/api/resource/${id}`).then(r => r.json()),
retry: 3,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000)
});

That retry protects the user's view. It does nothing for the BFF's outbound call to the downstream, because that call doesn't go through react-query.

On the BFF, alova/server retries the outbound request with backoff:

const { createAlova } = require('alova');
const { axiosRequestAdapter } = require('@alova/adapter-axios');
const { retry } = require('alova/server');

const alovaInst = createAlova({
baseURL: 'https://downstream.internal',
requestAdapter: axiosRequestAdapter()
});

const getResource = (id) =>
retry(alovaInst.Get(`/resource/${id}`), {
retry: 5,
backoff: { delay: 1000, multiplier: 2 }
});

// inside your route handler:
const data = await getResource(req.params.id);

Same idea, different layer. react-query covers the browser; retry covers the server.

Rate limiting: only on the server

react-query has no concept of throttling your BFF's calls to a downstream. There's nothing in its API for "max 4 requests per 4 seconds to service X". That is a server-side concern, and alova/server's createRateLimiter is built for it:

const { createRateLimiter } = require('alova/server');

const rateLimit = createRateLimiter({ duration: 4000, points: 4 });

app.get('/api/resource/:id', async (req, res) => {
try {
const data = await rateLimit(
retry(alovaInst.Get(`/resource/${req.params.id}`), {
retry: 5,
backoff: { delay: 1000, multiplier: 2 }
}),
{ key: `downstream:${req.params.id}` }
);
res.json(data);
} catch (err) {
res.status(429).json({ error: 'downstream busy, retry later' });
}
});

rateLimit consumes a point before the call leaves, so a struggling downstream isn't buried by your own traffic. The default store is the method's l2Cache; across instances you swap in a shared one (@alova/psc or redis) so the limit is real cluster-wide.

Cache: know which side it lives on

This is the part people mix up. react-query's cache is per browser. It keeps a user from refetching the same resource in the same tab. It does nothing for your BFF hammering a slow downstream, because that call isn't in the browser.

alova's caching is a client-side strategy feature too. cacheFor controls how long a method response is kept, with modes memory and restore, and non-GET requests default to null (no cache). On the server, alova/server gives you retry and createRateLimiter; it does not hand you a server-side response cache for downstream calls. If you want to skip a slow downstream for repeated GETs, you add your own cache (or lean on the downstream's own), and layer retry/rateLimit on top.

So the split is clean: react-query owns the browser cache, alova/server owns the BFF's outbound retry and throttle. Neither steps on the other.

The two tools solve different layers. Use react-query where the request ends in the browser, and alova/server for the BFF. For the full选型 view, see the react-query vs alova comparison.

Your BFF got rate-limited. One retry layer is enough.

· 5 min read
Alova Team

You run a Node BFF. It sits between your frontend and three downstream services. One afternoon the biggest downstream starts returning 429s, and your BFF answers every one by firing the same request again, immediately. Within a minute the downstream is buried under retries for requests it already rejected, and your frontend sees a wall of 500s. Sound familiar?

Here is the kind of handler that gets you there:

app.get('/api/resource/:id', async (req, res) => {
try {
const data = await fetch(`https://downstream.internal/resource/${req.params.id}`);
res.json(await data.json());
} catch (err) {
// naive retry: fire again, right now
const retry = await fetch(`https://downstream.internal/resource/${req.params.id}`);
res.json(await retry.json());
}
});

This looks harmless until it isn't. Two problems hide in it:

  1. The retry ignores why it failed. A 429 means "slow down". Retrying instantly is the opposite of slowing down. You amplify the very condition that broke things.
  2. No client-side throttle. Your BFF happily forwards every incoming request to a downstream that is already struggling. Nothing caps how hard you push it.

If you run more than one BFF instance, a hand-rolled counter (a module-level variable, an in-memory Map) doesn't help either. Each process keeps its own tally, so your "4 requests per second" limit becomes 4 per instance, per second. On a three-node cluster that is 12.

What a BFF actually needs here

Step back from any library. A downstream-call layer that survives rate limiting needs:

  • Backoff, not an immediate repeat. Wait, then wait longer. Exponential backoff with jitter keeps every instance from retrying in lockstep.
  • Retry capped and conditional. Stop after a few tries, and only for failures worth retrying (timeouts, 5xx, 429), not for 4xx that will never succeed.
  • A client-side rate limit. Before you even call the downstream, check a shared budget so you don't pile on.
  • A counter that survives the cluster. The limit has to live somewhere all instances agree on, not in one process's memory.

That is a fair bit of plumbing if you write it by hand. alova's server side ships two hooks that cover the first three directly, and the fourth through a pluggable store.

retry + createRateLimiter, one middleware

alova is a request strategy layer. On the server, alova/server gives you hooks that wrap a request method. retry adds backoff, createRateLimiter adds a client-side throttle. Both wrap the same method instance you already use to call the downstream.

Set up an alova instance. On the server I'll use the axios adapter, so the request still goes out through axios. alova only decides how the request runs.

const { createAlova } = require('alova');
const { axiosRequestAdapter } = require('@alova/adapter-axios');
const { retry, createRateLimiter } = require('alova/server');

const alovaInst = createAlova({
baseURL: 'https://downstream.internal',
requestAdapter: axiosRequestAdapter()
});

// 4 points per 4-second window, tracked through the method's cache store
const rateLimit = createRateLimiter({ duration: 4000, points: 4 });

Now wrap the downstream call. retry goes inside, rateLimit outside:

app.get('/api/resource/:id', async (req, res) => {
try {
const data = await rateLimit(
retry(alovaInst.Get(`/resource/${req.params.id}`), {
retry: 5,
backoff: { delay: 1000, multiplier: 2 }
}),
{ key: `downstream:${req.params.id}` }
);
res.json(data);
} catch (err) {
// rate limit hit, or retries exhausted
res.status(429).json({ error: 'downstream busy, retry later' });
}
});

What changed versus the naive version:

  • retry: 5 caps the attempts. backoff.delay: 1000 with multiplier: 2 spaces them at 1s, 2s, 4s, 8s, so a struggling downstream gets breathing room instead of a second hit.
  • rateLimit consumes one point per BFF request before it ever reaches the downstream. Over the window, only 4 get through. The rest throw and you return 429 early, protecting the downstream from your own traffic.
  • The key scopes the budget per downstream resource, so one hot key can't starve the others.

The retry defaults matter here too. With no options it retries 3 times, 1 second apart. We only override because this downstream is flaky enough to deserve more.

The cluster part

The default rate-limit store is the method's l2Cache. In a single process that is memory, which is fine. Across instances you point store at something shared, @alova/psc or a redis adapter, and all nodes draw from one budget. That is the piece a hand-written in-memory counter can never give you without building it yourself.

What you get

Not a benchmark, just the behavior the config produces:

SituationNaive handlerWith retry + rateLimit
Downstream returns 429Instant retry, more loadWait and back off, stop after 5 tries
Three BFF nodes, limit 4/s12/s actually sent4/s shared across nodes (with shared store)
Same request fires twiceTwo live callsSecond call shares the first in flight

The last row is request sharing: alova dedupes identical concurrent calls, so two users asking for the same resource don't trigger two downstream hits.

If your BFF calls a downstream that rate-limits and you run more than one instance, retry with createRateLimiter is worth an afternoon.

react-query or alova: does the request leave the browser?

· 3 min read
Alova Team

Choosing a data-fetching library usually starts with "which cache is better". That question skips the part that actually bites later: where do your requests run?

If every call lives in the browser and you're on React, react-query is a mature, well-trodden pick. But the moment you need the same request logic on a phone that isn't React Native, or inside a BFF that calls downstream services, the map changes. alova puts those cases on the same API. This post is a straight comparison, including where alova is the weaker choice.

Where react-query is clearly ahead

Be honest first:

  • React fit. react-query is built around React's render model. Hooks like useQuery slot into components naturally, and its devtools and query window are the most polished in the space.
  • Caching maturity. Its cache, dedup, background refetch, and stale-while-revalidate behavior are battle-tested across a huge number of production apps.
  • Community and answers. Years of Stack Overflow threads, blog posts, and copied snippets mean most problems already have a solution online.

If your app is React, in the browser, and you're happy with that world, react-query is the safer default. Saying otherwise would be dishonest.

Where alova goes further

alova is a request strategy layer too, but it isn't tied to one framework or one runtime:

  • One API across runtimes. The same useRequest and usePagination calls run on the web, in mobile apps (React Native / Expo), and on the server (Node BFF). You write the request logic once.
  • Server-side hooks. alova/server adds retry and createRateLimiter for your BFF's outbound calls, the layer react-query doesn't touch because it lives in the browser.
  • Framework choice. React, Vue, Svelte, and Solid all get first-class hooks, so a mixed-stack team isn't forced into React.
  • Sits on your existing adapter. alova doesn't send requests itself. It runs on top of a fetch or axios adapter, so axios stays in the picture if you already use it.

Side by side

Concernreact-queryalova
Primary runtimeBrowser (React)Web, mobile, server (BFF)
FrameworkReact-firstReact / Vue / Svelte / Solid
Client cacheExcellent, matureGood, strategy hooks
Server-side retry / rate limitNot its jobalova/server hooks
Cross-platform appsReact Native onlyReact Native, Expo, and more
Sends HTTP itselfNo (fetch/axios under)No (fetch/axios adapter)
Community sizeVery largeSmaller

On the last row: neither library replaces axios. Both sit above a request adapter, so if your team already depends on axios, it keeps doing the sending.

When react-query is the better call

  • Your frontend is React and browser-only, and you want the deepest React caching ecosystem available.
  • You don't run a BFF that needs outbound retry/rate-limit logic.
  • Your team already knows react-query and ships fast with it.

When alova fits better

  • You maintain the same request logic across web, a non-React-Native app, and a Node BFF.

  • Your BFF needs retry with backoff and a client-side rate limit for downstream calls (see the BFF retry post).

  • You're on Vue, Svelte, or Solid, or a mixed stack, and want one strategy layer.

  • alova vs other libraries

  • Server retry strategy

axios and alova, who handles what

· 3 min read
Alova Team

Short answer: they are layers, not rivals. axios is an HTTP client — it sends requests. alova is a request strategy layer — it decides how requests run: pagination, caching, retry, deduplication, token refresh. alova can use axios as its request adapter (@alova/adapter-axios), so choosing alova never means throwing axios away.

alova in one line: one set of request APIs that runs on the web, in apps (uni-app / Taro / mini-programs), and on the server (BFF).

Division of work

Concernaxiosalova
Sending HTTP requests✅ its jobDelegates to an adapter (fetch / XHR / axios / uni-app / Taro)
InterceptorsbeforeRequest / responded (your axios interceptors keep working)
Loading / error / data statesHand-written✅ auto-managed by useRequest and friends
Pagination, form, upload, SSE flowsHand-written✅ 20+ ready-made strategy hooks
Response cachingHand-written✅ multi-level cache (L1/L2) with declarative invalidation
Deduplicating identical concurrent requestsHand-written✅ built-in request sharing
Cross-platform (uni-app / Taro / mini-programs)Community wrappers✅ official adapters, same API everywhere
Server-side retry / rate limitingHand-writtenalova/server hooks

Where axios is genuinely stronger

An honest comparison cuts both ways:

  • Ecosystem and familiarity. axios has one of the largest communities in the JavaScript world. Nearly every edge case has a Stack Overflow answer; nearly every developer has used it. alova's community is far smaller.
  • Zero learning curve for your team. Everyone already knows axios.get(). alova introduces a Method abstraction and strategy hooks — a real (if small) mental shift.
  • If you only send a handful of requests, axios alone is simply enough. A strategy layer earns its keep only when you keep rewriting the same request logic.

What using both looks like

Keep your axios instance — including its interceptors and baseURL — and let alova drive it:

import axios from 'axios';
import { createAlova } from 'alova';
import { axiosRequestAdapter } from '@alova/adapter-axios';
import VueHook from 'alova/vue';

// your existing axios instance, untouched
const customAxios = axios.create({ baseURL: '/api', timeout: 10000 });

const alovaInst = createAlova({
statesHook: VueHook,
requestAdapter: axiosRequestAdapter({ axios: customAxios })
});

Before / after for a plain request in Vue 3:

// axios only: hand-written states
const loading = ref(false);
const data = ref({});
const error = ref(null);
const load = async () => {
try {
loading.value = true;
data.value = await customAxios.get('/todos');
} catch (e) {
error.value = e;
}
loading.value = false;
};
onMounted(load);

// axios + alova: states managed for you
const { loading, data, error } = useRequest(alovaInst.Get('/todos'));

Everything you configured for axios still applies: method config accepts all axios request options, and interceptor order is predictable — alova's beforeRequest fires before axios request interceptors, and alova's responded fires after axios response interceptors.

When you don't need alova

  • Your project fires only a few requests with no repeated pagination/caching/retry patterns — plain axios is the right call.
  • You are React-only, web-only, and already invested in React Query — switching buys you little unless you need cross-platform or server-side strategies.
  • You want a zero-abstraction stack — alova's Method + hooks model is one more concept, and that cost is real.

Next steps

uni-app pagination with one hook: race conditions, duplicate requests, loading state

· 5 min read
Alova Team

You've probably written pagination like this in a uni-app project — bump the page inside onReachBottom, fire a request, concatenate the array:

<script setup>
import { ref } from 'vue';
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';

const list = ref([]);
const page = ref(1);
const loading = ref(false);

const loadList = async () => {
loading.value = true;
const res = await new Promise(resolve => {
uni.request({
url: `https://api.example.com/goods?page=${page.value}&pageSize=10`,
success: resolve
});
});
list.value = [...list.value, ...res.data.list];
loading.value = false;
};

onReachBottom(() => {
page.value++;
loadList();
});

onPullDownRefresh(async () => {
page.value = 1;
list.value = [];
await loadList();
uni.stopPullDownRefresh();
});

loadList();
</script>

It runs, but it hides at least three problems you've probably hit in production:

  1. Race conditions: a user scrolls fast and triggers onReachBottom twice — pages 2 and 3 are both in flight. Whichever returns first gets concatenated first; on a weak network page 3 can easily arrive first, scrambling the list order.
  2. Duplicate requests: on some platforms onReachBottom fires several times in a row with no dedupe, so the same page is requested and appended twice.
  3. Missing edge cases: there's no "is this the last page" check, so it keeps firing empty requests at the bottom; pull-to-refresh and load-more share one loading flag, so the list flashes empty then slowly refills on refresh.

Fixing all three means adding: a dedupe flag, dropping stale responses by page number, an isLastPage calculation, and two separate states for refresh vs. append... Hand-written, that's usually dozens of lines of boilerplate unrelated to your list, and you repeat it on every list page.

What an ideal pagination solution needs

Forget any library for a moment. A "safe" pagination implementation needs at least:

  • Request-level dedupe: don't resend an in-flight request with the same params;
  • Responses land by page: a late response for an older page must not overwrite a newer one;
  • Append / refresh modes: load-more appends, pull-to-refresh resets;
  • Edge state: isLastPage, total, loading / preloading should be directly available;
  • Cross-platform: the same code must run in mini-programs, H5, and App.

The last point is the special constraint of uni-app — React Query and SWR target the browser's fetch by default and have no official adapter for the mini-program environment (uni.request). That's also why many uni-app projects end up back at hand-written pagination.

Using usePagination

alova is a request strategy library: one set of request APIs that runs on the web, in apps (uni-app / Taro / mini-programs), and on the server (BFF). Through the @alova/adapter-uniapp adapter it uses uni.request directly, and the usePagination strategy hook builds in the whole checklist above.

Install (note: the uni-app adapter currently supports Vue 3 uni-app only):

npm install alova @alova/adapter-uniapp @alova/shared --save

Create the alova instance; the adapter provides request adapting, storage adapting, and VueHook in one call:

// api/index.js
import { createAlova } from 'alova';
import AdapterUniapp from '@alova/adapter-uniapp';

export const alovaInst = createAlova({
baseURL: 'https://api.example.com',
...AdapterUniapp(),
responded(response) {
const { statusCode, data } = response;
if (statusCode >= 400) {
throw new Error('request error');
}
return data || null;
}
});

The full list page — the page management, race protection, edge checks, and dual loading you wrote by hand are now all returned by the hook:

<template>
<view v-for="item in data" :key="item.id" class="goods-item">
{{ item.name }}
</view>
<view v-if="loading">Loading...</view>
<view v-if="isLastPage">No more</view>
</template>

<script setup>
import { usePagination } from 'alova/client';
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';
import { alovaInst } from '@/api';

const queryGoods = (page, pageSize) =>
alovaInst.Get('/goods', {
params: { page, pageSize }
});

const { loading, data, page, isLastPage, total, reload } = usePagination(
(page, pageSize) => queryGoods(page, pageSize),
{
append: true, // append mode: next page auto-concatenated to the bottom
initialPageSize: 10,
data: response => response.list,
total: response => response.total
}
);

// load more: just bump the page; dedupe, race handling, last-page check are inside the hook
onReachBottom(() => {
if (!isLastPage.value) {
page.value++;
}
});

// pull to refresh: reload resets to page one
onPullDownRefresh(async () => {
await reload();
uni.stopPullDownRefresh();
});
</script>

Compare: your business code is down to two actions — "page + 1" and "reload". The protection logic behind those three pitfalls (shared-request dedupe, response placement, isLastPage) needs no maintenance from you. usePagination also preloads the next page by default, so the next page is often already cached when you scroll.

The same code compiles and runs on WeChat mini-program, H5, and App — uni.request is always the one sending requests, and alova only handles the "how". If your project already uses axios (H5 side), it can keep working as alova's request adapter through @alova/adapter-axios; your interceptors stay untouched.

Live examples (pagination, load-more, and 24+ runnable demos): alova.js.org/examples

When you don't need it

Honestly, in these cases hand-writing is enough and adding any request library is overhead:

  • A single-page list, or fixed-size data: one uni.request plus one ref is the optimal solution.
  • H5-only project already deep into React Query / SWR: they're mature in pure browser environments; no reason to switch for a single-platform project.
  • uni-app Vue 2 project: @alova/adapter-uniapp supports Vue 3 only; evaluate before adopting on Vue 2.
  • A stable, battle-tested pagination wrapper already in place: working old code beats a new dependency; wait for a refactor window.

On the flip side, if your project has multiple paginated / load-more lists, has hit race-condition or duplicate-request issues in production, or maintains mini-program + H5 + App at once, usePagination is worth one try:

npm i alova