Recursion in RxJS
The requirement: after a user selects or photographs an image, send it to an image-recognition API. The API rejects images over a certain size and returns empty results — a poor user experience.
Compressing on the client (mobile) was ruled out: bad UX and a performance hit on the device. Server-side compression on upload slowed the API response to over 5 seconds per request. The project already used OSS, and the OSS provider supported on-the-fly image processing (compression, format conversion). The problem: you can't know the resulting file size in advance, so you can't determine the right compression quality up front.
The approach: start with a default quality (e.g. 80%), issue a HEAD request for the processed URL, check Content-Length, and if it's still over the limit, reduce the quality and repeat — until the size is acceptable.
The obvious implementation is async/await with a loop or recursive function. But RxJS offers an elegant alternative: the expand operator, which recursively projects each value into a new Observable.
From the docs:
Recursively projects each source value to an Observable which is merged in the output Observable.
Here's the RxJS implementation:
const fetch$ = from(selectPhoto()).pipe(
map(({ uri, size }) => ({
uri,
size,
quality: DEFAULT_QUALITY,
})),
switchMap(({ size, quality, uri }) => {
// If the image is already under the limit, return immediately
if (size < MAX_FILE_SIZE) {
return of({ size, quality, uri });
}
return of({ size, quality, uri }).pipe(
expand(({ size, quality, uri }) =>
from(
fetch(`${uri}?x-oss-process=image/quality,Q_${quality}`, {
method: "HEAD",
}),
).pipe(
// Adjust quality and recurse
map((result) => ({
size: Number(res.headers.get("content-length")),
quality: quality - DELTA,
uri,
})),
),
),
takeWhile(({ size }) => size > MAX_FILE_SIZE),
// Grab the last value that was still over the limit
takeLast(1),
map(({ quality, ...rest }) => ({
quality: quality - DELTA,
...rest,
})),
);
}),
// Call the business logic with the final quality
switchMap(({ quality, uri }) =>
from(
request.post("/image/search", {
image: `${uri}?x-oss-process=image/quality,Q_${quality}`,
}),
),
),
catchError(() => of({ list: [] })),
map((res) => res?.list ?? []),
);