diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index 86a30ec5c900a..c6b4f005cbfcd 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -675,7 +675,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) {
fs := http.FileServer(cfg.StaticFS)
- fs = makeGzipHandler(fs)
+ fs = makeBrotliHandler(fs, cfg.StaticFS)
fs = makeCacheHandler(fs, etag)
http.StripPrefix("/web", fs).ServeHTTP(w, r)
diff --git a/lib/web/brotlihandler.go b/lib/web/brotlihandler.go
new file mode 100644
index 0000000000000..67b0ecd8f53cd
--- /dev/null
+++ b/lib/web/brotlihandler.go
@@ -0,0 +1,69 @@
+/*
+ * Teleport
+ * Copyright (C) 2025 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package web
+
+import (
+ "io"
+ "mime"
+ "net/http"
+ "path"
+ "slices"
+ "strings"
+)
+
+var compressedFileExtensions = []string{
+ ".js",
+ ".svg",
+ ".wasm",
+}
+
+// makeBrotliHandler serves pre-compressed .br files for supported file types.
+func makeBrotliHandler(handler http.Handler, fs http.FileSystem) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ext := path.Ext(r.URL.Path)
+ isRequestForCompressedFile := slices.Contains(compressedFileExtensions, ext)
+ clientAcceptsBrotli := strings.Contains(r.Header.Get("Accept-Encoding"), "br")
+ if !isRequestForCompressedFile || !clientAcceptsBrotli {
+ handler.ServeHTTP(w, r)
+ return
+ }
+
+ brPath := r.URL.Path + ".br"
+ brFile, err := fs.Open(brPath)
+ if err != nil {
+ handler.ServeHTTP(w, r)
+ return
+ }
+ defer brFile.Close()
+
+ contentType := mime.TypeByExtension(ext)
+ if contentType == "" {
+ contentType = "application/octet-stream" // same default as http.DetectContentType
+ }
+
+ w.Header().Set("Content-Encoding", "br")
+ w.Header().Set("Content-Type", contentType)
+
+ if r.Method == http.MethodHead {
+ return
+ }
+
+ io.Copy(w, brFile)
+ })
+}
diff --git a/lib/web/gziphandler.go b/lib/web/gziphandler.go
deleted file mode 100644
index d550a3392f66e..0000000000000
--- a/lib/web/gziphandler.go
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * Teleport
- * Copyright (C) 2023 Gravitational, Inc.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-package web
-
-import (
- "compress/gzip"
- "io"
- "net/http"
- "strings"
- "sync"
-)
-
-// writerPool is a sync.Pool for shared gzip writers.
-// each gzip writer allocates a lot of memory
-// so it makes sense to reset the writer and reuse the
-// internal buffers to avoid too many objects on the heap
-var writerPool = sync.Pool{
- New: func() any {
- gz := gzip.NewWriter(io.Discard)
- return gz
- },
-}
-
-func newGzipResponseWriter(w http.ResponseWriter) gzipResponseWriter {
- gz := writerPool.Get().(*gzip.Writer)
- gz.Reset(w)
- return gzipResponseWriter{gz: gz, ResponseWriter: w}
-}
-
-type gzipResponseWriter struct {
- gz *gzip.Writer
- http.ResponseWriter
-}
-
-// Write uses the Writer part of gzipResponseWriter to write the output.
-func (w gzipResponseWriter) Write(b []byte) (int, error) {
- _, haveType := w.Header()["Content-Type"]
- // Explicitly set Content-Type if it has not been set previously
- if !haveType {
- // If no content type, apply sniffing algorithm to un-gzipped body.
- w.Header().Set("Content-Type", http.DetectContentType(b))
- }
- return w.gz.Write(b)
-}
-
-// makeGzipHandler adds support for gzip compression for given handler.
-func makeGzipHandler(handler http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Check if the client can accept the gzip encoding and that this is not an image asset.
- if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || isCompressedImageRequest(r) {
- handler.ServeHTTP(w, r)
- return
- }
-
- // Set the HTTP header indicating encoding.
- w.Header().Set("Content-Encoding", "gzip")
- gzw := newGzipResponseWriter(w)
- defer gzw.gz.Close()
- handler.ServeHTTP(gzw, r)
- })
-}
-
-// isCompressedImageRequest checks whether a request is for a png or jpg/jpeg
-func isCompressedImageRequest(r *http.Request) bool {
- return strings.HasSuffix(r.URL.Path, ".png") || strings.HasSuffix(r.URL.Path, ".jpg") || strings.HasSuffix(r.URL.Path, ".jpeg")
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c5aba509bebae..dc9e5ed5c7895 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -274,6 +274,9 @@ importers:
typescript-eslint:
specifier: ^8.35.1
version: 8.35.1(eslint@9.30.0)(typescript@5.8.3)
+ vite-plugin-compression:
+ specifier: ^0.5.1
+ version: 0.5.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0))
vite-plugin-wasm:
specifier: ^3.4.1
version: 3.4.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0))
@@ -6539,6 +6542,11 @@ packages:
resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==}
engines: {node: '>=0.6.0'}
+ vite-plugin-compression@0.5.1:
+ resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
+ peerDependencies:
+ vite: '>=2.0.0'
+
vite-plugin-wasm@3.4.1:
resolution: {integrity: sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==}
peerDependencies:
@@ -13983,6 +13991,15 @@ snapshots:
extsprintf: 1.4.1
optional: true
+ vite-plugin-compression@0.5.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)):
+ dependencies:
+ chalk: 4.1.2
+ debug: 4.4.1
+ fs-extra: 10.1.0
+ vite: 6.3.5(@types/node@22.15.34)(yaml@2.8.0)
+ transitivePeerDependencies:
+ - supports-color
+
vite-plugin-wasm@3.4.1(vite@6.3.5(@types/node@22.15.34)(yaml@2.8.0)):
dependencies:
vite: 6.3.5(@types/node@22.15.34)(yaml@2.8.0)
diff --git a/web/README.md b/web/README.md
index e21b75e107c23..05cef0df8f558 100644
--- a/web/README.md
+++ b/web/README.md
@@ -39,7 +39,12 @@ To build the Teleport open source version
pnpm build-ui-oss
```
-The resulting output will be in the `webassets` folder.
+The resulting output will be in the `webassets` folder. By default, the webassets are compressed with Brotli. If you
+want to disable this for faster local builds, set the environment variable `VITE_DISABLE_COMPRESSION` to any value:
+
+```
+VITE_DISABLE_COMPRESSION=1 pnpm build-ui-oss
+```
### Docker Build
diff --git a/web/packages/build/package.json b/web/packages/build/package.json
index 249cfbe5926ec..c0b735448e296 100644
--- a/web/packages/build/package.json
+++ b/web/packages/build/package.json
@@ -34,6 +34,7 @@
"jsdom": "^26.1.0",
"rollup-plugin-visualizer": "^6.0.3",
"typescript-eslint": "^8.35.1",
+ "vite-plugin-compression": "^0.5.1",
"vite-plugin-wasm": "^3.4.1",
"vite-tsconfig-paths": "^5.1.4"
}
diff --git a/web/packages/build/vite/config.ts b/web/packages/build/vite/config.ts
index dec8a64bb7581..77d2f962d1e1f 100644
--- a/web/packages/build/vite/config.ts
+++ b/web/packages/build/vite/config.ts
@@ -21,6 +21,7 @@ import { resolve } from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig, type UserConfig } from 'vite';
+import compression from 'vite-plugin-compression';
import wasm from 'vite-plugin-wasm';
import { generateAppHashFile } from './apphash';
@@ -98,6 +99,18 @@ export function createViteConfig(
if (mode === 'production') {
config.base = '/web';
+
+ if (!process.env.VITE_DISABLE_COMPRESSION) {
+ config.plugins.push(
+ compression({
+ algorithm: 'brotliCompress',
+ deleteOriginFile: true,
+ filter: /\.(js|svg|wasm)$/,
+ threshold: 1024 * 10, // 10KB
+ verbose: false,
+ })
+ );
+ }
} else {
config.plugins.push(htmlPlugin(target));
// siteName matches everything between the slashes.