How to Create a Download File API in Spring Boot Using WebClient

Spring Boot simplifies backend development. In this tutorial, you will learn how to build a download file API and call it using both non-temporary and temporary file handling methods with WebClient. You will also understand how to use proper exception handling, optimize performance, and test the solution effectively.

Introduction to File Download APIs in Spring Boot

Modern applications often require the ability to download files. Users may need reports, invoices, or media files to be dynamically generated or fetched from another service. Spring Boot provides excellent tools to implement such functionality easily.

download file API allows a client to request and receive files over HTTP. You can use this for static files, dynamically generated files, or files retrieved from other microservices.

We’ll use Spring WebFlux’s WebClient for making HTTP calls. It’s non-blocking, efficient, and ideal for scalable applications.


Setting Up Your Spring Boot Project


Creating the Download API Controller

Now, create an endpoint that allows clients to download files from the server.

Step 1: Add a DownloadController class.

package com.example.downloadapi.controller;

import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

@RestController
public class DownloadController {

    private static final String FILE_DIRECTORY = "/tmp/files/";

    @GetMapping("/download/{filename}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String filename) throws IOException {
        File file = new File(FILE_DIRECTORY + filename);
        if (!file.exists()) {
            return ResponseEntity.notFound().build();
        }

        InputStreamResource resource = new InputStreamResource(new FileInputStream(file));

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"")
                .contentLength(file.length())
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(resource);
    }
}

Explanation:

  • The API endpoint /download/{filename} receives a filename.
  • The file is retrieved from a directory.
  • It returns the file as a stream, allowing large files to be downloaded efficiently.

This design prevents memory overload because it doesn’t load the whole file into memory at once.


Using WebClient for File Download

Spring WebClient is part of Spring WebFlux. It replaces the legacy RestTemplate and supports reactive, asynchronous calls.

You can use WebClient to download files either as temporary files or non-temporary (persistent) files.

Maven Dependency

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Let’s explore both.


Method 1: Download File Without Temporary Storage

Sometimes, you want to process a file directly in memory, for example, to stream it to another service.

Step 1: Create a WebClient Bean

package com.example.downloadapi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        return builder.build();
    }
}

Step 2: Create a FileDownloadService

package com.example.downloadapi.service;

import lombok.RequiredArgsConstructor;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.io.FileOutputStream;
import java.io.IOException;

@Service
@RequiredArgsConstructor
public class FileDownloadService {

    private final WebClient webClient;

    public void downloadFile(String fileUrl, String destinationPath) {
        Flux<DataBuffer> dataBufferFlux = webClient.get()
                .uri(fileUrl)
                .retrieve()
                .bodyToFlux(DataBuffer.class);

        try (FileOutputStream outputStream = new FileOutputStream(destinationPath)) {

            dataBufferFlux
                    .map(DataBuffer::asInputStream)
                    .doOnNext(inputStream -> {
                        try {
                            inputStream.transferTo(outputStream);
                        } catch (IOException e) {
                            throw new RuntimeException("Error writing file", e);
                        }
                    })
                    .blockLast();

        } catch (IOException e) {
            throw new RuntimeException("Error creating file output stream", e);
        }
    }
}

Explanation:

  • The method streams data using reactive Flux<DataBuffer>.
  • It writes data directly to the file using a channel.
  • The .blockLast() ensures the reactive stream completes before proceeding.

This approach efficiently handles file transfers without unnecessary buffering.

Method 2: Download File as Temporary File

Temporary files are helpful when you need to store files temporarily, for example, in a caching mechanism or a processing pipeline.

That error, Exceeded limit on max bytes to buffer: 262144
It is typical when using Spring WebFlux’s WebClient Because, by default, it tries to fully buffer the response body into memory, which has a default limit of 256 KB (or 262144 bytes).

The correct way to solve this is exactly as you suggested: by streaming the response directly to a file, rather than buffering it in memory first.

Here is how you would modify your downloadFile method to stream the Flux<DataBuffer> to a temporary file, thus avoiding the buffer limit.

Step 1: Create a Temporary File Download Method

import lombok.RequiredArgsConstructor;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

@Service
@RequiredArgsConstructor
public class TemporaryFileDownloadService {

    private final WebClient webClient;

    public Path downloadFileToTemp(String fileUrl) throws IOException {

        Path tempDir = Paths.get("D:/workspaces/gitlab-demo/files/download/temp");

        Files.createDirectories(tempDir);

        Path tempFile = Files.createTempFile(tempDir, "download-", ".tmp");

        Flux<DataBuffer> dataBufferFlux = webClient.get()
                .uri(fileUrl)
                .retrieve()
                .bodyToFlux(DataBuffer.class);

        try (FileOutputStream outputStream = new FileOutputStream(tempFile.toFile())) {

            dataBufferFlux
                    .map(DataBuffer::asInputStream)
                    .doOnNext(inputStream -> {
                        try {
                            inputStream.transferTo(outputStream);
                        } catch (IOException e) {
                            throw new RuntimeException("Error writing file", e);
                        }
                    })
                    .blockLast();
        }

        return tempFile;
    }
}

Explanation:

  • A temporary file is created using Files.createTempFile().
  • The content is written directly into this file.
  • It can be deleted automatically or manually at a later time.

This method is effective when processing files before storage or when security policies prohibit permanent file saving.


Handling Exceptions and Edge Cases

File downloads often fail due to network issues or missing files. Always handle exceptions gracefully.

Example of Exception Handling:

package com.example.downloadapi.exception;

public class FileDownloadException extends RuntimeException {
    public FileDownloadException(String message, Throwable cause) {
        super(message, cause);
    }
}

Usage in Service Layer:

try {
    // download logic
} catch (IOException e) {
    throw new FileDownloadException("Failed to download file", e);
}

This ensures clean separation between logic and error handling.

Testing the API

Testing confirms that the file download process works correctly. Spring Boot supports both unit tests and integration tests.

Example Test Using WebTestClient:

package com.example.downloadapi;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.core.io.buffer.DataBufferUtils;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DownloadControllerTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void testFileDownload() {
        webTestClient.get()
                .uri("/download/sample.txt")
                .exchange()
                .expectStatus().isOk()
                .expectBody(DataBuffer.class)
                .consumeWith(response -> {
                    assert response.getResponseBody() != null;
                    DataBufferUtils.release(response.getResponseBody());
                });
    }
}

This test ensures the endpoint returns the file successfully.


Logging and Monitoring Downloads

Monitoring helps maintain transparency and performance. Add logs to trace downloads.

Example Logging Integration:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LoggingService {
    private static final Logger logger = LoggerFactory.getLogger(LoggingService.class);

    public void logDownload(String fileName) {
        logger.info("File downloaded: {}", fileName);
    }
}

Use structured logging for better observability.

Best Practices for File Download APIs

  1. Use Streams: Stream large files instead of loading them into memory.
  2. Set Proper Headers: Always include Content-Disposition for clarity.
  3. Validate Filenames: Prevent Directory Traversal Attacks.
  4. Handle Timeouts: Configure WebClient timeouts for reliability.
  5. Secure the API: Use authentication for sensitive downloads.
  6. Clean Up Temporary Files: Delete temp files after use.

Example: Complete End-to-End Flow

Here’s how a complete example might look:

Download File

@RestController
@RequiredArgsConstructor
public class FileDownloadController {

    private final FileDownloadService fileDownloadService;

    @GetMapping("/fetch-file")
    public ResponseEntity<String> fetchFile() {
        String fileUrl = "http://localhost:8080/download/sample.txt";
        String destinationPath = "C:/downloads/sample.txt";

        fileDownloadService.downloadFile(fileUrl, destinationPath);

        return ResponseEntity.ok("File downloaded successfully to " + destinationPath);
    }
}
http://localhost:8080/fetch-file

When this endpoint is called, it triggers a WebClient request to another API and saves the file locally.

Download File as Temporary File

private final TemporaryFileDownloadService temporaryFileDownloadService; 

@GetMapping("/fetch-file-tmp")
public ResponseEntity<String> fetchFileTmp() throws IOException {
    String fileUrl = "http://localhost:8080/download/sample.txt";
    Path tempFile = temporaryFileDownloadService.downloadFileToTemp(fileUrl);

    Path destinationPath = Paths.get("D:/workspaces/gitlab-demo/files/download", "sample.txt");

    try {
        // Copy the file. StandardCopyOption.REPLACE_EXISTING overwrites if sample2.txt exists.
        Files.copy(tempFile, destinationPath, StandardCopyOption.REPLACE_EXISTING);
        log.info("Successfully copied 'sample.txt' to 'sample2.txt'.");

    } catch (IOException e) {
        log.error(e.getMessage(), e);
    } finally {
        if (tempFile != null) {
            try {
                Files.deleteIfExists(tempFile);
            } catch (Exception e) {
                // Log cleanup error
                log.error(e.getMessage(), e);
            }
        }
    }

    return ResponseEntity.ok("File downloaded successfully to " + destinationPath);
}
http://localhost:8080/fetch-file-tmp

Cleaning up temporary files is crucial, especially in long-running applications or when dealing with large downloads, to save disk space and prevent resource leaks.


Performance Optimization

To improve speed:

  • Enable connection pooling in WebClient.
  • Use asynchronous writes with Schedulers.boundedElastic().
  • Use gzip compression where possible.

Example:

WebClient.builder()
    .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024))
    .build();

This increases the buffer size, allowing large file downloads to be completed efficiently.


Finally

You’ve built a complete file download API using Spring Boot and WebClient. You also implemented both temporary and non-temporary storage strategies. This approach provides flexibility to handle various business use cases — from generating reports to retrieving files from microservices — and supports seamless integration with existing systems.

You can now:

  • Stream files efficiently.
  • Handle large downloads safely.
  • Manage files with temporary and permanent storage methods.

Continue enhancing your API by adding authentication, logging, and metrics to improve reliability and traceability.

This article was originally published on Medium.