diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b5dd97..dbd71f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased] - ReleaseDate
 
+### Added
+
+- Added support for rendering tiles WebP format using the `--image-format` option
+
 ## [2.3.1] - 2025-01-06
 
 ### Fixed
diff --git a/Cargo.lock b/Cargo.lock
index 87d4135..970c1be 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -461,10 +461,21 @@ checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b"
 dependencies = [
  "bytemuck",
  "byteorder-lite",
+ "image-webp",
  "num-traits",
  "png",
 ]
 
+[[package]]
+name = "image-webp"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
+]
+
 [[package]]
 name = "indexmap"
 version = "2.7.0"
@@ -800,6 +811,12 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
 [[package]]
 name = "quote"
 version = "1.0.38"
diff --git a/Cargo.toml b/Cargo.toml
index 6a6a300..c4bfdcb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,7 +44,7 @@ enum-map = "2.7.3"
 fastnbt = "2.3.2"
 futures-util = "0.3.28"
 git-version = "0.3.5"
-image = { version = "0.25.1", default-features = false, features = ["png"] }
+image = { version = "0.25.1", default-features = false, features = ["png", "webp"] }
 indexmap = { version = "2.0.0", features = ["serde"] }
 lru = "0.12.0"
 minedmap-nbt = { version = "0.1.1", path = "crates/nbt", default-features = false }
diff --git a/README.md b/README.md
index 4c684f9..1ea4856 100644
--- a/README.md
+++ b/README.md
@@ -55,9 +55,18 @@ a proper webserver like [nginx](https://nginx.org/) or upload the viewer togethe
 the generated map files to public webspace to make the map available to others.
 
 If you are uploading the directory to a remote webserver, you do not need to upload the
-`<viewer>/data/processed` directory, as that is only used locally to allow processing
+`<viewer>/data/processed` directory, as it is only used locally to allow processing
 updates more quickly.
 
+### Image formats
+
+MinedMap renders map tiles as PNG by default. Pass `--image-format webp` to select
+WebP instead. For typical Minecraft worlds, using WebP reduces file sizes by 10-15%
+without increasing processing time.
+
+MinedMap always uses lossless compression for tile images, regardless of the
+image format.
+
 ### Signs
 
 ![Sign screenshot](https://raw.githubusercontent.com/neocturne/MinedMap/e5d9c813ba3118d04dc7e52e3dc6f48808a69120/docs/images/signs.png)
diff --git a/src/core/common.rs b/src/core/common.rs
index be6d28a..b933dcd 100644
--- a/src/core/common.rs
+++ b/src/core/common.rs
@@ -7,6 +7,7 @@ use std::{
 };
 
 use anyhow::{Context, Result};
+use clap::ValueEnum;
 use indexmap::IndexSet;
 use regex::{Regex, RegexSet};
 use serde::{Deserialize, Serialize};
@@ -150,6 +151,8 @@ pub struct Config {
 	pub viewer_info_path: PathBuf,
 	/// Path of viewer entities file
 	pub viewer_entities_path: PathBuf,
+	/// Format of generated map tiles
+	pub image_format: ImageFormat,
 	/// Sign text filter patterns
 	pub sign_patterns: RegexSet,
 	/// Sign text transformation pattern
@@ -189,6 +192,7 @@ impl Config {
 			entities_path_final,
 			viewer_info_path,
 			viewer_entities_path,
+			image_format: args.image_format,
 			sign_patterns,
 			sign_transforms,
 		})
@@ -264,14 +268,39 @@ impl Config {
 		[&self.output_dir, Path::new(&dir)].iter().collect()
 	}
 
+	/// Returns the file extension for the configured image format
+	pub fn tile_extension(&self) -> &'static str {
+		match self.image_format {
+			ImageFormat::Png => "png",
+			ImageFormat::Webp => "webp",
+		}
+	}
+	/// Returns the configurured image format for the image library
+	pub fn tile_image_format(&self) -> image::ImageFormat {
+		match self.image_format {
+			ImageFormat::Png => image::ImageFormat::Png,
+			ImageFormat::Webp => image::ImageFormat::WebP,
+		}
+	}
+
 	/// Constructs the path of an output tile image
 	pub fn tile_path(&self, kind: TileKind, level: usize, coords: TileCoords) -> PathBuf {
-		let filename = coord_filename(coords, "png");
+		let filename = coord_filename(coords, self.tile_extension());
 		let dir = self.tile_dir(kind, level);
 		[Path::new(&dir), Path::new(&filename)].iter().collect()
 	}
 }
 
+/// Format of generated map tiles
+#[derive(Debug, Clone, Copy, Default, ValueEnum)]
+pub enum ImageFormat {
+	/// Generate PNG images
+	#[default]
+	Png,
+	/// Generate WebP images
+	Webp,
+}
+
 /// Copies a chunk image into a region tile
 pub fn overlay_chunk<I, J>(image: &mut I, chunk: &J, coords: ChunkCoords)
 where
diff --git a/src/core/metadata_writer.rs b/src/core/metadata_writer.rs
index 0ea1f65..92d8566 100644
--- a/src/core/metadata_writer.rs
+++ b/src/core/metadata_writer.rs
@@ -61,6 +61,8 @@ struct Metadata<'t> {
 	spawn: Spawn,
 	/// Enabled MinedMap features
 	features: Features,
+	/// Format of generated map tiles
+	tile_extension: &'static str,
 }
 
 /// Viewer entity JSON data structure
@@ -205,6 +207,7 @@ impl<'a> MetadataWriter<'a> {
 			mipmaps: Vec::new(),
 			spawn: Self::spawn(&level_dat),
 			features,
+			tile_extension: self.config.tile_extension(),
 		};
 
 		for tile_map in self.tiles.iter() {
diff --git a/src/core/mod.rs b/src/core/mod.rs
index f552ffa..5832379 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -16,7 +16,7 @@ use anyhow::{Context, Result};
 use clap::Parser;
 use git_version::git_version;
 
-use common::Config;
+use common::{Config, ImageFormat};
 use metadata_writer::MetadataWriter;
 use region_processor::RegionProcessor;
 use tile_mipmapper::TileMipmapper;
@@ -47,6 +47,9 @@ pub struct Args {
 	/// Enable verbose messages
 	#[arg(short, long)]
 	pub verbose: bool,
+	/// Format of generated map tiles
+	#[arg(long, value_enum, default_value_t)]
+	pub image_format: ImageFormat,
 	/// Prefix for text of signs to show on the map
 	#[arg(long)]
 	pub sign_prefix: Vec<String>,
diff --git a/src/core/region_processor.rs b/src/core/region_processor.rs
index ce2d060..e448f5e 100644
--- a/src/core/region_processor.rs
+++ b/src/core/region_processor.rs
@@ -79,6 +79,8 @@ struct SingleRegionProcessor<'a> {
 	lightmap: image::GrayAlphaImage,
 	/// Processed entity intermediate data
 	entities: ProcessedEntities,
+	/// Format of generated map tiles
+	image_format: image::ImageFormat,
 	/// True if any unknown block or biome types were encountered during processing
 	has_unknown: bool,
 }
@@ -127,6 +129,7 @@ impl<'a> SingleRegionProcessor<'a> {
 			processed_region,
 			lightmap,
 			entities,
+			image_format: processor.config.tile_image_format(),
 			has_unknown: false,
 		})
 	}
@@ -179,7 +182,7 @@ impl<'a> SingleRegionProcessor<'a> {
 			self.input_timestamp,
 			|file| {
 				self.lightmap
-					.write_to(file, image::ImageFormat::Png)
+					.write_to(file, self.image_format)
 					.context("Failed to save image")
 			},
 		)
diff --git a/src/core/tile_mipmapper.rs b/src/core/tile_mipmapper.rs
index d7e54a9..2eda0e9 100644
--- a/src/core/tile_mipmapper.rs
+++ b/src/core/tile_mipmapper.rs
@@ -144,7 +144,7 @@ where
 		}
 
 		image
-			.write_to(file, image::ImageFormat::Png)
+			.write_to(file, self.config.tile_image_format())
 			.context("Failed to save image")
 	}
 }
diff --git a/src/core/tile_renderer.rs b/src/core/tile_renderer.rs
index 09ad8a1..a972b78 100644
--- a/src/core/tile_renderer.rs
+++ b/src/core/tile_renderer.rs
@@ -304,7 +304,7 @@ impl<'a> TileRenderer<'a> {
 			processed_timestamp,
 			|file| {
 				image
-					.write_to(file, image::ImageFormat::Png)
+					.write_to(file, self.config.tile_image_format())
 					.context("Failed to save image")
 			},
 		)?;
diff --git a/viewer/MinedMap.js b/viewer/MinedMap.js
index cfcccf1..61188b1 100644
--- a/viewer/MinedMap.js
+++ b/viewer/MinedMap.js
@@ -73,7 +73,7 @@ function signIcon(material, kind) {
 }
 
 const MinedMapLayer = L.TileLayer.extend({
-	initialize: function (mipmaps, layer) {
+	initialize: function (mipmaps, layer, tile_extension) {
 		L.TileLayer.prototype.initialize.call(this, '', {
 			detectRetina: true,
 			tileSize: 512,
@@ -88,6 +88,7 @@ const MinedMapLayer = L.TileLayer.extend({
 
 		this.mipmaps = mipmaps;
 		this.layer = layer;
+		this.ext = tile_extension;
 	},
 
 	createTile: function (coords, done) {
@@ -112,7 +113,7 @@ const MinedMapLayer = L.TileLayer.extend({
 			return L.Util.emptyImageUrl;
 
 
-		return 'data/'+this.layer+'/'+z+'/r.'+coords.x+'.'+coords.y+'.png';
+		return `data/${this.layer}/${z}/r.${coords.x}.${coords.y}.${this.ext}`;
 	},
 });
 
@@ -332,6 +333,7 @@ window.createMap = function () {
 		const res = await response.json();
 		const {mipmaps, spawn} = res;
 		const features = res.features || {};
+		const tile_extension = res.tile_extension || 'png';
 
 		const updateParams = function () {
 			const args = parseHash();
@@ -369,10 +371,10 @@ window.createMap = function () {
 
 		const overlayMaps = {};
 
-		const mapLayer = new MinedMapLayer(mipmaps, 'map');
+		const mapLayer = new MinedMapLayer(mipmaps, 'map', tile_extension);
 		mapLayer.addTo(map);
 
-		const lightLayer = new MinedMapLayer(mipmaps, 'light');
+		const lightLayer = new MinedMapLayer(mipmaps, 'light', tile_extension);
 		overlayMaps['Illumination'] = lightLayer;
 		if (params.light)
 			map.addLayer(lightLayer);