You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Feature Request: Automatic HEAD request handling #2895
Currently, Echo requires explicit HEAD route registration for every GET route. Some other frameworks like Fiber, Express and Fastify handle this automatically. According to HTTP semantics, HEAD should behave identically to GET except that the response body is omitted. Automatically supporting HEAD for GET routes aligns with this expectation and reduces boilerplate.
Add automatic HEAD handling for GET routes when no explicit HEAD route is registered.
Implementation Options
Option 1: Middleware approach
e:=echo.New()
e.Use(middleware.AutoHead())
Pros:
Opt-in
No changes to core routing
Simple implementation
Cons:
Must be registered last in the middleware chain. Otherwise, middlewares registered after it will not execute for HEAD requests, since the middleware directly invokes the handler and bypasses the remaining chain
I've already implemented this approach, but after testing I realized the middleware ordering requirement makes it less convenient and more error-prone.
Option 2: Core integration
Auto-register HEAD routes during e.GET() calls, controlled by config :
e:=echo.New()
e.AutoHead=true
Default value could remain false to avoid breaking existing behavior.
Pros:
No middleware ordering constraints
Slightly better performance, since re-routing is avoided
Cons:
Requires changes to core Echo router/route registration
Questions
Would the Echo team be interested in this functionality?
If so, which implementation approach would be preferred: middleware or core integration? Would you consider a PR for either approach?
I've already implemented this approach, but after testing I realized the middleware ordering requirement makes it less convenient.
You can procably use e.Pre(middleware.AutoHead()) as it is run before routing is done. Switch the r.Method and maybe switch responseWriter to no-op one.
I actually tried the e.Pre() approach, but ran into two issues:
Detecting explicit HEAD routes
With e.Pre(), routing hasn't happened yet, so I'd need to manually run the router to detect if an explicit HEAD route exists - essentially routing twice.
Method visible in downstream middleware
With e.Pre(), I rewrite request method to GET before calling next(c). The method stays as GET through the entire chain (routing -> e.Use() middleware -> handler) and only gets restored to HEAD after next(c) returns. This means loggers, auth middleware, etc. would log/see GET instead of HEAD, which is misleading.
With e.Pre(), routing hasn't happened yet, so I'd need to manually run the router to detect if an explicit HEAD route exists - essentially routing twice
for skipping specific cases the Skipper callback exists. Are you trying to create some universal solution or is it custom application? For later case - you already know what actual HEAD routes you actually have and can skip them probably by doing simple prefix check.
Another remark: this logic does not suit into Router because HEAD responses as omitted. But GET routes can be duplicated to HEAD routes when custom Router router adds it be GET is added. Another way is to use echo.OnAddRoute callback.
But GET routes can be duplicated to HEAD routes when custom Router router adds it be GET is added. Another way is to use echo.OnAddRoute callback.
That's exactly what I had in mind for option 2: using OnAddRoute to auto-register HEAD routes when GET routes are added. However, if it were implemented as a built-in Echo feature (e.g., an e.AutoHead = true config), it would need to preserve the ability for users to set their own OnAddRoute callback.
Are you trying to create some universal solution or is it custom application?
I think it would be beneficial to have this built into Echo, since it aligns with the HTTP spec and other frameworks handle it automatically.
For my specific application, I can live with custom middleware, which must be included last in the chain.
The head registration should be done in get and actually be opt-out like the default for many frameworks.
I will study an implementation during some time and update the thread soon.
I think HEAD to GET should be handled by PRE middleware like this:
package main
import (
"bufio""errors""net""net/http""github.com/labstack/echo/v5"
)
typenobodyResponseWriterstruct {
http.ResponseWriter
}
func (w*nobodyResponseWriter) Write(b []byte) (int, error) {
returnlen(b), nil// no-op body writes
}
func (w*nobodyResponseWriter) Flush() {
err:=http.NewResponseController(w.ResponseWriter).Flush()
iferr!=nil&&errors.Is(err, http.ErrNotSupported) {
panic(errors.New("response writer flushing is not supported"))
}
}
func (w*nobodyResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
returnhttp.NewResponseController(w.ResponseWriter).Hijack()
}
func (w*nobodyResponseWriter) Unwrap() http.ResponseWriter {
returnw.ResponseWriter
}
// RewriteHeadToGetPreMiddleware returns an Echo pre-middleware that transparently// rewrites incoming HTTP HEAD requests into GET requests for downstream handlers,// while ensuring that no response body is written to the client.//// Semantics://// - If the incoming request method is HEAD, it is temporarily rewritten to GET// before invoking the next handler.// - The original ResponseWriter is wrapped with a no-op writer that discards// any body writes, ensuring compliance with HTTP HEAD semantics (i.e., headers// are identical to GET, but no body is sent).// - After the handler completes, the original request method (HEAD) and// ResponseWriter are restored.//// This allows existing GET handlers to serve HEAD requests without duplication,// preserving:// - Status codes (e.g., 200, 418, etc.)// - Headers (e.g., Content-Type, Content-Length, custom headers)//// while suppressing the response body as required by RFC semantics.//// Notes://// - The middleware must be registered as a Pre middleware (e.Pre) so that routing// and handler selection operate on the rewritten GET method.// - Any writes performed by handlers are intercepted and discarded, but their// reported byte counts are preserved.// - Flush and Hijack are delegated to the underlying ResponseWriter where supported.funcRewriteHeadToGetPreMiddleware() echo.MiddlewareFunc {
returnfunc(next echo.HandlerFunc) echo.HandlerFunc {
returnfunc(c*echo.Context) error {
ifc.Request().Method==http.MethodHead {
c.Request().Method=http.MethodGetrw:=c.Response()
c.SetResponse(&nobodyResponseWriter{rw})
// restore state so the previous handler can return with the proper statedeferfunc() {
c.SetResponse(rw)
c.Request().Method=http.MethodHead
}()
}
returnnext(c)
}
}
}
funcmain() {
e:=echo.New()
e.Pre(RewriteHeadToGetPreMiddleware())
e.GET("/test", func(c*echo.Context) error {
c.Response().Header().Set("X-Test", "test") // HEAD request should have the same headers but no bodyreturnc.String(http.StatusTeapot, "Hello, World!") // the return status code needs to be 418 even for HEAD
})
iferr:=e.Start(":8080"); err!=nil {
e.Logger.Error("failed to start server: ", "error", err.Error())
}
}
@aldas I think the e.Pre() version still has the same two problems:
It can't preserve an explicit HEAD handler cleanly. To do that you'd need either a separate Skipper list that duplicates route knowledge outside the router or an extra routing pass.
It rewrites the method too early, so downstream middleware sees GET instead of HEAD. That can affect logging, metrics, auth, tracing behavior.
It can't preserve an explicit HEAD handler cleanly. To do that you'd need either a separate Skipper list that duplicates route knowledge outside the router or an extra routing pass.
I would say that writin custom func to skip specific paths would be usual "Echo" approach.
It rewrites the method too early, so downstream middleware sees GET instead of HEAD. That can affect logging, metrics, auth, tracing behavior.
I agree - this would not play well with loggers if the logger is after the HEAD middleware. but you can add logger before the HEAD middleware
Logging middlewares probably should not do much work before the next() returns so adding it in PRE works
anyway HEAD support is not that simple as it seems. To follow RFC correctly there needs to be a special ResponseWriter added and this is not the responsibility of the router to do.
what ever the solution is - it has to been done with middlewares in my oppinion.
NB: seems that ResponseWriter should actually count the written bytes and return proper value with Content-Length header although nothing actually was sent to the client.
package main
import (
"bufio""net""net/http""strconv""github.com/labstack/echo/v5""github.com/labstack/echo/v5/middleware"
)
// headResponseWriter captures the response that a GET handler would produce for a// rewritten HEAD request, suppresses the body, and preserves response metadata.//// The writer buffers headers and status until the downstream handler returns so it// can compute a Content-Length value from the number of body bytes that would have// been written by the GET handler. If the handler already sets Content-Length// explicitly, that value is preserved.//// Flush is intentionally a no-op because emitting headers early would prevent// finalizing Content-Length after the handler completes.typeheadResponseWriterstruct {
rw http.ResponseWriterheader http.HeaderstatusintwroteHeaderboolbodyBytesint64
}
funcnewHeadResponseWriter(rw http.ResponseWriter) *headResponseWriter {
return&headResponseWriter{
rw: rw,
header: make(http.Header),
}
}
func (w*headResponseWriter) Header() http.Header {
returnw.header
}
func (w*headResponseWriter) WriteHeader(codeint) {
ifw.wroteHeader {
return
}
w.wroteHeader=truew.status=code
}
func (w*headResponseWriter) Write(b []byte) (int, error) {
if!w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
w.bodyBytes+=int64(len(b))
returnlen(b), nil
}
func (w*headResponseWriter) Flush() {
// No-op on purpose. A HEAD response has no body, and flushing early would// commit headers before Content-Length can be finalized.
}
func (w*headResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
returnhttp.NewResponseController(w.rw).Hijack()
}
func (w*headResponseWriter) Unwrap() http.ResponseWriter {
returnw.rw
}
func (w*headResponseWriter) commit() {
dst:=w.rw.Header()
fork, vv:=rangew.header {
dst[k] =append([]string(nil), vv...)
}
ifdst.Get("Content-Length") ==""&&dst.Get("Transfer-Encoding") ==""&&!statusMustNotHaveBody(w.status) {
dst.Set("Content-Length", strconv.FormatInt(w.bodyBytes, 10))
}
// "commit" the Response only when the headers were written otherwise the Echo errorhandler cannot properly handle errorsifw.wroteHeader {
w.rw.WriteHeader(w.status)
}
}
funcstatusMustNotHaveBody(codeint) bool {
return (code>=100&&code<200) ||code==http.StatusNoContent||code==http.StatusNotModified
}
// RewriteHeadToGetPreMiddleware returns an Echo pre-middleware that serves HEAD// requests by reusing the corresponding GET handlers while suppressing the response// body and preserving GET response metadata.//// See:// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD// - https://datatracker.ietf.org/doc/html/rfc7231#section-4.3.2//// For an incoming HEAD request, the middleware temporarily rewrites the request// method to GET before routing and handler execution, then wraps the response with// a writer that://// - discards body bytes instead of sending them to the client// - records the status code selected by the handler// - preserves all headers produced by the handler// - synthesizes Content-Length from the number of bytes the GET handler would// have written when the handler did not set Content-Length explicitly//// After the downstream handler returns, the middleware commits the captured status// and headers to the original response writer, restores the original request method// and response writer, and returns the handler result unchanged.//// This allows a single GET handler implementation to satisfy HEAD semantics:// same status and headers as GET, but with no response body.//// The middleware must be registered with e.Pre so routing happens against the// temporary GET method rather than the original HEAD method.//// Caveats://// - Content-Length is only accurate for bytes observed through Write on the// wrapped response. Middleware that transforms the body after this layer// may affect the final GET payload length.// - Flush is intentionally suppressed for rewritten HEAD requests because early// header commits would prevent final Content-Length calculation.funcRewriteHeadToGetPreMiddleware() echo.MiddlewareFunc {
returnfunc(next echo.HandlerFunc) echo.HandlerFunc {
returnfunc(c*echo.Context) error {
ifc.Request().Method!=http.MethodHead {
returnnext(c)
}
origMethod:=c.Request().MethodorigResp:=c.Response()
hrw:=newHeadResponseWriter(origResp)
c.Request().Method=http.MethodGetc.SetResponse(hrw)
deferfunc() {
hrw.commit()
c.SetResponse(origResp)
c.Request().Method=origMethod
}()
returnnext(c)
}
}
}
funcmain() {
e:=echo.New()
e.Pre(middleware.RequestLogger())
e.Pre(RewriteHeadToGetPreMiddleware())
e.GET("/test", func(c*echo.Context) error {
c.Response().Header().Set("X-Test", "test") // HEAD request should have the same headers but no bodyreturnc.String(http.StatusTeapot, "Hello, World!") // the return status code needs to be 418 even for HEAD
})
iferr:=e.Start(":8080"); err!=nil {
e.Logger.Error("failed to start server: ", "error", err.Error())
}
}
It does require wiring in Echo itself, so it's not as clean as a middleware-based solution could be in theory. But given the tradeoffs discussed here, I think this is the better implementation in practice: explicit HEAD handlers are preserved, and middleware still sees HEAD instead of GET.
Activity
aldas commentedon Feb 11, 2026
You can procably use
e.Pre(middleware.AutoHead())as it is run before routing is done. Switch the r.Method and maybe switch responseWriter to no-op one.dmeremyanin commentedon Feb 11, 2026
I actually tried the e.Pre() approach, but ran into two issues:
With e.Pre(), routing hasn't happened yet, so I'd need to manually run the router to detect if an explicit HEAD route exists - essentially routing twice.
With e.Pre(), I rewrite request method to GET before calling
next(c). The method stays as GET through the entire chain (routing -> e.Use() middleware -> handler) and only gets restored to HEAD afternext(c)returns. This means loggers, auth middleware, etc. would log/see GET instead of HEAD, which is misleading.aldas commentedon Feb 11, 2026
for skipping specific cases the
Skippercallback exists. Are you trying to create some universal solution or is it custom application? For later case - you already know what actual HEAD routes you actually have and can skip them probably by doing simple prefix check.aldas commentedon Feb 11, 2026
Another remark: this logic does not suit into Router because HEAD responses as omitted. But GET routes can be duplicated to HEAD routes when custom
Routerrouter adds it be GET is added. Another way is to useecho.OnAddRoutecallback.but omitting response body can be done with custom
http.ResponseWriterimplementation and that can be done with (pre)middleware.Here is example custom router implementation that composes default router https://github.com/labstack/echo/blob/master/router_concurrent.go
dmeremyanin commentedon Feb 11, 2026
That's exactly what I had in mind for option 2: using OnAddRoute to auto-register HEAD routes when GET routes are added. However, if it were implemented as a built-in Echo feature (e.g., an e.AutoHead = true config), it would need to preserve the ability for users to set their own OnAddRoute callback.
I think it would be beneficial to have this built into Echo, since it aligns with the HTTP spec and other frameworks handle it automatically.
For my specific application, I can live with custom middleware, which must be included last in the chain.
jean0t commentedon Apr 3, 2026
The head registration should be done in get and actually be opt-out like the default for many frameworks.
I will study an implementation during some time and update the thread soon.
aldas commentedon Apr 5, 2026
I think HEAD to GET should be handled by PRE middleware like this:
dmeremyanin commentedon Apr 5, 2026
@aldas I think the e.Pre() version still has the same two problems:
aldas commentedon Apr 5, 2026
I would say that writin custom func to skip specific paths would be usual "Echo" approach.
I agree - this would not play well with loggers if the logger is after the HEAD middleware. but you can add logger before the HEAD middleware
Logging middlewares probably should not do much work before the
next()returns so adding it in PRE worksanyway HEAD support is not that simple as it seems. To follow RFC correctly there needs to be a special ResponseWriter added and this is not the responsibility of the router to do.
what ever the solution is - it has to been done with middlewares in my oppinion.
aldas commentedon Apr 5, 2026
NB: seems that ResponseWriter should actually count the written bytes and return proper value with
Content-Lengthheader although nothing actually was sent to the client.aldas commentedon Apr 5, 2026
ChatGPT suggests something like that
Test with
curl -I http://localhost:8080/testatharvaSharma17 commentedon Apr 9, 2026
Hello, if the issue is not resolved id love to take it up and contribute
aldas commentedon Apr 10, 2026
It is still open. It would be better if you explain a little solution/PR before you submit it.
atharvaSharma17 commentedon Apr 13, 2026
@aldas #2944
dmeremyanin commentedon Apr 15, 2026
I reviewed #2944 and I like this approach.
It does require wiring in Echo itself, so it's not as clean as a middleware-based solution could be in theory. But given the tradeoffs discussed here, I think this is the better implementation in practice: explicit HEAD handlers are preserved, and middleware still sees HEAD instead of GET.
aldas commentedon Apr 19, 2026
Well, this additional route for each GET does not look clean. I propose something like that #2949
docs: fix review findings in README/ROADMAP
docs: liveness signals in README + public ROADMAP (#3002)