#Observables
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…
Aug 1, 2024
Recursion in RxJS

Recursion in RxJS

August 1, 2024

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 ?? []),
);