- Add performance section with full benchmark tables for Hello World and SQLite Counter scenarios - Add ASCII bar charts visualising RPS across concurrency levels 1–1000 - Add key takeaways explaining CPU, RAM, and latency characteristics - Restore Fast feature bullet with measured numbers (~132k RPS) - Add headline stats callout (~132k RPS, sub-4ms latency, ~58MB RAM at peak)
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
- Graceful shutdown — in-flight requests finish cleanly on
SIGTERM/SIGINT - Configurable — server timeouts, error handlers, not-found handlers all configurable
- Rich context — typed request/response helpers, value store, cookie and form support
- Built-in middleware — logger, recovery, CORS, request ID, max body size
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.Recovery(),
middleware.Logger(),
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)
All standard HTTP methods are supported: GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, TRACE, PATCH.
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
Middleware
Middleware follows the standard onion model — each middleware wraps the next.
type Middleware func(h Handler) Handler
Global Middleware
Applied to every route:
k.Use(middleware.Recovery())
k.Use(middleware.Logger())
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
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.
// 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 — always register this first
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")
CORS
Sets Cross-Origin Resource Sharing headers and handles preflight requests.
// allow all origins
k.Use(middleware.CORS())
// restrict origins
k.Use(middleware.CORS("https://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,
}))
Note:
AllowCredentials: truecannot be combined withAllowedOrigins: ["*"]— browsers will reject it.
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 |
WriteJSON(status int, v any) error |
JSON response |
WriteXML(status int, v any) error |
XML response |
WriteNoContent() error |
204 No Content |
Redirect(status int, url string) error |
Redirect |
SetHeader(key, value string) |
Set response header |
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