Lazy File Tutorial
This tutorial shows you how to create LazyFile and LazyBlob objects that defer reading content until it is needed, enabling memory-efficient file serving and byte-range support.
Prerequisites
- A Remix V3 project
- Basic understanding of the web
FileandBlobAPIs
Step 1: Create a LazyFile
A LazyFile takes three arguments: a read function that returns a ReadableStream, a filename, and an options object with type and size.
import { LazyFile } from 'remix/lazy-file'
let file = new LazyFile(
() => fetch('https://cdn.example.com/report.pdf').then((r) => r.body!),
'report.pdf',
{ type: 'application/pdf', size: 1_048_576 },
)
// Metadata is available immediately
console.log(file.name) // 'report.pdf'
console.log(file.type) // 'application/pdf'
console.log(file.size) // 1048576
// Content is NOT fetched yet -- no network call has been madeThe read function is called only when you access the content through stream(), arrayBuffer(), or text().
Step 2: Use LazyFile from the Filesystem
The fs package creates LazyFile objects from files on disk. This is the most common way to get a LazyFile.
import { openLazyFile } from 'remix/fs'
let file = await openLazyFile('./uploads/photo.jpg')
console.log(file.name) // 'photo.jpg'
console.log(file.type) // 'image/jpeg'
console.log(file.size) // 2097152
console.log(file.lastModified) // 1704067200000
// File content is NOT read from disk yetStep 3: Serve a LazyFile as a Response
Because LazyFile implements the File interface, you can pass it directly to response helpers.
import { openLazyFile } from 'remix/fs'
import { createFileResponse } from 'remix/response/file'
export async function loader() {
let file = await openLazyFile('./public/downloads/manual.pdf')
return createFileResponse(file)
}The createFileResponse helper reads the file's metadata to set Content-Type, Content-Length, and ETag headers. The file bytes are streamed to the client on demand.
Step 4: Support Byte-Range Requests
LazyFile supports slice(start, end), which returns a LazyBlob that reads only the requested byte range. This is essential for video streaming and resumable downloads.
import { LazyFile } from 'remix/lazy-file'
import { Range } from 'remix/headers'
export async function loader({ request }: { request: Request }) {
let file = new LazyFile(
() => fetch('https://cdn.example.com/video.mp4').then((r) => r.body!),
'video.mp4',
{ type: 'video/mp4', size: 52_428_800 },
)
let rangeHeader = request.headers.get('Range')
if (rangeHeader) {
let range = Range.parse(rangeHeader)
let start = range.ranges[0].start
let end = range.ranges[0].end ?? file.size - 1
let slice = file.slice(start, end + 1)
return new Response(slice.stream(), {
status: 206,
headers: {
'Content-Type': file.type,
'Content-Length': String(slice.size),
'Content-Range': `bytes ${start}-${end}/${file.size}`,
'Accept-Ranges': 'bytes',
},
})
}
return new Response(file.stream(), {
headers: {
'Content-Type': file.type,
'Content-Length': String(file.size),
'Accept-Ranges': 'bytes',
},
})
}Step 5: Create a LazyBlob
When you do not need a filename, use LazyBlob directly.
import { LazyBlob } from 'remix/lazy-file'
let blob = new LazyBlob(
() => fetch('https://api.example.com/data.json').then((r) => r.body!),
{ type: 'application/json', size: 4096 },
)
// Use like any Blob
let text = await blob.text()
let buffer = await blob.arrayBuffer()
let stream = blob.stream()Summary
| Concept | What You Learned |
|---|---|
| Deferred reading | Content is not loaded until stream(), arrayBuffer(), or text() is called |
| From disk | Use openLazyFile from remix/fs to create lazy files from the filesystem |
| Serving | Pass to createFileResponse for automatic headers and streaming |
| Byte ranges | Use slice(start, end) for range requests and video streaming |
| LazyBlob | Use when you do not need a filename |
Next Steps
- Use fs for filesystem-based lazy files
- Serve files with response helpers
- See the API Reference for all constructor options