diff --git a/modules/caddyhttp/caddyhttp.go b/modules/caddyhttp/caddyhttp.go
index 38e9a65f2..0aa1d6cd1 100644
--- a/modules/caddyhttp/caddyhttp.go
+++ b/modules/caddyhttp/caddyhttp.go
@@ -171,6 +171,9 @@ func (app *App) Provision(ctx caddy.Context) error {
 			if err != nil {
 				return fmt.Errorf("server %s: setting up server routes: %v", srvName, err)
 			}
+			// pre-compile the handler chain, and be sure to wrap it in our
+			// route handler so that important security checks are done, etc.
+			srv.primaryHandlerChain = srv.wrapPrimaryRoute(srv.Routes.Compile())
 		}
 
 		if srv.Errors != nil {
@@ -178,10 +181,7 @@ func (app *App) Provision(ctx caddy.Context) error {
 			if err != nil {
 				return fmt.Errorf("server %s: setting up server error handling routes: %v", srvName, err)
 			}
-		}
-
-		if srv.MaxRehandles == nil {
-			srv.MaxRehandles = &DefaultMaxRehandles
+			srv.errorHandlerChain = srv.Errors.Routes.Compile()
 		}
 	}
 
@@ -210,13 +210,6 @@ func (app *App) Validate() error {
 		}
 	}
 
-	// each server's max rehandle value must be valid
-	for srvName, srv := range app.Servers {
-		if srv.MaxRehandles != nil && *srv.MaxRehandles < 0 {
-			return fmt.Errorf("%s: invalid max_rehandles value: %d", srvName, *srv.MaxRehandles)
-		}
-	}
-
 	return nil
 }
 
@@ -608,7 +601,7 @@ func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
 
 // Middleware chains one Handler to the next by being passed
 // the next Handler in the chain.
-type Middleware func(HandlerFunc) HandlerFunc
+type Middleware func(Handler) Handler
 
 // MiddlewareHandler is like Handler except it takes as a third
 // argument the next handler in the chain. The next handler will
@@ -624,7 +617,7 @@ type MiddlewareHandler interface {
 }
 
 // emptyHandler is used as a no-op handler.
-var emptyHandler HandlerFunc = func(http.ResponseWriter, *http.Request) error { return nil }
+var emptyHandler Handler = HandlerFunc(func(http.ResponseWriter, *http.Request) error { return nil })
 
 // WeakString is a type that unmarshals any JSON value
 // as a string literal, with the following exceptions:
@@ -734,10 +727,6 @@ const (
 	DefaultHTTPSPort = 443
 )
 
-// DefaultMaxRehandles is the maximum number of rehandles to
-// allow, if not specified explicitly.
-var DefaultMaxRehandles = 3
-
 // Interface guards
 var (
 	_ caddy.App         = (*App)(nil)
diff --git a/modules/caddyhttp/errors.go b/modules/caddyhttp/errors.go
index 584f56f34..059306364 100644
--- a/modules/caddyhttp/errors.go
+++ b/modules/caddyhttp/errors.go
@@ -106,12 +106,6 @@ func trace() string {
 	return ""
 }
 
-// ErrRehandle is a special error value that Handlers should return
-// from their ServeHTTP() method if the request is to be re-processed.
-// This error value is a sentinel value that should not be wrapped or
-// modified.
-var ErrRehandle = fmt.Errorf("rehandling request")
-
 // ErrorCtxKey is the context key to use when storing
 // an error (for use with context.Context).
 const ErrorCtxKey = caddy.CtxKey("handler_chain_error")
diff --git a/modules/caddyhttp/fileserver/caddyfile.go b/modules/caddyhttp/fileserver/caddyfile.go
index 3bd0ae472..8013fa24b 100644
--- a/modules/caddyhttp/fileserver/caddyfile.go
+++ b/modules/caddyhttp/fileserver/caddyfile.go
@@ -127,8 +127,7 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
 	// to the end of the query string.
 	makeRoute := func(try []string, writeURIAppend string) []httpcaddyfile.ConfigValue {
 		handler := rewrite.Rewrite{
-			Rehandle: true,
-			URI:      "{http.matchers.file.relative}{http.request.uri.query_string}" + writeURIAppend,
+			URI: "{http.matchers.file.relative}{http.request.uri.query_string}" + writeURIAppend,
 		}
 		matcherSet := caddy.ModuleMap{
 			"file": h.JSON(MatchFile{
diff --git a/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go b/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go
index 531704849..a9b02a91e 100644
--- a/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go
+++ b/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go
@@ -149,8 +149,7 @@ func parsePHPFastCGI(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error
 		}),
 	}
 	rewriteHandler := rewrite.Rewrite{
-		URI:      "{http.matchers.file.relative}{http.request.uri.query_string}",
-		Rehandle: true,
+		URI: "{http.matchers.file.relative}{http.request.uri.query_string}",
 	}
 	rewriteRoute := caddyhttp.Route{
 		MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet},
diff --git a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
index 9a424b0a9..aa0d1cd45 100644
--- a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+++ b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
@@ -102,11 +102,9 @@ func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) {
 		defer cancel()
 	}
 
-	// extract dial information from request (this
-	// should embedded by the reverse proxy)
+	// extract dial information from request (should have been embedded by the reverse proxy)
 	network, address := "tcp", r.URL.Host
-	if dialInfoVal := ctx.Value(reverseproxy.DialInfoCtxKey); dialInfoVal != nil {
-		dialInfo := dialInfoVal.(reverseproxy.DialInfo)
+	if dialInfo, ok := reverseproxy.GetDialInfo(ctx); ok {
 		network = dialInfo.Network
 		address = dialInfo.Address
 	}
diff --git a/modules/caddyhttp/reverseproxy/healthchecks.go b/modules/caddyhttp/reverseproxy/healthchecks.go
index 76ee9450a..a3b57e1e6 100644
--- a/modules/caddyhttp/reverseproxy/healthchecks.go
+++ b/modules/caddyhttp/reverseproxy/healthchecks.go
@@ -212,7 +212,9 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, host H
 	// attach dialing information to this request
 	ctx := context.Background()
 	ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, caddy.NewReplacer())
-	ctx = context.WithValue(ctx, DialInfoCtxKey, dialInfo)
+	ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]interface{}{
+		dialInfoVarKey: dialInfo,
+	})
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
 	if err != nil {
 		return fmt.Errorf("making request: %v", err)
diff --git a/modules/caddyhttp/reverseproxy/hosts.go b/modules/caddyhttp/reverseproxy/hosts.go
index 6e25c66d3..54de5a8a4 100644
--- a/modules/caddyhttp/reverseproxy/hosts.go
+++ b/modules/caddyhttp/reverseproxy/hosts.go
@@ -15,11 +15,13 @@
 package reverseproxy
 
 import (
+	"context"
 	"fmt"
 	"strconv"
 	"sync/atomic"
 
 	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
 )
 
 // Host represents a remote host which can be proxied to.
@@ -223,12 +225,19 @@ func fillDialInfo(upstream *Upstream, repl *caddy.Replacer) (DialInfo, error) {
 	}, nil
 }
 
-// DialInfoCtxKey is used to store a DialInfo
-// in a context.Context.
-const DialInfoCtxKey = caddy.CtxKey("dial_info")
+// GetDialInfo gets the upstream dialing info out of the context,
+// and returns true if there was a valid value; false otherwise.
+func GetDialInfo(ctx context.Context) (DialInfo, bool) {
+	dialInfo, ok := caddyhttp.GetVar(ctx, dialInfoVarKey).(DialInfo)
+	return dialInfo, ok
+}
 
 // hosts is the global repository for hosts that are
 // currently in use by active configuration(s). This
 // allows the state of remote hosts to be preserved
 // through config reloads.
 var hosts = caddy.NewUsagePool()
+
+// dialInfoVarKey is the key used for the variable that holds
+// the dial info for the upstream connection.
+const dialInfoVarKey = "reverse_proxy.dial_info"
diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go
index 75f278658..044bb8380 100644
--- a/modules/caddyhttp/reverseproxy/httptransport.go
+++ b/modules/caddyhttp/reverseproxy/httptransport.go
@@ -92,8 +92,7 @@ func (h *HTTPTransport) newTransport() (*http.Transport, error) {
 	rt := &http.Transport{
 		DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
 			// the proper dialing information should be embedded into the request's context
-			if dialInfoVal := ctx.Value(DialInfoCtxKey); dialInfoVal != nil {
-				dialInfo := dialInfoVal.(DialInfo)
+			if dialInfo, ok := GetDialInfo(ctx); ok {
 				network = dialInfo.Network
 				address = dialInfo.Address
 			}
diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go
index d0c483370..acb5213fd 100644
--- a/modules/caddyhttp/reverseproxy/reverseproxy.go
+++ b/modules/caddyhttp/reverseproxy/reverseproxy.go
@@ -320,8 +320,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
 		// attach to the request information about how to dial the upstream;
 		// this is necessary because the information cannot be sufficiently
 		// or satisfactorily represented in a URL
-		ctx := context.WithValue(r.Context(), DialInfoCtxKey, dialInfo)
-		r = r.WithContext(ctx)
+		caddyhttp.SetVar(r.Context(), dialInfoVarKey, dialInfo)
 
 		// set placeholders with information about this upstream
 		repl.Set("http.reverse_proxy.upstream.address", dialInfo.String())
diff --git a/modules/caddyhttp/rewrite/caddyfile.go b/modules/caddyhttp/rewrite/caddyfile.go
index 667431363..532df9a38 100644
--- a/modules/caddyhttp/rewrite/caddyfile.go
+++ b/modules/caddyhttp/rewrite/caddyfile.go
@@ -44,7 +44,6 @@ func parseCaddyfileRewrite(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler,
 			return nil, h.ArgErr()
 		}
 	}
-	rewr.Rehandle = true
 	return rewr, nil
 }
 
@@ -52,7 +51,7 @@ func parseCaddyfileRewrite(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler,
 //
 //     strip_prefix [<matcher>] <prefix>
 //
-// The request path will be stripped its prefix if it matches <prefix>.
+// The request path will be stripped the given prefix.
 func parseCaddyfileStripPrefix(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
 	var rewr Rewrite
 	for h.Next() {
@@ -71,7 +70,7 @@ func parseCaddyfileStripPrefix(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHand
 //
 //     strip_suffix [<matcher>] <suffix>
 //
-// The request path will be stripped its suffix if it matches <suffix>.
+// The request path will be stripped the given suffix.
 func parseCaddyfileStripSuffix(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
 	var rewr Rewrite
 	for h.Next() {
diff --git a/modules/caddyhttp/rewrite/rewrite.go b/modules/caddyhttp/rewrite/rewrite.go
index b875adbe9..09478b840 100644
--- a/modules/caddyhttp/rewrite/rewrite.go
+++ b/modules/caddyhttp/rewrite/rewrite.go
@@ -15,7 +15,6 @@
 package rewrite
 
 import (
-	"fmt"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -32,9 +31,6 @@ func init() {
 
 // Rewrite is a middleware which can rewrite HTTP requests.
 //
-// The Rehandle and HTTPRedirect properties are mutually exclusive
-// (you cannot both rehandle and issue a redirect).
-//
 // These rewrite properties are applied to a request in this order:
 // Method, URI, StripPathPrefix, StripPathSuffix, URISubstring.
 //
@@ -61,10 +57,6 @@ type Rewrite struct {
 	// given status code.
 	HTTPRedirect caddyhttp.WeakString `json:"http_redirect,omitempty"`
 
-	// If true, the request will sent for rehandling after rewriting
-	// only if anything about the request was changed.
-	Rehandle bool `json:"rehandle,omitempty"`
-
 	logger *zap.Logger
 }
 
@@ -82,14 +74,6 @@ func (rewr *Rewrite) Provision(ctx caddy.Context) error {
 	return nil
 }
 
-// Validate ensures rewr's configuration is valid.
-func (rewr Rewrite) Validate() error {
-	if rewr.HTTPRedirect != "" && rewr.Rehandle {
-		return fmt.Errorf("cannot be configured to both redirect externally and rehandle internally")
-	}
-	return nil
-}
-
 func (rewr Rewrite) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
 	repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
 
@@ -104,9 +88,6 @@ func (rewr Rewrite) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy
 			zap.String("method", r.Method),
 			zap.String("uri", r.RequestURI),
 		)
-		if rewr.Rehandle {
-			return caddyhttp.ErrRehandle
-		}
 		if rewr.HTTPRedirect != "" {
 			statusCode, err := strconv.Atoi(repl.ReplaceAll(rewr.HTTPRedirect.String(), ""))
 			if err != nil {
diff --git a/modules/caddyhttp/routes.go b/modules/caddyhttp/routes.go
index 9b2f84922..4ae9bcd08 100644
--- a/modules/caddyhttp/routes.go
+++ b/modules/caddyhttp/routes.go
@@ -28,13 +28,15 @@ import (
 // in a highly flexible and performant manner.
 type Route struct {
 	// Group is an optional name for a group to which this
-	// route belongs. If a route belongs to a group, only
-	// the first matching route in the group will be used.
+	// route belongs. Grouping a route makes it mutually
+	// exclusive with others in its group; if a route belongs
+	// to a group, only the first matching route in that group
+	// will be executed.
 	Group string `json:"group,omitempty"`
 
 	// The matcher sets which will be used to qualify this
-	// route for a request. Essentially the "if" statement
-	// of this route. Each matcher set is OR'ed, but matchers
+	// route for a request (essentially the "if" statement
+	// of this route). Each matcher set is OR'ed, but matchers
 	// within a set are AND'ed together.
 	MatcherSetsRaw RawMatcherSets `json:"match,omitempty" caddy:"namespace=http.matchers"`
 
@@ -87,12 +89,14 @@ type Route struct {
 	// If you think of routes in this way, it will be easy and even fun to solve the puzzle of writing correct routes.
 	HandlersRaw []json.RawMessage `json:"handle,omitempty" caddy:"namespace=http.handlers inline_key=handler"`
 
-	// If true, no more routes will be executed after this one, even if they matched.
+	// If true, no more routes will be executed after this one.
 	Terminal bool `json:"terminal,omitempty"`
 
 	// decoded values
 	MatcherSets MatcherSets         `json:"-"`
 	Handlers    []MiddlewareHandler `json:"-"`
+
+	middleware []Middleware
 }
 
 // Empty returns true if the route has all zero/default values.
@@ -111,9 +115,9 @@ type RouteList []Route
 
 // Provision sets up all the routes by loading the modules.
 func (routes RouteList) Provision(ctx caddy.Context) error {
-	for i, route := range routes {
+	for i := range routes {
 		// matchers
-		matchersIface, err := ctx.LoadModule(&route, "MatcherSetsRaw")
+		matchersIface, err := ctx.LoadModule(&routes[i], "MatcherSetsRaw")
 		if err != nil {
 			return fmt.Errorf("loading matchers in route %d: %v", i, err)
 		}
@@ -123,93 +127,115 @@ func (routes RouteList) Provision(ctx caddy.Context) error {
 		}
 
 		// handlers
-		handlersIface, err := ctx.LoadModule(&route, "HandlersRaw")
+		handlersIface, err := ctx.LoadModule(&routes[i], "HandlersRaw")
 		if err != nil {
 			return fmt.Errorf("loading handler modules in route %d: %v", i, err)
 		}
 		for _, handler := range handlersIface.([]interface{}) {
 			routes[i].Handlers = append(routes[i].Handlers, handler.(MiddlewareHandler))
 		}
+
+		// pre-compile the middleware handler chain
+		for _, midhandler := range routes[i].Handlers {
+			routes[i].middleware = append(routes[i].middleware, wrapMiddleware(midhandler))
+		}
 	}
+
 	return nil
 }
 
-// BuildCompositeRoute creates a chain of handlers by
-// applying all of the matching routes.
-func (routes RouteList) BuildCompositeRoute(req *http.Request) Handler {
-	if len(routes) == 0 {
-		return emptyHandler
-	}
-
+// Compile prepares a middleware chain from the route list.
+// This should only be done once: after all the routes have
+// been provisioned, and before serving requests.
+func (routes RouteList) Compile() Handler {
 	var mid []Middleware
-	groups := make(map[string]struct{})
-
 	for _, route := range routes {
-		// route must match at least one of the matcher sets
-		if !route.MatcherSets.AnyMatch(req) {
-			continue
-		}
-
-		// if route is part of a group, ensure only the
-		// first matching route in the group is applied
-		if route.Group != "" {
-			_, ok := groups[route.Group]
-			if ok {
-				// this group has already been satisfied
-				// by a matching route
-				continue
-			}
-			// this matching route satisfies the group
-			groups[route.Group] = struct{}{}
-		}
-
-		// apply the rest of the route
-		for _, mh := range route.Handlers {
-			// we have to be sure to wrap mh outside
-			// of our current stack frame so that the
-			// reference to this mh isn't overwritten
-			// on the next iteration, leaving the last
-			// middleware in the chain as the ONLY
-			// middleware in the chain!
-			mid = append(mid, wrapMiddleware(mh))
-		}
-
-		// if this route is supposed to be last, don't
-		// compile any more into the chain
-		if route.Terminal {
-			break
-		}
+		mid = append(mid, wrapRoute(route))
 	}
-
-	// build the middleware chain, with the responder at the end
 	stack := emptyHandler
 	for i := len(mid) - 1; i >= 0; i-- {
 		stack = mid[i](stack)
 	}
-
 	return stack
 }
 
-// wrapMiddleware wraps m such that it can be correctly
-// appended to a list of middleware. We can't do this
-// directly in a loop because it relies on a reference
-// to mh not changing until the execution of its handler,
-// which is deferred by multiple func closures. In other
-// words, we need to pull this particular MiddlewareHandler
+// wrapRoute wraps route with a middleware and handler so that it can
+// be chained in and defer evaluation of its matchers to request-time.
+// Like wrapMiddleware, it is vital that this wrapping takes place in
+// its own stack frame so as to not overwrite the reference to the
+// intended route by looping and changing the reference each time.
+func wrapRoute(route Route) Middleware {
+	return func(next Handler) Handler {
+		return HandlerFunc(func(rw http.ResponseWriter, req *http.Request) error {
+			// copy the next handler (it's an interface, so it's just
+			// a very lightweight copy of a pointer); this is important
+			// because this is a closure to the func below, which
+			// re-assigns the value as it compiles the middleware stack;
+			// if we don't make this copy, we'd affect the underlying
+			// pointer for all future request (yikes); we could
+			// alternatively solve this by moving the func below out of
+			// this closure and into a standalone package-level func,
+			// but I just thought this made more sense
+			nextCopy := next
+
+			// route must match at least one of the matcher sets
+			if !route.MatcherSets.AnyMatch(req) {
+				return nextCopy.ServeHTTP(rw, req)
+			}
+
+			// if route is part of a group, ensure only the
+			// first matching route in the group is applied
+			if route.Group != "" {
+				groups := req.Context().Value(routeGroupCtxKey).(map[string]struct{})
+
+				if _, ok := groups[route.Group]; ok {
+					// this group has already been
+					// satisfied by a matching route
+					return nextCopy.ServeHTTP(rw, req)
+				}
+
+				// this matching route satisfies the group
+				groups[route.Group] = struct{}{}
+			}
+
+			// make terminal routes terminate
+			if route.Terminal {
+				nextCopy = emptyHandler
+			}
+
+			// compile this route's handler stack
+			for i := len(route.middleware) - 1; i >= 0; i-- {
+				nextCopy = route.middleware[i](nextCopy)
+			}
+
+			return nextCopy.ServeHTTP(rw, req)
+		})
+	}
+}
+
+// wrapMiddleware wraps mh such that it can be correctly
+// appended to a list of middleware in preparation for
+// compiling into a handler chain. We can't do this inline
+// inside a loop, because it relies on a reference to mh
+// not changing until the execution of its handler (which
+// is deferred by multiple func closures). In other words,
+// we need to pull this particular MiddlewareHandler
 // pointer into its own stack frame to preserve it so it
 // won't be overwritten in future loop iterations.
 func wrapMiddleware(mh MiddlewareHandler) Middleware {
-	return func(next HandlerFunc) HandlerFunc {
-		return func(w http.ResponseWriter, r *http.Request) error {
-			// TODO: We could wait to evaluate matchers here, just eval
-			// the next matcher and choose the next route...
+	return func(next Handler) Handler {
+		// copy the next handler (it's an interface, so it's
+		// just a very lightweight copy of a pointer); this
+		// is a safeguard against the handler changing the
+		// value, which could affect future requests (yikes)
+		nextCopy := next
 
-			// TODO: This is where request tracing could be implemented; also
-			// see below to trace the responder as well
-			// TODO: Trace a diff of the request, would be cool too! see what changed since the last middleware (host, headers, URI...)
+		return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
+			// TODO: This is where request tracing could be implemented
+			// TODO: Trace a diff of the request, would be cool too... see what changed since the last middleware (host, headers, URI...)
 			// TODO: see what the std lib gives us in terms of stack tracing too
-			return mh.ServeHTTP(w, r, next)
-		}
+			return mh.ServeHTTP(w, r, nextCopy)
+		})
 	}
 }
 
@@ -219,7 +245,7 @@ func wrapMiddleware(mh MiddlewareHandler) Middleware {
 type MatcherSet []RequestMatcher
 
 // Match returns true if the request matches all
-// matchers in mset.
+// matchers in mset or if there are no matchers.
 func (mset MatcherSet) Match(r *http.Request) bool {
 	for _, m := range mset {
 		if !m.Match(r) {
@@ -265,3 +291,5 @@ func (ms *MatcherSets) FromInterface(matcherSets interface{}) error {
 	}
 	return nil
 }
+
+var routeGroupCtxKey = caddy.CtxKey("route_group")
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index c333991b7..ce61b13b0 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -61,10 +61,12 @@ type Server struct {
 	MaxHeaderBytes int `json:"max_header_bytes,omitempty"`
 
 	// Routes describes how this server will handle requests.
-	// When a request comes in, each route's matchers will
-	// be evaluated against the request, and matching routes
-	// will be compiled into a middleware chain in the order
-	// in which they appear in the list.
+	// Routes are executed sequentially. First a route's matchers
+	// are evaluated, then its grouping. If it matches and has
+	// not been mutually-excluded by its grouping, then its
+	// handlers are executed sequentially. The sequence of invoked
+	// handlers comprises a compiled middleware chain that flows
+	// from each matching route and its handlers to the next.
 	Routes RouteList `json:"routes,omitempty"`
 
 	// Errors is how this server will handle errors returned from any
@@ -86,11 +88,6 @@ type Server struct {
 	// only on the HTTPS port.
 	AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
 
-	// MaxRehandles is the maximum number of times to allow a
-	// request to be rehandled, to prevent accidental infinite
-	// loops. Default: 1.
-	MaxRehandles *int `json:"max_rehandles,omitempty"`
-
 	// If true, will require that a request's Host header match
 	// the value of the ServerName sent by the client's TLS
 	// ClientHello; often a necessary safeguard when using TLS
@@ -105,6 +102,9 @@ type Server struct {
 	// This field is not subject to compatibility promises.
 	ExperimentalHTTP3 bool `json:"experimental_http3,omitempty"`
 
+	primaryHandlerChain Handler
+	errorHandlerChain   Handler
+
 	tlsApp       *caddytls.TLS
 	logger       *zap.Logger
 	accessLogger *zap.Logger
@@ -129,6 +129,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
 	ctx = context.WithValue(ctx, ServerCtxKey, s)
 	ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]interface{}))
+	ctx = context.WithValue(ctx, routeGroupCtxKey, make(map[string]struct{}))
 	var url2 url.URL // avoid letting this escape to the heap
 	ctx = context.WithValue(ctx, OriginalRequestCtxKey, originalRequest(r, &url2))
 	r = r.WithContext(ctx)
@@ -137,22 +138,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// anymore, finish setting up the replacer
 	addHTTPVarsToReplacer(repl, r, w)
 
-	loggableReq := LoggableHTTPRequest{r}
+	// encode the request for logging purposes before
+	// it enters any handler chain; this is necessary
+	// to capture the original request in case it gets
+	// modified during handling
+	loggableReq := zap.Object("request", LoggableHTTPRequest{r})
 	errLog := s.errorLogger.With(
-		// encode the request for logging purposes before
-		// it enters any handler chain; this is necessary
-		// to capture the original request in case it gets
-		// modified during handling
-		zap.Object("request", loggableReq),
+		loggableReq,
 	)
 
 	if s.accessLogger != nil {
 		wrec := NewResponseRecorder(w, nil, nil)
 		w = wrec
-		accLog := s.accessLogger.With(
-			// capture the original version of the request
-			zap.Object("request", loggableReq),
-		)
+
+		// capture the original version of the request
+		accLog := s.accessLogger.With(loggableReq)
+
 		start := time.Now()
 		defer func() {
 			latency := time.Since(start)
@@ -187,8 +188,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// build and execute the primary handler chain
-	err := s.executeCompositeRoute(w, r, s.Routes)
+	// execute the primary handler chain
+	err := s.primaryHandlerChain.ServeHTTP(w, r)
 	if err != nil {
 		// prepare the error log
 		logger := errLog
@@ -204,7 +205,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 		if s.Errors != nil && len(s.Errors.Routes) > 0 {
 			// execute user-defined error handling route
-			err2 := s.executeCompositeRoute(w, r, s.Errors.Routes)
+			err2 := s.errorHandlerChain.ServeHTTP(w, r)
 			if err2 == nil {
 				// user's error route handled the error response
 				// successfully, so now just log the error
@@ -229,39 +230,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// executeCompositeRoute compiles a composite route from routeList and executes
-// it using w and r. This function handles the sentinel ErrRehandle error value,
-// which reprocesses requests through the stack again. Any error value returned
-// from this function would be an actual error that needs to be handled.
-func (s *Server) executeCompositeRoute(w http.ResponseWriter, r *http.Request, routeList RouteList) error {
-	maxRehandles := 0
-	if s.MaxRehandles != nil {
-		maxRehandles = *s.MaxRehandles
-	}
-	var err error
-	for i := -1; i <= maxRehandles; i++ {
-		// we started the counter at -1 because we
-		// always want to run this at least once
-
-		// the purpose of rehandling is often to give
-		// matchers a chance to re-evaluate on the
-		// changed version of the request, so compile
-		// the handler stack anew in each iteration
-		stack := routeList.BuildCompositeRoute(r)
-		stack = s.wrapPrimaryRoute(stack)
-
-		// only loop if rehandling is required
-		err = stack.ServeHTTP(w, r)
-		if err != ErrRehandle {
-			break
-		}
-		if i >= maxRehandles-1 {
-			return fmt.Errorf("too many rehandles")
-		}
-	}
-	return err
-}
-
 // wrapPrimaryRoute wraps stack (a compiled middleware handler chain)
 // in s.enforcementHandler which performs crucial security checks, etc.
 func (s *Server) wrapPrimaryRoute(stack Handler) Handler {
diff --git a/modules/caddyhttp/starlarkmw/starlarkmw.go b/modules/caddyhttp/starlarkmw/starlarkmw.go
index 47e335dbe..bd42e1300 100644
--- a/modules/caddyhttp/starlarkmw/starlarkmw.go
+++ b/modules/caddyhttp/starlarkmw/starlarkmw.go
@@ -59,7 +59,7 @@ func (s *StarlarkMW) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	// dynamically build middleware chain for each request
-	stack := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
+	var stack caddyhttp.Handler = caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
 		wr, err := convert.ToValue(w)
 		if err != nil {
 			return fmt.Errorf("cannot convert response writer to starlark value")
@@ -83,10 +83,10 @@ func (s *StarlarkMW) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
 	// TODO :- make middlewareResponseWriter exported and wrap w with that
 	var mid []caddyhttp.Middleware
 	for _, m := range s.execute.Modules {
-		mid = append(mid, func(next caddyhttp.HandlerFunc) caddyhttp.HandlerFunc {
-			return func(w http.ResponseWriter, r *http.Request) error {
+		mid = append(mid, func(next caddyhttp.Handler) caddyhttp.Handler {
+			return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
 				return m.Instance.ServeHTTP(w, r, next)
-			}
+			})
 		})
 	}
 
@@ -96,7 +96,7 @@ func (s *StarlarkMW) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
 
 	s.execute.Modules = nil
 
-	return stack(w, r)
+	return stack.ServeHTTP(w, r)
 }
 
 // Cleanup cleans up any modules loaded during the creation of a starlark route.
diff --git a/modules/caddyhttp/subroute.go b/modules/caddyhttp/subroute.go
index 13453a562..a3248738f 100644
--- a/modules/caddyhttp/subroute.go
+++ b/modules/caddyhttp/subroute.go
@@ -27,13 +27,12 @@ func init() {
 
 // Subroute implements a handler that compiles and executes routes.
 // This is useful for a batch of routes that all inherit the same
-// matchers, or for routes with matchers that must be have deferred
-// evaluation (e.g. if they depend on placeholders created by other
-// matchers that need to be evaluated first).
+// matchers, or for multiple routes that should be treated as a
+// single route.
 //
-// You can also use subroutes to handle errors from specific handlers.
-// First the primary Routes will be executed, and if they return an
-// error, the Errors routes will be executed; in that case, an error
+// You can also use subroutes to handle errors from its handlers.
+// First the primary routes will be executed, and if they return an
+// error, the errors routes will be executed; in that case, an error
 // is only returned to the entry point at the server if there is an
 // additional error returned from the errors routes.
 type Subroute struct {
@@ -71,11 +70,11 @@ func (sr *Subroute) Provision(ctx caddy.Context) error {
 }
 
 func (sr *Subroute) ServeHTTP(w http.ResponseWriter, r *http.Request, _ Handler) error {
-	subroute := sr.Routes.BuildCompositeRoute(r)
+	subroute := sr.Routes.Compile()
 	err := subroute.ServeHTTP(w, r)
 	if err != nil && sr.Errors != nil {
 		r = sr.Errors.WithError(r, err)
-		errRoute := sr.Errors.Routes.BuildCompositeRoute(r)
+		errRoute := sr.Errors.Routes.Compile()
 		return errRoute.ServeHTTP(w, r)
 	}
 	return err
diff --git a/modules/caddyhttp/vars.go b/modules/caddyhttp/vars.go
index cd0d8d001..d6c5a6609 100644
--- a/modules/caddyhttp/vars.go
+++ b/modules/caddyhttp/vars.go
@@ -15,6 +15,7 @@
 package caddyhttp
 
 import (
+	"context"
 	"net/http"
 
 	"github.com/caddyserver/caddy/v2"
@@ -75,6 +76,27 @@ func (m VarsMatcher) Match(r *http.Request) bool {
 	return true
 }
 
+// GetVar gets a value out of the context's variable table by key.
+// If the key does not exist, the return value will be nil.
+func GetVar(ctx context.Context, key string) interface{} {
+	varMap, ok := ctx.Value(VarsCtxKey).(map[string]interface{})
+	if !ok {
+		return nil
+	}
+	return varMap[key]
+}
+
+// SetVar sets a value in the context's variable table with
+// the given key. It overwrites any previous value with the
+// same key.
+func SetVar(ctx context.Context, key string, value interface{}) {
+	varMap, ok := ctx.Value(VarsCtxKey).(map[string]interface{})
+	if !ok {
+		return
+	}
+	varMap[key] = value
+}
+
 // Interface guards
 var (
 	_ MiddlewareHandler = (*VarsMiddleware)(nil)