- Run global middleware for all requests, including OPTIONS preflights, NotFound, and MethodNotAllowed — previously bypassed by httprouter's internal handling - Implement Hijacker, Flusher, and Pusher on the response writer for WebSocket, SSE, and HTTP/2 push support - Fix CORS: echo a single matching origin, handle AllowCredentials with wildcard, append Vary: Origin - Logger logs from a defer to capture correct status on panicked requests - Static and StaticFS accept route middleware; add Context.AddHeader; warn on NotFound handler override
go-kite
A fast, lightweight, and expressive HTTP framework for Go, built on top of httprouter.
~132,000 req/sec · sub-4ms latency · ~58MB RAM at peak load
Features
- Fast — ~132k RPS, sitting just below raw
net/httpand above Gin, Echo, and Chi - Middleware — global, group, and route-level middleware with correct onion ordering
- Groups — nestable route groups with prefix and middleware inheritance
- Static files & SPA — serve assets and single-page apps with optional middleware
- Graceful shutdown — in-flight requests finish cleanly on
SIGTERM/SIGINT - Configurable — server timeouts, error/not-found/method-not-allowed handlers
- Rich context — typed request/response helpers, value store, cookie and form support
- Built-in middleware — logger, recovery, CORS, request ID, max body size
- WebSocket & SSE ready — response writer implements
Hijacker,Flusher, andPusher
Installation
go get git.trcreatives.at/trcreatives/go-kite
Quickstart
package main
import (
"net/http"
"log"
"git.trcreatives.at/trcreatives/go-kite"
"git.trcreatives.at/trcreatives/go-kite/middleware"
)
func main() {
k := kite.New()
k.Use(
middleware.Logger(), // first, so it observes the final status after Recovery
middleware.Recovery(),
middleware.RequestID(),
)
k.GET("/", func(ctx *kite.Context) error {
return ctx.WriteJSON(http.StatusOK, map[string]string{
"message": "Hello, World!",
})
})
if err := k.Start("localhost:8080"); err != nil {
log.Fatal(err)
}
}
Routing
k := kite.New()
k.GET("/users", listUsers)
k.POST("/users", createUser)
k.GET("/users/:id", getUser)
k.PUT("/users/:id", updateUser)
k.DELETE("/users/:id", deleteUser)
Supported HTTP methods: GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, TRACE.
Route Groups
Groups allow you to share a common prefix and middleware across multiple routes.
api := k.Group("/api")
api.GET("/users", listUsers) // GET /api/users
api.POST("/users", createUser) // POST /api/users
Groups can be nested:
api := k.Group("/api")
v1 := api.Group("/v1")
v1.GET("/users", listUsers) // GET /api/v1/users
Middleware can be attached to groups:
api := k.Group("/api", authMiddleware)
admin := api.Group("/admin", adminOnlyMiddleware)
admin.GET("/stats", getStats) // chain: global → auth → adminOnly → handler
Static Files & Single-Page Apps
Static Assets
Static serves files from a directory on disk under a URL prefix:
k.Static("/assets", "./public")
// GET /assets/css/main.css → ./public/css/main.css
Middleware can be applied per-prefix:
k.Static("/admin/assets", "./admin-dist", authMiddleware)
StaticFS is the same but takes an http.FileSystem, useful for embedded assets via embed:
//go:embed dist
var distFS embed.FS
k.StaticFS("/assets", http.FS(distFS))
Both are also available on Group for prefixed/scoped serving.
Single-Page Applications
For Vue, React, Svelte, and similar frameworks, SPA serves the directory and falls back to index.html for unknown paths — letting client-side routing work correctly:
k.SPA("./dist")
SPAFS takes an http.FileSystem for embedded builds.
Note:
SPAandSPAFSregister the file server as the NotFound handler. If you've already calledSetNotFoundHandler(or call it afterward), a warning is logged about the override.
Middleware
Middleware follows the standard onion model — each middleware wraps the next.
type Middleware func(h Handler) Handler
Global Middleware
Applied to every request, including NotFound, MethodNotAllowed, and OPTIONS preflights:
k.Use(middleware.Logger())
k.Use(middleware.Recovery())
Route Middleware
Applied to a single route:
k.GET("/admin", adminHandler, authMiddleware, rateLimitMiddleware)
Execution Order
→ Global 1
→ Global 2
→ Group 1
→ Route 1
[Handler]
← Route 1
← Group 1
← Global 2
← Global 1
The first middleware registered is the outermost — it runs first on the way in, and last on the way out.
Recommended Order
Register Logger before Recovery. The reverse order causes Logger's deferred log line to read the status before Recovery writes the 500, which means panicked requests log as 200.
k.Use(
middleware.Logger(), // outermost — sees the final status
middleware.Recovery(), // catches panics from inner handlers
middleware.RequestID(),
)
Writing Custom Middleware
func MyMiddleware(h kite.Handler) kite.Handler {
return func(ctx *kite.Context) error {
// before handler
err := h(ctx)
// after handler
return err
}
}
Built-in Middleware
All middleware lives in the middleware sub-package:
import "git.trcreatives.at/trcreatives/go-kite/middleware"
Logger
Logs method, path, status code and latency for every request. Logs from a defer, so panicked and errored requests are captured too (provided Logger is registered before Recovery).
// simple
k.Use(middleware.Logger())
// custom
k.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Output: logFile,
Format: func(method, path string, statusCode int, latency time.Duration) string {
return fmt.Sprintf("%s %s %d %dms", method, path, statusCode, latency.Milliseconds())
},
Skip: func(ctx *kite.Context) bool {
return ctx.GetPath() == "/health"
},
}))
Recovery
Catches panics and returns a 500 instead of crashing the server.
// simple
k.Use(middleware.Recovery())
// custom — integrate with Sentry, Datadog, etc.
k.Use(middleware.RecoveryWithConfig(middleware.RecoveryConfig{
OnPanic: func(ctx *kite.Context, recovered any, stack []byte) error {
sentry.CaptureException(fmt.Errorf("%v", recovered))
return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{
"error": "internal server error",
})
},
}))
RequestID
Attaches a unique ID to every request. Reuses an incoming X-Request-ID header if present, for distributed tracing.
// simple
k.Use(middleware.RequestID())
// custom header
k.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{
Header: "X-Trace-ID",
}))
// retrieve in handler
id := ctx.GetValue("X-Request-ID").(string)
CORS
Sets Cross-Origin Resource Sharing headers and handles preflight requests.
// allow all origins (no credentials)
k.Use(middleware.CORS())
// restrict to specific origins
k.Use(middleware.CORS("https://myapp.com", "https://staging.myapp.com"))
// full control
k.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowedOrigins: []string{"https://myapp.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 86400,
}))
For multi-origin allowlists, the middleware checks the request's Origin against the list and echoes back only the matching one — Access-Control-Allow-Origin always contains a single origin, never a comma-separated list. A Vary: Origin header is added so caches behave correctly.
Note: When
AllowCredentials: trueis combined withAllowedOrigins: ["*"], the middleware echoes the request'sOrigininstead of sending*, since browsers reject that combination.
MaxBodySize
Protects against oversized request bodies. Returns 413 if the limit is exceeded.
// global 4MB limit
k.Use(middleware.MaxBodySize(4 << 20))
// per-route override for file uploads
k.POST("/upload", uploadHandler, middleware.MaxBodySize(100 << 20))
Context
Every handler receives a *kite.Context with the following methods:
Request
| Method | Description |
|---|---|
GetRequest() *http.Request |
Underlying request |
GetResponse() http.ResponseWriter |
Underlying response writer |
GetContext() context.Context |
Underlying context |
GetMethod() string |
HTTP method |
GetPath() string |
Request path |
IsMethod(method string) bool |
Check HTTP method |
GetPathParam(key string) string |
Route parameter (e.g. /users/:id) |
GetQueryParam(key string) string |
Query string parameter |
GetHeader(key string) string |
Request header |
GetIP() string |
Client IP, respects X-Forwarded-For |
GetCookie(name string) (*http.Cookie, error) |
Request cookie |
GetStatusCode() int |
Current response status code |
Values
| Method | Description |
|---|---|
SetValue(key string, v any) |
Store a value in the context |
GetValue(key string) any |
Retrieve a stored value |
Forms
| Method | Description |
|---|---|
GetForm() (url.Values, error) |
All form values |
GetFormValue(key string) string |
Single form value |
GetMultipartForm(maxMemory int64) (*multipart.Form, error) |
Multipart form |
GetMultipartFormFile(key string) (multipart.File, *multipart.FileHeader, error) |
Uploaded file |
Body Binding
| Method | Description |
|---|---|
BindJSON(v any) error |
Decode JSON request body |
BindXML(v any) error |
Decode XML request body |
Response
| Method | Description |
|---|---|
WriteBytes(status int, v []byte) error |
Raw bytes response |
WriteString(status int, v string) error |
Plain text response (UTF-8) |
WriteJSON(status int, v any) error |
JSON response (UTF-8) |
WriteXML(status int, v any) error |
XML response (UTF-8) |
WriteNoContent() error |
204 No Content |
Redirect(status int, url string) error |
Redirect |
SetHeader(key, value string) |
Set response header (replaces existing) |
AddHeader(key, value string) |
Append response header (for multi-value) |
SetCookie(cookie *http.Cookie) |
Set response cookie |
Error Handling
Handlers return error. By default, errors are logged and a 500 is returned to the client.
k.GET("/users/:id", func(ctx *kite.Context) error {
user, err := db.GetUser(ctx.GetPathParam("id"))
if err != nil {
return err // caught by error handler
}
return ctx.WriteJSON(http.StatusOK, user)
})
Custom Error Handler
k.SetErrorHandler(func(ctx *kite.Context, err error) error {
return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
})
Custom Not Found Handler
k.SetNotFoundHandler(func(ctx *kite.Context) error {
return ctx.WriteJSON(http.StatusNotFound, map[string]string{
"error": "route not found",
})
})
Custom Method Not Allowed Handler
k.SetMethodNotAllowedHandler(func(ctx *kite.Context) error {
return ctx.WriteJSON(http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
})
Server Configuration
k := kite.New()
k.SetServerReadTimeout(10 * time.Second) // default: 10s
k.SetServerWriteTimeout(30 * time.Second) // default: 30s
k.SetServerIdleTimeout(60 * time.Second) // default: 60s
k.SetServerShutdownTimeout(30 * time.Second) // default: 30s
Graceful Shutdown
Start() blocks until SIGINT or SIGTERM is received, then waits for all in-flight requests to finish before exiting. This maps directly to how Docker, Kubernetes, and systemd stop processes.
if err := k.Start("localhost:8080"); err != nil {
log.Fatal(err)
}
TLS
Use StartTLS instead of Start and provide your certificate and key files:
if err := k.StartTLS("localhost:443", "cert.pem", "key.pem"); err != nil {
log.Fatal(err)
}
For local development you can generate a self-signed certificate:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
For production, use a certificate from a trusted CA. Caddy and Nginx are both good options as a reverse proxy if you prefer to terminate TLS outside the application.
Performance
Benchmarked on a MacBook Pro. Each scenario ran for 30 seconds per concurrency level with a 5 second warmup.
Hello World (string + logger middleware)
| Concurrency | RPS | Avg Latency | Avg CPU | Avg RAM |
|---|---|---|---|---|
| 1 | 22,076 | 0.04ms | 31.0% | 20.95MB |
| 10 | 79,567 | 0.12ms | 86.2% | 24.01MB |
| 25 | 95,066 | 0.25ms | 88.8% | 24.54MB |
| 50 | 100,283 | 0.47ms | 91.5% | 27.81MB |
| 100 | 113,956 | 0.83ms | 93.5% | 30.91MB |
| 250 | 128,858 | 1.81ms | 97.0% | 43.42MB |
| 500 | 132,760 | 3.45ms | 91.9% | 58.20MB |
| 1000 | 131,101 | 6.92ms | 98.6% | 83.23MB |
RPS
140k │ ▓▓▓▓ ████
120k │ ▓▓▓▓ ████ ████
100k │ ████ ████ ████ ████ ████ ████
80k │ ████ ████ ████ ████ ████ ████ ████
60k │ ████ ████ ████ ████ ████ ████ ████
40k │ ████ ████ ████ ████ ████ ████ ████ ████
20k │ ████ ████ ████ ████ ████ ████ ████ ████
└──────────────────────────────────────────────▶
1 10 25 50 100 250 500 1000
Concurrency
Sweet spot: 250–500 workers — RPS plateaus around 132k while latency stays under 4ms.
SQLite Counter (UPDATE + SELECT + JSON response)
| Concurrency | RPS | Avg Latency | Avg CPU | Avg RAM |
|---|---|---|---|---|
| 1 | 4,983 | 0.20ms | 19.9% | 83.34MB |
| 10 | 4,728 | 2.04ms | 19.4% | 86.30MB |
| 25 | 4,433 | 5.50ms | 19.0% | 88.81MB |
| 50 | 2,913 | 15.98ms | 29.5% | 61.68MB |
| 100 | 2,139 | 38.61ms | 20.8% | 71.38MB |
| 250 | 628 | 189.88ms | 18.0% | 102.66MB |
| 500 | 257 | 548.91ms | 11.9% | 164.40MB |
| 1000 | 130 | 853.20ms | 9.4% | 288.71MB |
RPS
5.0k │ ████ ████
4.0k │ ████ ████ ████
3.0k │ ████ ████ ████ ████
2.0k │ ████ ████ ████ ████ ████
1.0k │ ████ ████ ████ ████ ████ ████
500 │ ████ ████ ████ ████ ████ ████ ████
100 │ ████ ████ ████ ████ ████ ████ ████ ████
└──────────────────────────────────────────────▶
1 10 25 50 100 250 500 1000
Concurrency
Sweet spot: 1 worker — SQLite's single-writer model means concurrency hurts. RPS drops sharply beyond 10 workers as write contention builds up. This is a SQLite limitation, not a kite limitation — a Postgres or MySQL backend would scale horizontally.
Key Takeaways
- Pure HTTP throughput peaks at ~132k RPS with negligible RAM usage (~58MB at peak)
- Middleware overhead (logger) adds virtually no cost
- RAM scales linearly with concurrency — roughly 50–85 bytes per idle goroutine
- CPU saturates around 250 workers for in-memory workloads — beyond that, latency grows but RPS flattens
- Database workloads are bottlenecked by the DB, not the framework — kite's overhead is not the limiting factor
License
MIT