Repository: quic-go/quic-go
Pull request #5227 (2025-06-13 08:08:26)

optimize parsing logic for STREAM, DATAGRAM and ACK frames
I’m not fully finished cleaning up my code yet, but I’d appreciate an early opinion on a tradeoff I've made.

In Conn.handleFrames, I intentionally avoided using the wire.Frame interface, especially on the hot path, to improve performance. This decision required duplicating or even triplicating the frame handling logic—now there's similar logic separately for StreamFrame, AckFrame, and all other frames handled through the interface.

Given that the frame handling logic (approximately lines 1475–1499 of `connection.go`) has multiple outcomes (continue, break, error), extracting it into a separate function seems complicated and might hurt readability or performance (we would need to handle these cases outside of it anyway).

I'm not particularly happy with the resulting duplication, but other solutions seem to require using interfaces again, impacting performance negatively.

Could you take a quick preliminary look at Conn.handleFrames and share if you notice something I might have missed?

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
Attention: Patch coverage is `50.30864%` with `161 lines` in your changes missing coverage. Please review.
> Project coverage is 83.21%. Comparing base [(`d33d293`)](https://app.codecov.io/gh/quic-go/quic-go/commit/d33d293fd2ea5f8e399146f9237d2b179c90f1f1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`cb0ca3a`)](https://app.codecov.io/gh/quic-go/quic-go/commit/cb0ca3a394b83fb2ea986c9e7a766b677d7550cd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

| [Files with missing lines](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) | Patch % | Lines |
|---|---|---|
| [connection.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&filepath=connection.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-Y29ubmVjdGlvbi5nbw==) | 31.40% | [113 Missing and 5 partials :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [internal/wire/frame\_parser.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&filepath=internal%2Fwire%2Fframe_parser.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aW50ZXJuYWwvd2lyZS9mcmFtZV9wYXJzZXIuZ28=) | 76.06% | [16 Missing and 1 partial :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [internal/wire/frame\_type.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&filepath=internal%2Fwire%2Fframe_type.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aW50ZXJuYWwvd2lyZS9mcmFtZV90eXBlLmdv) | 40.00% | [12 Missing :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [internal/ackhandler/ack\_eliciting.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&filepath=internal%2Fackhandler%2Fack_eliciting.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aW50ZXJuYWwvYWNraGFuZGxlci9hY2tfZWxpY2l0aW5nLmdv) | 0.00% | [8 Missing :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [internal/wire/frame.go](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&filepath=internal%2Fwire%2Fframe.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aW50ZXJuYWwvd2lyZS9mcmFtZS5nbw==) | 0.00% | [6 Missing :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #5227      +/-   ##
==========================================
- Coverage   84.10%   83.21%   -0.89%     
==========================================
  Files         154      151       -3     
  Lines       17449    17445       -4     
==========================================
- Hits        14674    14516     -158     
- Misses       2206     2358     +152     
- Partials      569      571       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/5227?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

jannis-seemann: **TODO:**
- remove ParseFrame and parseFrame
- Adjusts tests
- Write tests for FrameType
- Evaluate if we want to turn the FrameType into an iota, so that it doesn't map the RFC9000 types for frames anymore. But then, we need to specify all the types for all possible StreamFrames, they are currently not listed in the enum at all. Also, byte(frameType) may still be used in the code, we need to check and refactor this then accordingly. 
- Evaluate what we do with `ErrUnknownFrameType`, where to store it
- Go through all the changes, and double check everything 
jannis-seemann: @marten-seemann Please review.

The final results are in:

```
goos: darwin
goarch: arm64
pkg: github.com/quic-go/quic-go/internal/wire
cpu: Apple M4 Max
               │   old.out   │              new.out               │
               │   sec/op    │   sec/op     vs base               │
ParseFrames-16   1.042µ ± 2%   1.020µ ± 2%  -2.11% (p=0.000 n=10)

               │   old.out    │             new.out              │
               │     B/op     │     B/op      vs base            │
ParseFrames-16   7.967Ki ± 0%   7.967Ki ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

               │  old.out   │            new.out             │
               │ allocs/op  │ allocs/op   vs base            │
ParseFrames-16   16.00 ± 0%   16.00 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal
```
jannis-seemann: So, I performed these changes, but we now lack the performance improvement that we were looking in the first place. I guess it comes from the associated logic of wrapping the error. This had technically been done in the intiail benchmark of the code, so in my version, this should also happen. 

Thus, unless you see something that I'm missing, I need to conclude that the new version is not faster than the old, and we should not merge this PR. 

```
goos: darwin
goarch: arm64
pkg: github.com/quic-go/quic-go/internal/wire
cpu: Apple M4 Max
               │   old.out   │            new.out            │
               │   sec/op    │   sec/op     vs base          │
ParseFrames-16   1.060µ ± 1%   1.061µ ± 0%  ~ (p=0.868 n=10)

               │   old.out    │             new.out              │
               │     B/op     │     B/op      vs base            │
ParseFrames-16   7.967Ki ± 0%   7.967Ki ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

               │  old.out   │            new.out             │
               │ allocs/op  │ allocs/op   vs base            │
ParseFrames-16   16.00 ± 0%   16.00 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal
```
jannis-seemann: Turns out we didn't benchmark our code, most of the time was spend in the garbage collector, as we didn't put back the StreamFrame to be released. I'll now further investigate this.
jannis-seemann: So, I have now debugged this, and added a few more benchmarks. The idea is that now we have benchmarks for each type, and also another test to ensure that we have 0 allocations for AckFrame and StreamFrame. 

Here are the results. While the new code is 5% slower for everything but StreamFrame / AckFrame / DatagramFrame, it is 5% faster for StreamFrame and for the AckFrame. Given the high occurence of these frame types, I believe the PR is worth it :)

```
goos: darwin
goarch: arm64
pkg: github.com/quic-go/quic-go/internal/wire
cpu: Apple M4 Max
                              │   old.out   │              new.out               │
                              │   sec/op    │   sec/op     vs base               │
ParseExtendedHeader-16          42.45n ± 0%   42.52n ± 1%       ~ (p=0.119 n=20)
ParseStreamAndACK-16            46.89n ± 0%   44.34n ± 0%  -5.43% (p=0.000 n=20)
ParseOtherFrames-16             112.8n ± 0%   117.8n ± 1%  +4.43% (p=0.000 n=20)
ParseAckFrame-16                263.2n ± 0%   249.3n ± 0%  -5.30% (p=0.000 n=20)
ParseStreamFrame-16             189.4n ± 0%   180.4n ± 0%  -4.73% (p=0.000 n=20)
ParseDatagramFrame-16           440.1n ± 1%   437.1n ± 1%  -0.66% (p=0.003 n=20)
ParseInitial/without_token-16   30.43n ± 0%   30.35n ± 1%       ~ (p=0.673 n=20)
ParseInitial/with_token-16      38.27n ± 0%   38.29n ± 0%       ~ (p=0.995 n=20)
ParseRetry-16                   36.61n ± 0%   36.80n ± 0%  +0.51% (p=0.015 n=20)
geomean                         84.08n        82.99n       -1.30%

                              │    old.out     │                new.out                │
                              │      B/op      │     B/op      vs base                 │
ParseExtendedHeader-16            224.0 ± 0%       224.0 ± 0%       ~ (p=1.000 n=20) ¹
ParseStreamAndACK-16              0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseOtherFrames-16               232.0 ± 0%       232.0 ± 0%       ~ (p=1.000 n=20) ¹
ParseAckFrame-16                  0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseStreamFrame-16               0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseDatagramFrame-16           2.344Ki ± 0%     2.344Ki ± 0%       ~ (p=1.000 n=20) ¹
ParseInitial/without_token-16     96.00 ± 0%       96.00 ± 0%       ~ (p=1.000 n=20) ¹
ParseInitial/with_token-16        128.0 ± 0%       128.0 ± 0%       ~ (p=1.000 n=20) ¹
ParseRetry-16                     144.0 ± 0%       144.0 ± 0%       ~ (p=1.000 n=20) ¹
geomean                                      ²                 +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                              │   old.out    │               new.out               │
                              │  allocs/op   │ allocs/op   vs base                 │
ParseExtendedHeader-16          2.000 ± 0%     2.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseStreamAndACK-16            0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseOtherFrames-16             6.000 ± 0%     6.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseAckFrame-16                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseStreamFrame-16             0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseDatagramFrame-16           20.00 ± 0%     20.00 ± 0%       ~ (p=1.000 n=20) ¹
ParseInitial/without_token-16   1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseInitial/with_token-16      2.000 ± 0%     2.000 ± 0%       ~ (p=1.000 n=20) ¹
ParseRetry-16                   2.000 ± 0%     2.000 ± 0%       ~ (p=1.000 n=20) ¹
geomean                                    ²               +0.00%                ²
```
jannis-seemann: Here we go :)

Patch:
diff --git a/connection.go b/connection.go
index ce565aa0..2251af08 100644
--- a/connection.go
+++ b/connection.go
@@ -1437,39 +1437,100 @@ func (c *Conn) handleFrames(
 	}
 	handshakeWasComplete := c.handshakeComplete
 	var handleErr error
+	var skipHandling bool
+
 	for len(data) > 0 {
-		l, frame, err := c.frameParser.ParseNext(data, encLevel, c.version)
+		frameType, l, err := c.frameParser.ParseType(data, encLevel)
 		if err != nil {
+			// The frame parser skips over PADDING frames, and returns an io.EOF if the PADDING
+			// frames were the last frames in this packet.
+			if err == io.EOF {
+				break
+			}
 			return false, false, nil, err
 		}
 		data = data[l:]
-		if frame == nil {
-			break
-		}
-		if ackhandler.IsFrameAckEliciting(frame) {
+
+		if ackhandler.IsFrameTypeAckEliciting(frameType) {
 			isAckEliciting = true
 		}
-		if !wire.IsProbingFrame(frame) {
+		if !wire.IsProbingFrameType(frameType) {
 			isNonProbing = true
 		}
-		if log != nil {
-			frames = append(frames, toLoggingFrame(frame))
-		}
-		// An error occurred handling a previous frame.
-		// Don't handle the current frame.
-		if handleErr != nil {
-			continue
-		}
-		pc, err := c.handleFrame(frame, encLevel, destConnID, rcvTime)
-		if err != nil {
-			if log == nil {
+
+		// We're inlining common cases, to avoid using interfaces
+		// Fast path: STREAM, DATAGRAM and ACK
+		if frameType.IsStreamFrameType() {
+			streamFrame, l, err := c.frameParser.ParseStreamFrame(frameType, data, c.version)
+			if err != nil {
+				return false, false, nil, err
+			}
+			data = data[l:]
+
+			if log != nil {
+				frames = append(frames, toLoggingFrame(streamFrame))
+			}
+			// an error occurred handling a previous frame, don't handle the current frame
+			if skipHandling {
+				continue
+			}
+			handleErr = c.streamsMap.HandleStreamFrame(streamFrame, rcvTime)
+		} else if frameType.IsAckFrameType() {
+			ackFrame, l, err := c.frameParser.ParseAckFrame(frameType, data, encLevel, c.version)
+			if err != nil {
+				return false, false, nil, err
+			}
+			data = data[l:]
+			if log != nil {
+				frames = append(frames, toLoggingFrame(ackFrame))
+			}
+			// an error occurred handling a previous frame, don't handle the current frame
+			if skipHandling {
+				continue
+			}
+			handleErr = c.handleAckFrame(ackFrame, encLevel, rcvTime)
+		} else if frameType.IsDatagramFrameType() {
+			datagramFrame, l, err := c.frameParser.ParseDatagramFrame(frameType, data, c.version)
+			if err != nil {
+				return false, false, nil, err
+			}
+			data = data[l:]
+
+			if log != nil {
+				frames = append(frames, toLoggingFrame(datagramFrame))
+			}
+			// an error occurred handling a previous frame, don't handle the current frame
+			if skipHandling {
+				continue
+			}
+			handleErr = c.handleDatagramFrame(datagramFrame)
+		} else {
+			frame, l, err := c.frameParser.ParseLessCommonFrame(frameType, data, c.version)
+			if err != nil {
 				return false, false, nil, err
 			}
-			// If we're logging, we need to keep parsing (but not handling) all frames.
+			data = data[l:]
+
+			if log != nil {
+				frames = append(frames, toLoggingFrame(frame))
+			}
+			// an error occurred handling a previous frame, don't handle the current frame
+			if skipHandling {
+				continue
+			}
+			pc, err := c.handleFrame(frame, encLevel, destConnID, rcvTime)
+			if pc != nil {
+				pathChallenge = pc
+			}
 			handleErr = err
 		}
-		if pc != nil {
-			pathChallenge = pc
+
+		if handleErr != nil {
+			// if we're logging, we need to keep parsing (but not handling) all frames
+			skipHandling = true
+			if log == nil {
+				return false, false, nil, handleErr
+			}
 		}
 	}
 
@@ -1503,10 +1564,6 @@ func (c *Conn) handleFrame(
 	switch frame := f.(type) {
 	case *wire.CryptoFrame:
 		err = c.handleCryptoFrame(frame, encLevel, rcvTime)
-	case *wire.StreamFrame:
-		err = c.streamsMap.HandleStreamFrame(frame, rcvTime)
-	case *wire.AckFrame:
-		err = c.handleAckFrame(frame, encLevel, rcvTime)
 	case *wire.ConnectionCloseFrame:
 		err = c.handleConnectionCloseFrame(frame)
 	case *wire.ResetStreamFrame:
@@ -1537,8 +1594,6 @@ func (c *Conn) handleFrame(
 		err = c.connIDGenerator.Retire(frame.SequenceNumber, destConnID, rcvTime.Add(3*c.rttStats.PTO(false)))
 	case *wire.HandshakeDoneFrame:
 		err = c.handleHandshakeDoneFrame(rcvTime)
-	case *wire.DatagramFrame:
-		err = c.handleDatagramFrame(frame)
 	default:
 		err = fmt.Errorf("unexpected frame type: %s", reflect.ValueOf(&frame).Elem().Type().Name())
 	}
diff --git a/fuzzing/frames/fuzz.go b/fuzzing/frames/fuzz.go
index e7b0247a..c8200265 100644
--- a/fuzzing/frames/fuzz.go
+++ b/fuzzing/frames/fuzz.go
@@ -2,6 +2,7 @@ package frames
 
 import (
 	"fmt"
+	"io"
 
 	"github.com/quic-go/quic-go/internal/ackhandler"
 	"github.com/quic-go/quic-go/internal/protocol"
@@ -41,15 +42,32 @@ func Fuzz(data []byte) int {
 	var b []byte
 	for len(data) > 0 {
 		initialLen := len(data)
-		l, f, err := parser.ParseNext(data, encLevel, version)
+		frameType, l, err := parser.ParseType(data, encLevel)
 		if err != nil {
+			if err == io.EOF { // the last frame was a PADDING frame
+				continue
+			}
 			break
 		}
+
 		data = data[l:]
 		numFrames++
-		if f == nil { // PADDING frame
-			continue
+
+		var f wire.Frame
+		switch {
+		case frameType.IsStreamFrameType():
+			f, l, err = parser.ParseStreamFrame(frameType, data, version)
+		case frameType == wire.FrameTypeAck || frameType == wire.FrameTypeAckECN:
+			f, l, err = parser.ParseAckFrame(frameType, data, encLevel, version)
+		case frameType == wire.FrameTypeDatagramNoLength || frameType == wire.FrameTypeDatagramWithLength:
+			f, l, err = parser.ParseDatagramFrame(frameType, data, version)
+		default:
+			f, l, err = parser.ParseLessCommonFrame(frameType, data, version)
 		}
+		if err != nil {
+			break
+		}
+		data = data[l:]
 		wire.IsProbingFrame(f)
 		ackhandler.IsFrameAckEliciting(f)
 		// We accept empty STREAM frames, but we don't write them.
diff --git a/internal/ackhandler/ack_eliciting.go b/internal/ackhandler/ack_eliciting.go
index 34506b12..8d843612 100644
--- a/internal/ackhandler/ack_eliciting.go
+++ b/internal/ackhandler/ack_eliciting.go
@@ -2,6 +2,19 @@ package ackhandler
 
 import "github.com/quic-go/quic-go/internal/wire"
 
+// IsFrameTypeAckEliciting returns true if the frame is ack-eliciting.
+func IsFrameTypeAckEliciting(t wire.FrameType) bool {
+	//nolint:exhaustive // The default case catches the rest.
+	switch t {
+	case wire.FrameTypeAck, wire.FrameTypeAckECN:
+		return false
+	case wire.FrameTypeConnectionClose, wire.FrameTypeApplicationClose:
+		return false
+	default:
+		return true
+	}
+}
+
 // IsFrameAckEliciting returns true if the frame is ack-eliciting.
 func IsFrameAckEliciting(f wire.Frame) bool {
 	_, isAck := f.(*wire.AckFrame)
diff --git a/internal/wire/ack_frame.go b/internal/wire/ack_frame.go
index 8befef4f..68bebfa7 100644
--- a/internal/wire/ack_frame.go
+++ b/internal/wire/ack_frame.go
@@ -21,9 +21,9 @@ type AckFrame struct {
 }
 
 // parseAckFrame reads an ACK frame
-func parseAckFrame(frame *AckFrame, b []byte, typ uint64, ackDelayExponent uint8, _ protocol.Version) (int, error) {
+func parseAckFrame(frame *AckFrame, b []byte, typ FrameType, ackDelayExponent uint8, _ protocol.Version) (int, error) {
 	startLen := len(b)
-	ecn := typ == ackECNFrameType
+	ecn := typ == FrameTypeAckECN
 
 	la, l, err := quicvarint.Parse(b)
 	if err != nil {
@@ -122,9 +122,9 @@ func parseAckFrame(frame *AckFrame, b []byte, typ uint64, ackDelayExponent uint8
 func (f *AckFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
 	hasECN := f.ECT0 > 0 || f.ECT1 > 0 || f.ECNCE > 0
 	if hasECN {
-		b = append(b, ackECNFrameType)
+		b = append(b, byte(FrameTypeAckECN))
 	} else {
-		b = append(b, ackFrameType)
+		b = append(b, byte(FrameTypeAck))
 	}
 	b = quicvarint.Append(b, uint64(f.LargestAcked()))
 	b = quicvarint.Append(b, encodeAckDelay(f.DelayTime))
diff --git a/internal/wire/connection_close_frame.go b/internal/wire/connection_close_frame.go
index be11a1b2..6c71aab6 100644
--- a/internal/wire/connection_close_frame.go
+++ b/internal/wire/connection_close_frame.go
@@ -15,9 +15,9 @@ type ConnectionCloseFrame struct {
 	ReasonPhrase       string
 }
 
-func parseConnectionCloseFrame(b []byte, typ uint64, _ protocol.Version) (*ConnectionCloseFrame, int, error) {
+func parseConnectionCloseFrame(b []byte, typ FrameType, _ protocol.Version) (*ConnectionCloseFrame, int, error) {
 	startLen := len(b)
-	f := &ConnectionCloseFrame{IsApplicationError: typ == applicationCloseFrameType}
+	f := &ConnectionCloseFrame{IsApplicationError: typ == FrameTypeApplicationClose}
 	ec, l, err := quicvarint.Parse(b)
 	if err != nil {
 		return nil, 0, replaceUnexpectedEOF(err)
@@ -60,9 +60,9 @@ func (f *ConnectionCloseFrame) Length(protocol.Version) protocol.ByteCount {
 
 func (f *ConnectionCloseFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
 	if f.IsApplicationError {
-		b = append(b, applicationCloseFrameType)
+		b = append(b, byte(FrameTypeApplicationClose))
 	} else {
-		b = append(b, connectionCloseFrameType)
+		b = append(b, byte(FrameTypeConnectionClose))
 	}
 
 	b = quicvarint.Append(b, f.ErrorCode)
diff --git a/internal/wire/crypto_frame.go b/internal/wire/crypto_frame.go
index 0aa7fe7b..60a713f7 100644
--- a/internal/wire/crypto_frame.go
+++ b/internal/wire/crypto_frame.go
@@ -38,7 +38,7 @@ func parseCryptoFrame(b []byte, _ protocol.Version) (*CryptoFrame, int, error) {
 }
 
 func (f *CryptoFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, cryptoFrameType)
+	b = append(b, byte(FrameTypeCrypto))
 	b = quicvarint.Append(b, uint64(f.Offset))
 	b = quicvarint.Append(b, uint64(len(f.Data)))
 	b = append(b, f.Data...)
diff --git a/internal/wire/data_blocked_frame.go b/internal/wire/data_blocked_frame.go
index c97d4c62..11c72ea3 100644
--- a/internal/wire/data_blocked_frame.go
+++ b/internal/wire/data_blocked_frame.go
@@ -19,7 +19,7 @@ func parseDataBlockedFrame(b []byte, _ protocol.Version) (*DataBlockedFrame, int
 }
 
 func (f *DataBlockedFrame) Append(b []byte, version protocol.Version) ([]byte, error) {
-	b = append(b, dataBlockedFrameType)
+	b = append(b, byte(FrameTypeDataBlocked))
 	return quicvarint.Append(b, uint64(f.MaximumData)), nil
 }
 
diff --git a/internal/wire/datagram_frame.go b/internal/wire/datagram_frame.go
index 071fda9a..a6034867 100644
--- a/internal/wire/datagram_frame.go
+++ b/internal/wire/datagram_frame.go
@@ -19,10 +19,10 @@ type DatagramFrame struct {
 	Data           []byte
 }
 
-func parseDatagramFrame(b []byte, typ uint64, _ protocol.Version) (*DatagramFrame, int, error) {
+func parseDatagramFrame(b []byte, typ FrameType, _ protocol.Version) (*DatagramFrame, int, error) {
 	startLen := len(b)
 	f := &DatagramFrame{}
-	f.DataLenPresent = typ&0x1 > 0
+	f.DataLenPresent = uint64(typ)&0x1 > 0
 
 	var length uint64
 	if f.DataLenPresent {
diff --git a/internal/wire/frame.go b/internal/wire/frame.go
index 10d4eebc..09ea92f7 100644
--- a/internal/wire/frame.go
+++ b/internal/wire/frame.go
@@ -19,3 +19,15 @@ func IsProbingFrame(f Frame) bool {
 	}
 	return false
 }
+
+// IsProbingFrameType returns true if the FrameType is a probing frame.
+// See section 9.1 of RFC 9000.
+func IsProbingFrameType(f FrameType) bool {
+	//nolint:exhaustive // PATH_CHALLENGE, PATH_RESPONSE and NEW_CONNECTION_ID are the only probing frames
+	switch f {
+	case FrameTypePathChallenge, FrameTypePathResponse, FrameTypeNewConnectionID:
+		return true
+	default:
+		return false
+	}
+}
diff --git a/internal/wire/frame_parser.go b/internal/wire/frame_parser.go
index 794c70cc..e92e29c8 100644
--- a/internal/wire/frame_parser.go
+++ b/internal/wire/frame_parser.go
@@ -4,39 +4,12 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"reflect"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
 	"github.com/quic-go/quic-go/quicvarint"
 )
 
-const (
-	pingFrameType               = 0x1
-	ackFrameType                = 0x2
-	ackECNFrameType             = 0x3
-	resetStreamFrameType        = 0x4
-	stopSendingFrameType        = 0x5
-	cryptoFrameType             = 0x6
-	newTokenFrameType           = 0x7
-	maxDataFrameType            = 0x10
-	maxStreamDataFrameType      = 0x11
-	bidiMaxStreamsFrameType     = 0x12
-	uniMaxStreamsFrameType      = 0x13
-	dataBlockedFrameType        = 0x14
-	streamDataBlockedFrameType  = 0x15
-	bidiStreamBlockedFrameType  = 0x16
-	uniStreamBlockedFrameType   = 0x17
-	newConnectionIDFrameType    = 0x18
-	retireConnectionIDFrameType = 0x19
-	pathChallengeFrameType      = 0x1a
-	pathResponseFrameType       = 0x1b
-	connectionCloseFrameType    = 0x1c
-	applicationCloseFrameType   = 0x1d
-	handshakeDoneFrameType      = 0x1e
-	resetStreamAtFrameType      = 0x24 // https://datatracker.ietf.org/doc/draft-ietf-quic-reliable-stream-reset/06/
-)
-
 var errUnknownFrameType = errors.New("unknown frame type")
 
 // The FrameParser parses QUIC frames, one by one.
@@ -59,20 +32,15 @@ func NewFrameParser(supportsDatagrams, supportsResetStreamAt bool) *FrameParser
 	}
 }
 
-// ParseNext parses the next frame.
-// It skips PADDING frames.
-func (p *FrameParser) ParseNext(data []byte, encLevel protocol.EncryptionLevel, v protocol.Version) (int, Frame, error) {
-	frame, l, err := p.parseNext(data, encLevel, v)
-	return l, frame, err
-}
-
-func (p *FrameParser) parseNext(b []byte, encLevel protocol.EncryptionLevel, v protocol.Version) (Frame, int, error) {
+// ParseType parses the frame type of the next frame.
+// It skips over PADDING frames.
+func (p *FrameParser) ParseType(b []byte, encLevel protocol.EncryptionLevel) (FrameType, int, error) {
 	var parsed int
 	for len(b) != 0 {
 		typ, l, err := quicvarint.Parse(b)
 		parsed += l
 		if err != nil {
-			return nil, parsed, &qerr.TransportError{
+			return 0, parsed, &qerr.TransportError{
 				ErrorCode:    qerr.FrameEncodingError,
 				ErrorMessage: err.Error(),
 			}
@@ -81,115 +49,126 @@ func (p *FrameParser) parseNext(b []byte, encLevel protocol.EncryptionLevel, v p
 		if typ == 0x0 { // skip PADDING frames
 			continue
 		}
-
-		f, l, err := p.parseFrame(b, typ, encLevel, v)
-		parsed += l
-		if err != nil {
-			return nil, parsed, &qerr.TransportError{
+		ft := FrameType(typ)
+		valid := ft.isValidRFC9000() ||
+			(p.supportsDatagrams && ft.IsDatagramFrameType()) ||
+			(p.supportsResetStreamAt && ft == FrameTypeResetStreamAt)
+		if !valid {
+			return 0, parsed, &qerr.TransportError{
+				ErrorCode:    qerr.FrameEncodingError,
 				FrameType:    typ,
+				ErrorMessage: errUnknownFrameType.Error(),
+			}
+		}
+		if !ft.isAllowedAtEncLevel(encLevel) {
+			return 0, parsed, &qerr.TransportError{
 				ErrorCode:    qerr.FrameEncodingError,
-				ErrorMessage: err.Error(),
+				FrameType:    typ,
+				ErrorMessage: fmt.Sprintf("%d not allowed at encryption level %s", ft, encLevel),
 			}
 		}
-		return f, parsed, nil
+		return ft, parsed, nil
 	}
-	return nil, parsed, nil
+	return 0, parsed, io.EOF
 }
 
-func (p *FrameParser) parseFrame(b []byte, typ uint64, encLevel protocol.EncryptionLevel, v protocol.Version) (Frame, int, error) {
-	var frame Frame
-	var err error
-	var l int
-	if typ&0xf8 == 0x8 {
-		frame, l, err = parseStreamFrame(b, typ, v)
-	} else {
-		switch typ {
-		case pingFrameType:
-			frame = &PingFrame{}
-		case ackFrameType, ackECNFrameType:
-			ackDelayExponent := p.ackDelayExponent
-			if encLevel != protocol.Encryption1RTT {
-				ackDelayExponent = protocol.DefaultAckDelayExponent
-			}
-			p.ackFrame.Reset()
-			l, err = parseAckFrame(p.ackFrame, b, typ, ackDelayExponent, v)
-			frame = p.ackFrame
-		case resetStreamFrameType:
-			frame, l, err = parseResetStreamFrame(b, false, v)
-		case stopSendingFrameType:
-			frame, l, err = parseStopSendingFrame(b, v)
-		case cryptoFrameType:
-			frame, l, err = parseCryptoFrame(b, v)
-		case newTokenFrameType:
-			frame, l, err = parseNewTokenFrame(b, v)
-		case maxDataFrameType:
-			frame, l, err = parseMaxDataFrame(b, v)
-		case maxStreamDataFrameType:
-			frame, l, err = parseMaxStreamDataFrame(b, v)
-		case bidiMaxStreamsFrameType, uniMaxStreamsFrameType:
-			frame, l, err = parseMaxStreamsFrame(b, typ, v)
-		case dataBlockedFrameType:
-			frame, l, err = parseDataBlockedFrame(b, v)
-		case streamDataBlockedFrameType:
-			frame, l, err = parseStreamDataBlockedFrame(b, v)
-		case bidiStreamBlockedFrameType, uniStreamBlockedFrameType:
-			frame, l, err = parseStreamsBlockedFrame(b, typ, v)
-		case newConnectionIDFrameType:
-			frame, l, err = parseNewConnectionIDFrame(b, v)
-		case retireConnectionIDFrameType:
-			frame, l, err = parseRetireConnectionIDFrame(b, v)
-		case pathChallengeFrameType:
-			frame, l, err = parsePathChallengeFrame(b, v)
-		case pathResponseFrameType:
-			frame, l, err = parsePathResponseFrame(b, v)
-		case connectionCloseFrameType, applicationCloseFrameType:
-			frame, l, err = parseConnectionCloseFrame(b, typ, v)
-		case handshakeDoneFrameType:
-			frame = &HandshakeDoneFrame{}
-		case 0x30, 0x31:
-			if !p.supportsDatagrams {
-				return nil, 0, errUnknownFrameType
-			}
-			frame, l, err = parseDatagramFrame(b, typ, v)
-		case resetStreamAtFrameType:
-			if !p.supportsResetStreamAt {
-				return nil, 0, errUnknownFrameType
-			}
-			frame, l, err = parseResetStreamFrame(b, true, v)
-		default:
-			err = errUnknownFrameType
+func (p *FrameParser) ParseStreamFrame(frameType FrameType, data []byte, v protocol.Version) (*StreamFrame, int, error) {
+	frame, n, err := ParseStreamFrame(data, frameType, v)
+	if err != nil {
+		return nil, n, &qerr.TransportError{
+			ErrorCode:    qerr.FrameEncodingError,
+			FrameType:    uint64(frameType),
+			ErrorMessage: err.Error(),
 		}
 	}
-	if err != nil {
-		return nil, 0, err
+	return frame, n, nil
+}
+
+func (p *FrameParser) ParseAckFrame(frameType FrameType, data []byte, encLevel protocol.EncryptionLevel, v protocol.Version) (*AckFrame, int, error) {
+	ackDelayExponent := p.ackDelayExponent
+	if encLevel != protocol.Encryption1RTT {
+		ackDelayExponent = protocol.DefaultAckDelayExponent
 	}
-	if !p.isAllowedAtEncLevel(frame, encLevel) {
-		return nil, l, fmt.Errorf("%s not allowed at encryption level %s", reflect.TypeOf(frame).Elem().Name(), encLevel)
+	p.ackFrame.Reset()
+	l, err := parseAckFrame(p.ackFrame, data, frameType, ackDelayExponent, v)
+	if err != nil {
+		return nil, l, &qerr.TransportError{
+			ErrorCode:    qerr.FrameEncodingError,
+			FrameType:    uint64(frameType),
+			ErrorMessage: err.Error(),
+		}
 	}
-	return frame, l, nil
+
+	return p.ackFrame, l, nil
 }
 
-func (p *FrameParser) isAllowedAtEncLevel(f Frame, encLevel protocol.EncryptionLevel) bool {
-	switch encLevel {
-	case protocol.EncryptionInitial, protocol.EncryptionHandshake:
-		switch f.(type) {
-		case *CryptoFrame, *AckFrame, *ConnectionCloseFrame, *PingFrame:
-			return true
-		default:
-			return false
-		}
-	case protocol.Encryption0RTT:
-		switch f.(type) {
-		case *CryptoFrame, *AckFrame, *ConnectionCloseFrame, *NewTokenFrame, *PathResponseFrame, *RetireConnectionIDFrame:
-			return false
-		default:
-			return true
+func (p *FrameParser) ParseDatagramFrame(frameType FrameType, data []byte, v protocol.Version) (*DatagramFrame, int, error) {
+	f, l, err := parseDatagramFrame(data, frameType, v)
+	if err != nil {
+		return nil, 0, &qerr.TransportError{
+			ErrorCode:    qerr.FrameEncodingError,
+			FrameType:    uint64(frameType),
+			ErrorMessage: err.Error(),
 		}
-	case protocol.Encryption1RTT:
-		return true
+	}
+	return f, l, nil
+}
+
+// ParseLessCommonFrame parses everything except STREAM, ACK or DATAGRAM.
+// These cases should be handled separately for performance reasons.
+func (p *FrameParser) ParseLessCommonFrame(frameType FrameType, data []byte, v protocol.Version) (Frame, int, error) {
+	var frame Frame
+	var l int
+	var err error
+	//nolint:exhaustive // Common frames should already be handled.
+	switch frameType {
+	case FrameTypePing:
+		frame = &PingFrame{}
+	case FrameTypeResetStream:
+		frame, l, err = parseResetStreamFrame(data, false, v)
+	case FrameTypeStopSending:
+		frame, l, err = parseStopSendingFrame(data, v)
+	case FrameTypeCrypto:
+		frame, l, err = parseCryptoFrame(data, v)
+	case FrameTypeNewToken:
+		frame, l, err = parseNewTokenFrame(data, v)
+	case FrameTypeMaxData:
+		frame, l, err = parseMaxDataFrame(data, v)
+	case FrameTypeMaxStreamData:
+		frame, l, err = parseMaxStreamDataFrame(data, v)
+	case FrameTypeBidiMaxStreams, FrameTypeUniMaxStreams:
+		frame, l, err = parseMaxStreamsFrame(data, frameType, v)
+	case FrameTypeDataBlocked:
+		frame, l, err = parseDataBlockedFrame(data, v)
+	case FrameTypeStreamDataBlocked:
+		frame, l, err = parseStreamDataBlockedFrame(data, v)
+	case FrameTypeBidiStreamBlocked, FrameTypeUniStreamBlocked:
+		frame, l, err = parseStreamsBlockedFrame(data, frameType, v)
+	case FrameTypeNewConnectionID:
+		frame, l, err = parseNewConnectionIDFrame(data, v)
+	case FrameTypeRetireConnectionID:
+		frame, l, err = parseRetireConnectionIDFrame(data, v)
+	case FrameTypePathChallenge:
+		frame, l, err = parsePathChallengeFrame(data, v)
+	case FrameTypePathResponse:
+		frame, l, err = parsePathResponseFrame(data, v)
+	case FrameTypeConnectionClose, FrameTypeApplicationClose:
+		frame, l, err = parseConnectionCloseFrame(data, frameType, v)
+	case FrameTypeHandshakeDone:
+		frame = &HandshakeDoneFrame{}
+	case FrameTypeResetStreamAt:
+		frame, l, err = parseResetStreamFrame(data, true, v)
 	default:
-		panic("unknown encryption level")
+		err = errUnknownFrameType
+	}
+	if err != nil {
+		return frame, l, &qerr.TransportError{
+			ErrorCode:    qerr.FrameEncodingError,
+			FrameType:    uint64(frameType),
+			ErrorMessage: err.Error(),
+		}
 	}
+	return frame, l, err
 }
 
 // SetAckDelayExponent sets the acknowledgment delay exponent (sent in the transport parameters).
diff --git a/internal/wire/frame_type.go b/internal/wire/frame_type.go
new file mode 100644
index 00000000..0576657f
--- /dev/null
+++ b/internal/wire/frame_type.go
@@ -0,0 +1,77 @@
+package wire
+
+import "github.com/quic-go/quic-go/internal/protocol"
+
+type FrameType uint64
+
+// These constants correspond to those defined in RFC 9000.
+// Stream frame types are not listed explicitly here; use FrameType.IsStreamFrameType() to identify them.
+const (
+	FrameTypePing        FrameType = 0x1
+	FrameTypeAck         FrameType = 0x2
+	FrameTypeAckECN      FrameType = 0x3
+	FrameTypeResetStream FrameType = 0x4
+	FrameTypeStopSending FrameType = 0x5
+	FrameTypeCrypto      FrameType = 0x6
+	FrameTypeNewToken    FrameType = 0x7
+
+	FrameTypeMaxData            FrameType = 0x10
+	FrameTypeMaxStreamData      FrameType = 0x11
+	FrameTypeBidiMaxStreams     FrameType = 0x12
+	FrameTypeUniMaxStreams      FrameType = 0x13
+	FrameTypeDataBlocked        FrameType = 0x14
+	FrameTypeStreamDataBlocked  FrameType = 0x15
+	FrameTypeBidiStreamBlocked  FrameType = 0x16
+	FrameTypeUniStreamBlocked   FrameType = 0x17
+	FrameTypeNewConnectionID    FrameType = 0x18
+	FrameTypeRetireConnectionID FrameType = 0x19
+	FrameTypePathChallenge      FrameType = 0x1a
+	FrameTypePathResponse       FrameType = 0x1b
+	FrameTypeConnectionClose    FrameType = 0x1c
+	FrameTypeApplicationClose   FrameType = 0x1d
+	FrameTypeHandshakeDone      FrameType = 0x1e
+	FrameTypeResetStreamAt      FrameType = 0x24 // https://datatracker.ietf.org/doc/draft-ietf-quic-reliable-stream-reset/06/
+
+	FrameTypeDatagramNoLength   FrameType = 0x30
+	FrameTypeDatagramWithLength FrameType = 0x31
+)
+
+func (t FrameType) IsStreamFrameType() bool {
+	return t >= 0x8 && t <= 0xf
+}
+
+func (t FrameType) isValidRFC9000() bool {
+	return t <= 0x1e
+}
+
+func (t FrameType) IsAckFrameType() bool {
+	return t == FrameTypeAck || t == FrameTypeAckECN
+}
+
+func (t FrameType) IsDatagramFrameType() bool {
+	return t == FrameTypeDatagramNoLength || t == FrameTypeDatagramWithLength
+}
+
+func (t FrameType) isAllowedAtEncLevel(encLevel protocol.EncryptionLevel) bool {
+	//nolint:exhaustive
+	switch encLevel {
+	case protocol.EncryptionInitial, protocol.EncryptionHandshake:
+		switch t {
+		case FrameTypeCrypto, FrameTypeAck, FrameTypeAckECN, FrameTypeConnectionClose, FrameTypePing:
+			return true
+		default:
+			return false
+		}
+	case protocol.Encryption0RTT:
+		switch t {
+		case FrameTypeCrypto, FrameTypeAck, FrameTypeAckECN, FrameTypeConnectionClose, FrameTypeNewToken, FrameTypePathResponse, FrameTypeRetireConnectionID:
+			return false
+		default:
+			return true
+		}
+	case protocol.Encryption1RTT:
+		return true
+	default:
+		panic("unknown encryption level")
+	}
+}
diff --git a/internal/wire/handshake_done_frame.go b/internal/wire/handshake_done_frame.go
index 85dd6474..bf95f525 100644
--- a/internal/wire/handshake_done_frame.go
+++ b/internal/wire/handshake_done_frame.go
@@ -8,7 +8,7 @@ import (
 type HandshakeDoneFrame struct{}
 
 func (f *HandshakeDoneFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	return append(b, handshakeDoneFrameType), nil
+	return append(b, byte(FrameTypeHandshakeDone)), nil
 }
 
 // Length of a written frame
diff --git a/internal/wire/max_data_frame.go b/internal/wire/max_data_frame.go
index 5819c027..bfbdcba6 100644
--- a/internal/wire/max_data_frame.go
+++ b/internal/wire/max_data_frame.go
@@ -22,7 +22,7 @@ func parseMaxDataFrame(b []byte, _ protocol.Version) (*MaxDataFrame, int, error)
 }
 
 func (f *MaxDataFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, maxDataFrameType)
+	b = append(b, byte(FrameTypeMaxData))
 	b = quicvarint.Append(b, uint64(f.MaximumData))
 	return b, nil
 }
diff --git a/internal/wire/max_stream_data_frame.go b/internal/wire/max_stream_data_frame.go
index db9091af..0966ea46 100644
--- a/internal/wire/max_stream_data_frame.go
+++ b/internal/wire/max_stream_data_frame.go
@@ -31,7 +31,7 @@ func parseMaxStreamDataFrame(b []byte, _ protocol.Version) (*MaxStreamDataFrame,
 }
 
 func (f *MaxStreamDataFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, maxStreamDataFrameType)
+	b = append(b, byte(FrameTypeMaxStreamData))
 	b = quicvarint.Append(b, uint64(f.StreamID))
 	b = quicvarint.Append(b, uint64(f.MaximumStreamData))
 	return b, nil
diff --git a/internal/wire/max_streams_frame.go b/internal/wire/max_streams_frame.go
index a8745bd1..30612e23 100644
--- a/internal/wire/max_streams_frame.go
+++ b/internal/wire/max_streams_frame.go
@@ -13,12 +13,13 @@ type MaxStreamsFrame struct {
 	MaxStreamNum protocol.StreamNum
 }
 
-func parseMaxStreamsFrame(b []byte, typ uint64, _ protocol.Version) (*MaxStreamsFrame, int, error) {
+func parseMaxStreamsFrame(b []byte, typ FrameType, _ protocol.Version) (*MaxStreamsFrame, int, error) {
 	f := &MaxStreamsFrame{}
+	//nolint:exhaustive // Function will only be called with BidiMaxStreamsFrameType or UniMaxStreamsFrameType
 	switch typ {
-	case bidiMaxStreamsFrameType:
+	case FrameTypeBidiMaxStreams:
 		f.Type = protocol.StreamTypeBidi
-	case uniMaxStreamsFrameType:
+	case FrameTypeUniMaxStreams:
 		f.Type = protocol.StreamTypeUni
 	}
 	streamID, l, err := quicvarint.Parse(b)
@@ -35,9 +36,9 @@ func parseMaxStreamsFrame(b []byte, typ uint64, _ protocol.Version) (*MaxStreams
 func (f *MaxStreamsFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
 	switch f.Type {
 	case protocol.StreamTypeBidi:
-		b = append(b, bidiMaxStreamsFrameType)
+		b = append(b, byte(FrameTypeBidiMaxStreams))
 	case protocol.StreamTypeUni:
-		b = append(b, uniMaxStreamsFrameType)
+		b = append(b, byte(FrameTypeUniMaxStreams))
 	}
 	b = quicvarint.Append(b, uint64(f.MaxStreamNum))
 	return b, nil
diff --git a/internal/wire/new_connection_id_frame.go b/internal/wire/new_connection_id_frame.go
index 6f2287f4..05831926 100644
--- a/internal/wire/new_connection_id_frame.go
+++ b/internal/wire/new_connection_id_frame.go
@@ -61,7 +61,7 @@ func parseNewConnectionIDFrame(b []byte, _ protocol.Version) (*NewConnectionIDFr
 }
 
 func (f *NewConnectionIDFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, newConnectionIDFrameType)
+	b = append(b, byte(FrameTypeNewConnectionID))
 	b = quicvarint.Append(b, f.SequenceNumber)
 	b = quicvarint.Append(b, f.RetirePriorTo)
 	connIDLen := f.ConnectionID.Len()
diff --git a/internal/wire/new_token_frame.go b/internal/wire/new_token_frame.go
index f1d4d00f..73d356b1 100644
--- a/internal/wire/new_token_frame.go
+++ b/internal/wire/new_token_frame.go
@@ -31,7 +31,7 @@ func parseNewTokenFrame(b []byte, _ protocol.Version) (*NewTokenFrame, int, erro
 }
 
 func (f *NewTokenFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, newTokenFrameType)
+	b = append(b, byte(FrameTypeNewToken))
 	b = quicvarint.Append(b, uint64(len(f.Token)))
 	b = append(b, f.Token...)
 	return b, nil
diff --git a/internal/wire/path_challenge_frame.go b/internal/wire/path_challenge_frame.go
index 2aca989f..7a4a767e 100644
--- a/internal/wire/path_challenge_frame.go
+++ b/internal/wire/path_challenge_frame.go
@@ -21,7 +21,7 @@ func parsePathChallengeFrame(b []byte, _ protocol.Version) (*PathChallengeFrame,
 }
 
 func (f *PathChallengeFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, pathChallengeFrameType)
+	b = append(b, byte(FrameTypePathChallenge))
 	b = append(b, f.Data[:]...)
 	return b, nil
 }
diff --git a/internal/wire/path_response_frame.go b/internal/wire/path_response_frame.go
index 76532c85..e76d037b 100644
--- a/internal/wire/path_response_frame.go
+++ b/internal/wire/path_response_frame.go
@@ -21,7 +21,7 @@ func parsePathResponseFrame(b []byte, _ protocol.Version) (*PathResponseFrame, i
 }
 
 func (f *PathResponseFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, pathResponseFrameType)
+	b = append(b, byte(FrameTypePathResponse))
 	b = append(b, f.Data[:]...)
 	return b, nil
 }
diff --git a/internal/wire/ping_frame.go b/internal/wire/ping_frame.go
index 71f8d16c..5d344d44 100644
--- a/internal/wire/ping_frame.go
+++ b/internal/wire/ping_frame.go
@@ -8,7 +8,7 @@ import (
 type PingFrame struct{}
 
 func (f *PingFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	return append(b, pingFrameType), nil
+	return append(b, byte(FrameTypePing)), nil
 }
 
 // Length of a written frame
diff --git a/internal/wire/reset_stream_frame.go b/internal/wire/reset_stream_frame.go
index cb678bf4..4101b76b 100644
--- a/internal/wire/reset_stream_frame.go
+++ b/internal/wire/reset_stream_frame.go
@@ -56,9 +56,9 @@ func parseResetStreamFrame(b []byte, isResetStreamAt bool, _ protocol.Version) (
 
 func (f *ResetStreamFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
 	if f.ReliableSize == 0 {
-		b = quicvarint.Append(b, resetStreamFrameType)
+		b = quicvarint.Append(b, uint64(FrameTypeResetStream))
 	} else {
-		b = quicvarint.Append(b, resetStreamAtFrameType)
+		b = quicvarint.Append(b, uint64(FrameTypeResetStreamAt))
 	}
 	b = quicvarint.Append(b, uint64(f.StreamID))
 	b = quicvarint.Append(b, uint64(f.ErrorCode))
diff --git a/internal/wire/retire_connection_id_frame.go b/internal/wire/retire_connection_id_frame.go
index 27aeff84..1927f9dc 100644
--- a/internal/wire/retire_connection_id_frame.go
+++ b/internal/wire/retire_connection_id_frame.go
@@ -19,7 +19,7 @@ func parseRetireConnectionIDFrame(b []byte, _ protocol.Version) (*RetireConnecti
 }
 
 func (f *RetireConnectionIDFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, retireConnectionIDFrameType)
+	b = append(b, byte(FrameTypeRetireConnectionID))
 	b = quicvarint.Append(b, f.SequenceNumber)
 	return b, nil
 }
diff --git a/internal/wire/stop_sending_frame.go b/internal/wire/stop_sending_frame.go
index a2326f8e..2b15c710 100644
--- a/internal/wire/stop_sending_frame.go
+++ b/internal/wire/stop_sending_frame.go
@@ -38,7 +38,7 @@ func (f *StopSendingFrame) Length(_ protocol.Version) protocol.ByteCount {
 }
 
 func (f *StopSendingFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
-	b = append(b, stopSendingFrameType)
+	b = append(b, byte(FrameTypeStopSending))
 	b = quicvarint.Append(b, uint64(f.StreamID))
 	b = quicvarint.Append(b, uint64(f.ErrorCode))
 	return b, nil
diff --git a/internal/wire/stream_frame.go b/internal/wire/stream_frame.go
index cdc32722..e53962b1 100644
--- a/internal/wire/stream_frame.go
+++ b/internal/wire/stream_frame.go
@@ -19,7 +19,7 @@ type StreamFrame struct {
 	fromPool bool
 }
 
-func parseStreamFrame(b []byte, typ uint64, _ protocol.Version) (*StreamFrame, int, error) {
+func ParseStreamFrame(b []byte, typ FrameType, _ protocol.Version) (*StreamFrame, int, error) {
 	startLen := len(b)
 	hasOffset := typ&0b100 > 0
 	fin := typ&0b1 > 0
diff --git a/internal/wire/streams_blocked_frame.go b/internal/wire/streams_blocked_frame.go
index c946fec3..d98fde46 100644
--- a/internal/wire/streams_blocked_frame.go
+++ b/internal/wire/streams_blocked_frame.go
@@ -13,12 +13,13 @@ type StreamsBlockedFrame struct {
 	StreamLimit protocol.StreamNum
 }
 
-func parseStreamsBlockedFrame(b []byte, typ uint64, _ protocol.Version) (*StreamsBlockedFrame, int, error) {
+func parseStreamsBlockedFrame(b []byte, typ FrameType, _ protocol.Version) (*StreamsBlockedFrame, int, error) {
 	f := &StreamsBlockedFrame{}
+	//nolint:exhaustive // This will only be called with a BidiStreamBlockedFrameType or a UniStreamBlockedFrameType.
 	switch typ {
-	case bidiStreamBlockedFrameType:
+	case FrameTypeBidiStreamBlocked:
 		f.Type = protocol.StreamTypeBidi
-	case uniStreamBlockedFrameType:
+	case FrameTypeUniStreamBlocked:
 		f.Type = protocol.StreamTypeUni
 	}
 	streamLimit, l, err := quicvarint.Parse(b)
@@ -35,9 +36,9 @@ func parseStreamsBlockedFrame(b []byte, typ uint64, _ protocol.Version) (*Stream
 func (f *StreamsBlockedFrame) Append(b []byte, _ protocol.Version) ([]byte, error) {
 	switch f.Type {
 	case protocol.StreamTypeBidi:
-		b = append(b, bidiStreamBlockedFrameType)
+		b = append(b, byte(FrameTypeBidiStreamBlocked))
 	case protocol.StreamTypeUni:
-		b = append(b, uniStreamBlockedFrameType)
+		b = append(b, byte(FrameTypeUniStreamBlocked))
 	}
 	b = quicvarint.Append(b, uint64(f.StreamLimit))
 	return b, nil


Test patch:
diff --git a/connection_test.go b/connection_test.go
index cf976eb6..30d7dd8c 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -215,17 +215,19 @@ func TestConnectionHandleStreamRelatedFrames(t *testing.T) {
 		name  string
 		frame wire.Frame
 	}{
-		{name: "STREAM", frame: &wire.StreamFrame{StreamID: id, Data: []byte("foobar")}},
 		{name: "RESET_STREAM", frame: &wire.ResetStreamFrame{StreamID: id, ErrorCode: 42, FinalSize: 1337}},
 		{name: "STOP_SENDING", frame: &wire.StopSendingFrame{StreamID: id, ErrorCode: 42}},
 		{name: "MAX_STREAM_DATA", frame: &wire.MaxStreamDataFrame{StreamID: id, MaximumStreamData: 1337}},
 		{name: "STREAM_DATA_BLOCKED", frame: &wire.StreamDataBlockedFrame{StreamID: id, MaximumStreamData: 42}},
+		{name: "STREAM_FRAME", frame: &wire.StreamFrame{StreamID: id, Data: []byte{1, 2, 3, 4, 5, 6, 7, 8}, Offset: 1337}},
 	}
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
 			tc := newServerTestConnection(t, gomock.NewController(t), nil, false)
-			_, err := tc.conn.handleFrame(test.frame, protocol.Encryption1RTT, connID, time.Now())
+			data, err := test.frame.Append(nil, protocol.Version1)
+			require.NoError(t, err)
+			_, _, _, err = tc.conn.handleFrames(data, connID, protocol.Encryption1RTT, nil, time.Now())
 			require.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.StreamStateError})
 		})
 	}
@@ -2996,3 +2998,37 @@ func testConnectionMigration(t *testing.T, enabled bool) {
 		t.Fatal("timeout")
 	}
 }
+
+func TestConnectionDatagrams(t *testing.T) {
+	t.Run("disabled", func(t *testing.T) {
+		testConnectionDatagrams(t, false)
+	})
+	t.Run("enabled", func(t *testing.T) {
+		testConnectionDatagrams(t, true)
+	})
+}
+
+func testConnectionDatagrams(t *testing.T, enabled bool) {
+	tc := newServerTestConnection(t, nil, &Config{EnableDatagrams: enabled}, false)
+
+	data, err := (&wire.DatagramFrame{Data: []byte("foo"), DataLenPresent: true}).Append(nil, protocol.Version1)
+	require.NoError(t, err)
+	data, err = (&wire.DatagramFrame{Data: []byte("bar")}).Append(data, protocol.Version1)
+	require.NoError(t, err)
+	_, _, _, err = tc.conn.handleFrames(data, protocol.ConnectionID{}, protocol.Encryption1RTT, nil, time.Now())
+
+	if !enabled {
+		require.ErrorIs(t, err, &qerr.TransportError{ErrorCode: qerr.FrameEncodingError, FrameType: uint64(wire.FrameTypeDatagramWithLength)})
+		return
+	}
+
+	require.NoError(t, err)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+	d, err := tc.conn.ReceiveDatagram(ctx)
+	require.NoError(t, err)
+	require.Equal(t, []byte("foo"), d)
+	d, err = tc.conn.ReceiveDatagram(ctx)
+	require.NoError(t, err)
+	require.Equal(t, []byte("bar"), d)
+}
diff --git a/internal/ackhandler/ack_eliciting_test.go b/internal/ackhandler/ack_eliciting_test.go
index 1c363e93..65cc627e 100644
--- a/internal/ackhandler/ack_eliciting_test.go
+++ b/internal/ackhandler/ack_eliciting_test.go
@@ -7,6 +7,48 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
+func TestIsFrameTypeAckEliciting(t *testing.T) {
+	testCases := map[wire.FrameType]bool{
+		wire.FrameTypePing:               true,
+		wire.FrameTypeAck:                false,
+		wire.FrameTypeAckECN:             false,
+		wire.FrameTypeResetStream:        true,
+		wire.FrameTypeStopSending:        true,
+		wire.FrameTypeCrypto:             true,
+		wire.FrameTypeNewToken:           true,
+		wire.FrameType(0x08):             true,
+		wire.FrameType(0x09):             true,
+		wire.FrameType(0x0a):             true,
+		wire.FrameType(0x0b):             true,
+		wire.FrameType(0x0c):             true,
+		wire.FrameType(0x0d):             true,
+		wire.FrameType(0x0e):             true,
+		wire.FrameType(0x0f):             true,
+		wire.FrameTypeMaxData:            true,
+		wire.FrameTypeMaxStreamData:      true,
+		wire.FrameTypeBidiMaxStreams:     true,
+		wire.FrameTypeUniMaxStreams:      true,
+		wire.FrameTypeDataBlocked:        true,
+		wire.FrameTypeStreamDataBlocked:  true,
+		wire.FrameTypeBidiStreamBlocked:  true,
+		wire.FrameTypeUniStreamBlocked:   true,
+		wire.FrameTypeNewConnectionID:    true,
+		wire.FrameTypeRetireConnectionID: true,
+		wire.FrameTypePathChallenge:      true,
+		wire.FrameTypePathResponse:       true,
+		wire.FrameTypeConnectionClose:    false,
+		wire.FrameTypeApplicationClose:   false,
+		wire.FrameTypeHandshakeDone:      true,
+		wire.FrameTypeResetStreamAt:      true,
+		wire.FrameTypeDatagramNoLength:   true,
+		wire.FrameTypeDatagramWithLength: true,
+	}
+
+	for ft, expected := range testCases {
+		require.Equal(t, expected, IsFrameTypeAckEliciting(ft), "unexpected result for frame type 0x%x", ft)
+	}
+}
+
 func TestAckElicitingFrames(t *testing.T) {
 	testCases := map[wire.Frame]bool{
 		&wire.AckFrame{}:             false,
diff --git a/internal/wire/ack_frame_test.go b/internal/wire/ack_frame_test.go
index f6390bfc..e7f21a0b 100644
--- a/internal/wire/ack_frame_test.go
+++ b/internal/wire/ack_frame_test.go
@@ -17,7 +17,7 @@ func TestParseACKWithoutRanges(t *testing.T) {
 	data = append(data, encodeVarInt(0)...)  // num blocks
 	data = append(data, encodeVarInt(10)...) // first ack block
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	require.Equal(t, protocol.PacketNumber(100), frame.LargestAcked())
@@ -31,7 +31,7 @@ func TestParseACKSinglePacket(t *testing.T) {
 	data = append(data, encodeVarInt(0)...) // num blocks
 	data = append(data, encodeVarInt(0)...) // first ack block
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	require.Equal(t, protocol.PacketNumber(55), frame.LargestAcked())
@@ -45,7 +45,7 @@ func TestParseACKAllPacketsFrom0ToLargest(t *testing.T) {
 	data = append(data, encodeVarInt(0)...)  // num blocks
 	data = append(data, encodeVarInt(20)...) // first ack block
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	require.Equal(t, protocol.PacketNumber(20), frame.LargestAcked())
@@ -59,7 +59,7 @@ func TestParseACKRejectFirstBlockLargerThanLargestAcked(t *testing.T) {
 	data = append(data, encodeVarInt(0)...)  // num blocks
 	data = append(data, encodeVarInt(21)...) // first ack block
 	var frame AckFrame
-	_, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	_, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.EqualError(t, err, "invalid first ACK range")
 }
 
@@ -71,7 +71,7 @@ func TestParseACKWithSingleBlock(t *testing.T) {
 	data = append(data, encodeVarInt(98)...)  // gap
 	data = append(data, encodeVarInt(50)...)  // ack block
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	require.Equal(t, protocol.PacketNumber(1000), frame.LargestAcked())
@@ -93,7 +93,7 @@ func TestParseACKWithMultipleBlocks(t *testing.T) {
 	data = append(data, encodeVarInt(1)...) // gap
 	data = append(data, encodeVarInt(1)...) // ack block
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	require.Equal(t, protocol.PacketNumber(100), frame.LargestAcked())
@@ -118,7 +118,7 @@ func TestParseACKUseAckDelayExponent(t *testing.T) {
 		typ, l, err := quicvarint.Parse(b)
 		require.NoError(t, err)
 		var frame AckFrame
-		n, err := parseAckFrame(&frame, b[l:], typ, protocol.AckDelayExponent+i, protocol.Version1)
+		n, err := parseAckFrame(&frame, b[l:], FrameType(typ), protocol.AckDelayExponent+i, protocol.Version1)
 		require.NoError(t, err)
 		require.Equal(t, len(b[l:]), n)
 		require.Equal(t, delayTime*(1<<i), frame.DelayTime)
@@ -131,7 +131,7 @@ func TestParseACKHandleDelayTimeOverflow(t *testing.T) {
 	data = append(data, encodeVarInt(0)...)                // num blocks
 	data = append(data, encodeVarInt(0)...)                // first ack block
 	var frame AckFrame
-	_, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	_, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Greater(t, frame.DelayTime, time.Duration(0))
 	// The maximum encodable duration is ~292 years.
@@ -146,11 +146,11 @@ func TestParseACKErrorOnEOF(t *testing.T) {
 	data = append(data, encodeVarInt(98)...)  // gap
 	data = append(data, encodeVarInt(50)...)  // ack block
 	var frame AckFrame
-	_, err := parseAckFrame(&frame, data, ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+	_, err := parseAckFrame(&frame, data, FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	for i := range data {
 		var frame AckFrame
-		_, err := parseAckFrame(&frame, data[:i], ackFrameType, protocol.AckDelayExponent, protocol.Version1)
+		_, err := parseAckFrame(&frame, data[:i], FrameTypeAck, protocol.AckDelayExponent, protocol.Version1)
 		require.Equal(t, io.EOF, err)
 	}
 }
@@ -164,7 +164,7 @@ func TestParseACKECN(t *testing.T) {
 	data = append(data, encodeVarInt(0x12345)...)    // ECT(1)
 	data = append(data, encodeVarInt(0x12345678)...) // ECN-CE
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackECNFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAckECN, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	require.Equal(t, protocol.PacketNumber(100), frame.LargestAcked())
@@ -186,12 +186,12 @@ func TestParseACKECNErrorOnEOF(t *testing.T) {
 	data = append(data, encodeVarInt(0x12345)...)    // ECT(1)
 	data = append(data, encodeVarInt(0x12345678)...) // ECN-CE
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, data, ackECNFrameType, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, data, FrameTypeAckECN, protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), n)
 	for i := range data {
 		var frame AckFrame
-		_, err := parseAckFrame(&frame, data[:i], ackECNFrameType, protocol.AckDelayExponent, protocol.Version1)
+		_, err := parseAckFrame(&frame, data[:i], FrameTypeAckECN, protocol.AckDelayExponent, protocol.Version1)
 		require.Equal(t, io.EOF, err)
 	}
 }
@@ -202,7 +202,7 @@ func TestWriteACKSimpleFrame(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{ackFrameType}
+	expected := []byte{byte(FrameTypeAck)}
 	expected = append(expected, encodeVarInt(1337)...) // largest acked
 	expected = append(expected, 0)                     // delay
 	expected = append(expected, encodeVarInt(0)...)    // num ranges
@@ -220,7 +220,7 @@ func TestWriteACKECNFrame(t *testing.T) {
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
 	require.Len(t, b, int(f.Length(protocol.Version1)))
-	expected := []byte{ackECNFrameType}
+	expected := []byte{byte(FrameTypeAckECN)}
 	expected = append(expected, encodeVarInt(2000)...) // largest acked
 	expected = append(expected, 0)                     // delay
 	expected = append(expected, encodeVarInt(0)...)    // num ranges
@@ -243,7 +243,7 @@ func TestWriteACKSinglePacket(t *testing.T) {
 	require.NoError(t, err)
 	b = b[l:]
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, b, typ, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, b, FrameType(typ), protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(b), n)
 	require.Equal(t, f, &frame)
@@ -262,7 +262,7 @@ func TestWriteACKManyPackets(t *testing.T) {
 	require.NoError(t, err)
 	b = b[l:]
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, b, typ, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, b, FrameType(typ), protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(b), n)
 	require.Equal(t, f, &frame)
@@ -284,7 +284,7 @@ func TestWriteACKSingleGap(t *testing.T) {
 	require.NoError(t, err)
 	b = b[l:]
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, b, typ, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, b, FrameType(typ), protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(b), n)
 	require.Equal(t, f, &frame)
@@ -308,7 +308,7 @@ func TestWriteACKMultipleRanges(t *testing.T) {
 	require.NoError(t, err)
 	b = b[l:]
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, b, typ, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, b, FrameType(typ), protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(b), n)
 	require.Equal(t, f, &frame)
@@ -333,7 +333,7 @@ func TestWriteACKLimitMaxSize(t *testing.T) {
 	require.NoError(t, err)
 	b = b[l:]
 	var frame AckFrame
-	n, err := parseAckFrame(&frame, b, typ, protocol.AckDelayExponent, protocol.Version1)
+	n, err := parseAckFrame(&frame, b, FrameType(typ), protocol.AckDelayExponent, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(b), n)
 	require.True(t, frame.HasMissingRanges())
diff --git a/internal/wire/connection_close_frame_test.go b/internal/wire/connection_close_frame_test.go
index df76b24a..ae54113b 100644
--- a/internal/wire/connection_close_frame_test.go
+++ b/internal/wire/connection_close_frame_test.go
@@ -15,7 +15,7 @@ func TestParseConnectionCloseTransportError(t *testing.T) {
 	data = append(data, encodeVarInt(0x1337)...)              // frame type
 	data = append(data, encodeVarInt(uint64(len(reason)))...) // reason phrase length
 	data = append(data, []byte(reason)...)
-	frame, l, err := parseConnectionCloseFrame(data, connectionCloseFrameType, protocol.Version1)
+	frame, l, err := parseConnectionCloseFrame(data, FrameTypeConnectionClose, protocol.Version1)
 	require.NoError(t, err)
 	require.False(t, frame.IsApplicationError)
 	require.EqualValues(t, 0x19, frame.ErrorCode)
@@ -29,7 +29,7 @@ func TestParseConnectionCloseWithApplicationError(t *testing.T) {
 	data := encodeVarInt(0xcafe)
 	data = append(data, encodeVarInt(uint64(len(reason)))...) // reason phrase length
 	data = append(data, reason...)
-	frame, l, err := parseConnectionCloseFrame(data, applicationCloseFrameType, protocol.Version1)
+	frame, l, err := parseConnectionCloseFrame(data, FrameTypeApplicationClose, protocol.Version1)
 	require.NoError(t, err)
 	require.True(t, frame.IsApplicationError)
 	require.EqualValues(t, 0xcafe, frame.ErrorCode)
@@ -41,7 +41,7 @@ func TestParseConnectionCloseLongReasonPhrase(t *testing.T) {
 	data := encodeVarInt(0xcafe)
 	data = append(data, encodeVarInt(0x42)...)   // frame type
 	data = append(data, encodeVarInt(0xffff)...) // reason phrase length
-	_, _, err := parseConnectionCloseFrame(data, connectionCloseFrameType, protocol.Version1)
+	_, _, err := parseConnectionCloseFrame(data, FrameTypeConnectionClose, protocol.Version1)
 	require.Equal(t, io.EOF, err)
 }
 
@@ -51,11 +51,11 @@ func TestParseConnectionCloseErrorsOnEOFs(t *testing.T) {
 	data = append(data, encodeVarInt(0x1337)...)              // frame type
 	data = append(data, encodeVarInt(uint64(len(reason)))...) // reason phrase length
 	data = append(data, []byte(reason)...)
-	_, l, err := parseConnectionCloseFrame(data, connectionCloseFrameType, protocol.Version1)
+	_, l, err := parseConnectionCloseFrame(data, FrameTypeConnectionClose, protocol.Version1)
 	require.Equal(t, len(data), l)
 	require.NoError(t, err)
 	for i := range data {
-		_, _, err = parseConnectionCloseFrame(data[:i], connectionCloseFrameType, protocol.Version1)
+		_, _, err = parseConnectionCloseFrame(data[:i], FrameTypeConnectionClose, protocol.Version1)
 		require.Equal(t, io.EOF, err)
 	}
 }
@@ -64,7 +64,7 @@ func TestParseConnectionCloseNoReasonPhrase(t *testing.T) {
 	data := encodeVarInt(0xcafe)
 	data = append(data, encodeVarInt(0x42)...) // frame type
 	data = append(data, encodeVarInt(0)...)
-	frame, l, err := parseConnectionCloseFrame(data, connectionCloseFrameType, protocol.Version1)
+	frame, l, err := parseConnectionCloseFrame(data, FrameTypeConnectionClose, protocol.Version1)
 	require.NoError(t, err)
 	require.Empty(t, frame.ReasonPhrase)
 	require.Equal(t, len(data), l)
@@ -77,7 +77,7 @@ func TestWriteConnectionCloseNoReasonPhrase(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{connectionCloseFrameType}
+	expected := []byte{byte(FrameTypeConnectionClose)}
 	expected = append(expected, encodeVarInt(0xbeef)...)
 	expected = append(expected, encodeVarInt(0x12345)...) // frame type
 	expected = append(expected, encodeVarInt(0)...)       // reason phrase length
@@ -91,7 +91,7 @@ func TestWriteConnectionCloseWithReasonPhrase(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{connectionCloseFrameType}
+	expected := []byte{byte(FrameTypeConnectionClose)}
 	expected = append(expected, encodeVarInt(0xdead)...)
 	expected = append(expected, encodeVarInt(0)...) // frame type
 	expected = append(expected, encodeVarInt(6)...) // reason phrase length
@@ -107,7 +107,7 @@ func TestWriteConnectionCloseWithApplicationError(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{applicationCloseFrameType}
+	expected := []byte{byte(FrameTypeApplicationClose)}
 	expected = append(expected, encodeVarInt(0xdead)...)
 	expected = append(expected, encodeVarInt(6)...) // reason phrase length
 	expected = append(expected, []byte("foobar")...)
diff --git a/internal/wire/crypto_frame_test.go b/internal/wire/crypto_frame_test.go
index 89965bf5..674b03ac 100644
--- a/internal/wire/crypto_frame_test.go
+++ b/internal/wire/crypto_frame_test.go
@@ -40,7 +40,7 @@ func TestWriteCryptoFrame(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{cryptoFrameType}
+	expected := []byte{byte(FrameTypeCrypto)}
 	expected = append(expected, encodeVarInt(0x123456)...) // offset
 	expected = append(expected, encodeVarInt(6)...)        // length
 	expected = append(expected, []byte("foobar")...)
diff --git a/internal/wire/data_blocked_frame_test.go b/internal/wire/data_blocked_frame_test.go
index 12b1535e..ba19e846 100644
--- a/internal/wire/data_blocked_frame_test.go
+++ b/internal/wire/data_blocked_frame_test.go
@@ -33,7 +33,7 @@ func TestWriteDataBlocked(t *testing.T) {
 	frame := DataBlockedFrame{MaximumData: 0xdeadbeef}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{dataBlockedFrameType}
+	expected := []byte{byte(FrameTypeDataBlocked)}
 	expected = append(expected, encodeVarInt(0xdeadbeef)...)
 	require.Equal(t, expected, b)
 	require.Equal(t, protocol.ByteCount(1+quicvarint.Len(uint64(frame.MaximumData))), frame.Length(protocol.Version1))
diff --git a/internal/wire/frame_parser_test.go b/internal/wire/frame_parser_test.go
index fefb5c11..821bc5d6 100644
--- a/internal/wire/frame_parser_test.go
+++ b/internal/wire/frame_parser_test.go
@@ -3,22 +3,31 @@ package wire
 import (
 	"bytes"
 	"crypto/rand"
+	"fmt"
+	"io"
 	"slices"
 	"testing"
 	"time"
 
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
-
 	"github.com/stretchr/testify/require"
 )
 
-func TestFrameParsingReturnsNilWhenNothingToRead(t *testing.T) {
+func TestFrameTypeParsingReturnsNilWhenNothingToRead(t *testing.T) {
 	parser := NewFrameParser(true, true)
-	l, f, err := parser.ParseNext(nil, protocol.Encryption1RTT, protocol.Version1)
-	require.NoError(t, err)
+	frameType, l, err := parser.ParseType(nil, protocol.Encryption1RTT)
+	require.Equal(t, io.EOF, err)
+	require.Zero(t, frameType)
+	require.Zero(t, l)
+}
+
+func TestParseLessCommonFrameReturnsEOFWhenNothingToRead(t *testing.T) {
+	parser := NewFrameParser(true, true)
+	l, f, err := parser.ParseLessCommonFrame(FrameTypeMaxStreamData, nil, protocol.Version1)
+	require.IsType(t, &qerr.TransportError{}, err)
 	require.Zero(t, l)
-	require.Nil(t, f)
+	require.Zero(t, f)
 }
 
 func TestFrameParsingSkipsPaddingFrames(t *testing.T) {
@@ -26,17 +35,24 @@ func TestFrameParsingSkipsPaddingFrames(t *testing.T) {
 	b := []byte{0, 0} // 2 PADDING frames
 	b, err := (&PingFrame{}).Append(b, protocol.Version1)
 	require.NoError(t, err)
-	l, f, err := parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
 	require.NoError(t, err)
-	require.Equal(t, &PingFrame{}, f)
-	require.Equal(t, 2+1, l)
+	require.Equal(t, 3, l)
+	require.Equal(t, FrameTypePing, frameType)
+
+	frame, l, err := parser.ParseLessCommonFrame(frameType, b[1:], protocol.Version1)
+	require.NoError(t, err)
+	require.Zero(t, l)
+	require.IsType(t, &PingFrame{}, frame)
 }
 
 func TestFrameParsingHandlesPaddingAtEnd(t *testing.T) {
 	parser := NewFrameParser(true, true)
-	l, f, err := parser.ParseNext([]byte{0, 0, 0}, protocol.Encryption1RTT, protocol.Version1)
-	require.NoError(t, err)
-	require.Nil(t, f)
+	b := []byte{0, 0, 0}
+
+	_, l, err := parser.ParseType(b, protocol.Encryption1RTT)
+	require.Equal(t, io.EOF, err)
 	require.Equal(t, 3, l)
 }
 
@@ -48,10 +64,15 @@ func TestFrameParsingParsesSingleFrame(t *testing.T) {
 		b, err = (&PingFrame{}).Append(b, protocol.Version1)
 		require.NoError(t, err)
 	}
-	l, f, err := parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
 	require.NoError(t, err)
-	require.IsType(t, &PingFrame{}, f)
+	require.Equal(t, FrameTypePing, frameType)
 	require.Equal(t, 1, l)
+
+	frame, l, err := parser.ParseLessCommonFrame(frameType, b, protocol.Version1)
+	require.NoError(t, err)
+	require.Zero(t, l)
+	require.IsType(t, &PingFrame{}, frame)
 }
 
 func TestFrameParserACK(t *testing.T) {
@@ -59,12 +80,16 @@ func TestFrameParserACK(t *testing.T) {
 	f := &AckFrame{AckRanges: []AckRange{{Smallest: 1, Largest: 0x13}}}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	l, frame, err := parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
+	require.NoError(t, err)
+	require.Equal(t, FrameTypeAck, frameType)
+	require.Equal(t, 1, l)
+
+	frame, l, err := parser.ParseAckFrame(frameType, b[l:], protocol.Encryption1RTT, protocol.Version1)
 	require.NoError(t, err)
 	require.NotNil(t, frame)
-	require.IsType(t, f, frame)
-	require.Equal(t, protocol.PacketNumber(0x13), frame.(*AckFrame).LargestAcked())
-	require.Equal(t, len(b), l)
+	require.Equal(t, protocol.PacketNumber(0x13), frame.LargestAcked())
+	require.Equal(t, len(b)-1, l)
 }
 
 func TestFrameParserAckDelay(t *testing.T) {
@@ -85,15 +110,31 @@ func testFrameParserAckDelay(t *testing.T, encLevel protocol.EncryptionLevel) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	_, frame, err := parser.ParseNext(b, encLevel, protocol.Version1)
+	frameType, l, err := parser.ParseType(b, encLevel)
+	require.NoError(t, err)
+	require.Equal(t, FrameTypeAck, frameType)
+	require.Equal(t, 1, l)
+
+	frame, l, err := parser.ParseAckFrame(frameType, b[l:], encLevel, protocol.Version1)
 	require.NoError(t, err)
+	require.Equal(t, len(b)-1, l)
 	if encLevel == protocol.Encryption1RTT {
-		require.Equal(t, 4*time.Second, frame.(*AckFrame).DelayTime)
+		require.Equal(t, 4*time.Second, frame.DelayTime)
 	} else {
-		require.Equal(t, time.Second, frame.(*AckFrame).DelayTime)
+		require.Equal(t, time.Second, frame.DelayTime)
 	}
 }
 
+func checkFrameUnsupported(t *testing.T, err error, expectedFrameType uint64) {
+	t.Helper()
+	require.ErrorContains(t, err, errUnknownFrameType.Error())
+	var transportErr *qerr.TransportError
+	require.ErrorAs(t, err, &transportErr)
+	require.Equal(t, qerr.FrameEncodingError, transportErr.ErrorCode)
+	require.Equal(t, expectedFrameType, transportErr.FrameType)
+	require.Equal(t, "unknown frame type", transportErr.ErrorMessage)
+}
+
 func TestFrameParserStreamFrames(t *testing.T) {
 	parser := NewFrameParser(true, true)
 	f := &StreamFrame{
@@ -104,28 +145,95 @@ func TestFrameParserStreamFrames(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	l, frame, err := parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
 	require.NoError(t, err)
-	require.NotNil(t, frame)
-	require.Equal(t, f, frame)
-	require.Equal(t, len(b), l)
+	require.Equal(t, FrameType(0xd), frameType)
+	require.True(t, frameType.IsStreamFrameType())
+	require.Equal(t, 1, l)
+
+	// ParseLessCommonFrame should not handle Stream Frames
+	frame, l, err := parser.ParseLessCommonFrame(frameType, b[l:], protocol.Version1)
+	checkFrameUnsupported(t, err, 0xd)
+	require.Nil(t, frame)
+	require.Zero(t, l)
+}
+
+func TestParseStreamFrameWrapsError(t *testing.T) {
+	parser := NewFrameParser(true, true)
+	f := &StreamFrame{
+		StreamID:       0x1234,
+		Offset:         0x1000,
+		Data:           []byte("hello world"),
+		DataLenPresent: true,
+	}
+	b, err := f.Append(nil, protocol.Version1)
+	require.NoError(t, err)
+
+	// Corrupt the buffer to trigger a parse error
+	b = b[:len(b)-2] // Remove last 2 bytes to cause an EOF
+
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
+	require.NoError(t, err)
+
+	frame, n, err := parser.ParseStreamFrame(frameType, b[l:], protocol.Version1)
+	require.Nil(t, frame)
+	require.Zero(t, n)
+
+	var transportErr *qerr.TransportError
+	require.ErrorAs(t, err, &transportErr)
+	require.Equal(t, qerr.FrameEncodingError, transportErr.ErrorCode)
+	require.Equal(t, uint64(frameType), transportErr.FrameType)
+	require.Contains(t, transportErr.Error(), "EOF")
+}
+
+func TestParseStreamFrameSuccess(t *testing.T) {
+	parser := NewFrameParser(true, true)
+	original := &StreamFrame{
+		StreamID:       0x1234,
+		Offset:         0x1000,
+		Fin:            true,
+		Data:           []byte("hello world"),
+		DataLenPresent: true,
+	}
+	b, err := original.Append(nil, protocol.Version1)
+	require.NoError(t, err)
+
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
+	require.NoError(t, err)
+	require.True(t, frameType.IsStreamFrameType())
+	require.Equal(t, FrameType(0x0f), frameType) // STREAM | OFF | LEN | FIN
+
+	parsed, n, err := parser.ParseStreamFrame(frameType, b[l:], protocol.Version1)
+	require.NoError(t, err)
+	require.NotNil(t, parsed)
+	require.Equal(t, len(b)-l, n)
+
+	require.Equal(t, original.StreamID, parsed.StreamID)
+	require.Equal(t, original.Offset, parsed.Offset)
+	require.Equal(t, original.Fin, parsed.Fin)
+	require.Equal(t, original.DataLenPresent, parsed.DataLenPresent)
+	require.Equal(t, original.Data, parsed.Data)
 }
 
 func TestFrameParserFrames(t *testing.T) {
 	tests := []struct {
-		name  string
-		frame Frame
+		name      string
+		frameType FrameType
+		frame     Frame
 	}{
 		{
-			name:  "MAX_DATA",
-			frame: &MaxDataFrame{MaximumData: 0xcafe},
+			name:      "MAX_DATA",
+			frameType: FrameTypeMaxData,
+			frame:     &MaxDataFrame{MaximumData: 0xcafe},
 		},
 		{
-			name:  "MAX_STREAM_DATA",
-			frame: &MaxStreamDataFrame{StreamID: 0xdeadbeef, MaximumStreamData: 0xdecafbad},
+			name:      "MAX_STREAM_DATA",
+			frameType: FrameTypeMaxStreamData,
+			frame:     &MaxStreamDataFrame{StreamID: 0xdeadbeef, MaximumStreamData: 0xdecafbad},
 		},
 		{
-			name: "RESET_STREAM",
+			name:      "RESET_STREAM",
+			frameType: FrameTypeResetStream,
 			frame: &ResetStreamFrame{
 				StreamID:  0xdeadbeef,
 				FinalSize: 0xdecafbad1234,
@@ -133,35 +241,43 @@ func TestFrameParserFrames(t *testing.T) {
 			},
 		},
 		{
-			name:  "STOP_SENDING",
-			frame: &StopSendingFrame{StreamID: 0x42},
+			name:      "STOP_SENDING",
+			frameType: FrameTypeStopSending,
+			frame:     &StopSendingFrame{StreamID: 0x42},
 		},
 		{
-			name:  "CRYPTO",
-			frame: &CryptoFrame{Offset: 0x1337, Data: []byte("lorem ipsum")},
+			name:      "CRYPTO",
+			frameType: FrameTypeCrypto,
+			frame:     &CryptoFrame{Offset: 0x1337, Data: []byte("lorem ipsum")},
 		},
 		{
-			name:  "NEW_TOKEN",
-			frame: &NewTokenFrame{Token: []byte("foobar")},
+			name:      "NEW_TOKEN",
+			frameType: FrameTypeNewToken,
+			frame:     &NewTokenFrame{Token: []byte("foobar")},
 		},
 		{
-			name:  "MAX_STREAMS",
-			frame: &MaxStreamsFrame{Type: protocol.StreamTypeBidi, MaxStreamNum: 0x1337},
+			name:      "MAX_STREAMS",
+			frameType: FrameTypeBidiMaxStreams,
+			frame:     &MaxStreamsFrame{Type: protocol.StreamTypeBidi, MaxStreamNum: 0x1337},
 		},
 		{
-			name:  "DATA_BLOCKED",
-			frame: &DataBlockedFrame{MaximumData: 0x1234},
+			name:      "DATA_BLOCKED",
+			frameType: FrameTypeDataBlocked,
+			frame:     &DataBlockedFrame{MaximumData: 0x1234},
 		},
 		{
-			name:  "STREAM_DATA_BLOCKED",
-			frame: &StreamDataBlockedFrame{StreamID: 0xdeadbeef, MaximumStreamData: 0xdead},
+			name:      "STREAM_DATA_BLOCKED",
+			frameType: FrameTypeStreamDataBlocked,
+			frame:     &StreamDataBlockedFrame{StreamID: 0xdeadbeef, MaximumStreamData: 0xdead},
 		},
 		{
-			name:  "STREAMS_BLOCKED",
-			frame: &StreamsBlockedFrame{Type: protocol.StreamTypeBidi, StreamLimit: 0x1234567},
+			name:      "STREAMS_BLOCKED",
+			frameType: FrameTypeBidiStreamBlocked,
+			frame:     &StreamsBlockedFrame{Type: protocol.StreamTypeBidi, StreamLimit: 0x1234567},
 		},
 		{
-			name: "NEW_CONNECTION_ID",
+			name:      "NEW_CONNECTION_ID",
+			frameType: FrameTypeNewConnectionID,
 			frame: &NewConnectionIDFrame{
 				SequenceNumber:      0x1337,
 				ConnectionID:        protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef}),
@@ -169,32 +285,39 @@ func TestFrameParserFrames(t *testing.T) {
 			},
 		},
 		{
-			name:  "RETIRE_CONNECTION_ID",
-			frame: &RetireConnectionIDFrame{SequenceNumber: 0x1337},
+			name:      "RETIRE_CONNECTION_ID",
+			frameType: FrameTypeRetireConnectionID,
+			frame:     &RetireConnectionIDFrame{SequenceNumber: 0x1337},
 		},
 		{
-			name:  "PATH_CHALLENGE",
-			frame: &PathChallengeFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}},
+			name:      "PATH_CHALLENGE",
+			frameType: FrameTypePathChallenge,
+			frame:     &PathChallengeFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}},
 		},
 		{
-			name:  "PATH_RESPONSE",
-			frame: &PathResponseFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}},
+			name:      "PATH_RESPONSE",
+			frameType: FrameTypePathResponse,
+			frame:     &PathResponseFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}},
 		},
 		{
-			name:  "CONNECTION_CLOSE",
-			frame: &ConnectionCloseFrame{IsApplicationError: true, ReasonPhrase: "foobar"},
+			name:      "CONNECTION_CLOSE",
+			frameType: FrameTypeConnectionClose,
+			frame:     &ConnectionCloseFrame{IsApplicationError: false, ReasonPhrase: "foobar"},
 		},
 		{
-			name:  "HANDSHAKE_DONE",
-			frame: &HandshakeDoneFrame{},
+			name:      "APPLICATION_CLOSE",
+			frameType: FrameTypeApplicationClose,
+			frame:     &ConnectionCloseFrame{IsApplicationError: true, ReasonPhrase: "foobar"},
 		},
 		{
-			name:  "DATAGRAM",
-			frame: &DatagramFrame{Data: []byte("foobar")},
+			name:      "HANDSHAKE_DONE",
+			frameType: FrameTypeHandshakeDone,
+			frame:     &HandshakeDoneFrame{},
 		},
 		{
-			name:  "RESET_STREAM_AT",
-			frame: &ResetStreamFrame{StreamID: 0x1337, ReliableSize: 0x42, FinalSize: 0xdeadbeef},
+			name:      "RESET_STREAM_AT",
+			frameType: FrameTypeResetStreamAt,
+			frame:     &ResetStreamFrame{StreamID: 0x1337, ReliableSize: 0x42, FinalSize: 0xdeadbeef},
 		},
 	}
 
@@ -203,22 +326,173 @@ func TestFrameParserFrames(t *testing.T) {
 			parser := NewFrameParser(true, true)
 			b, err := test.frame.Append(nil, protocol.Version1)
 			require.NoError(t, err)
-			l, frame, err := parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+
+			frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
+			require.NoError(t, err)
+			require.Equal(t, test.frameType, frameType)
+			require.Equal(t, 1, l)
+
+			frame, l, err := parser.ParseLessCommonFrame(frameType, b[l:], protocol.Version1)
 			require.NoError(t, err)
 			require.Equal(t, test.frame, frame)
-			require.Equal(t, len(b), l)
+			require.Equal(t, len(b)-1, l)
 		})
 	}
 }
 
-func checkFrameUnsupported(t *testing.T, err error, expectedFrameType uint64) {
-	t.Helper()
-	require.ErrorContains(t, err, errUnknownFrameType.Error())
-	var transportErr *qerr.TransportError
-	require.ErrorAs(t, err, &transportErr)
-	require.Equal(t, qerr.FrameEncodingError, transportErr.ErrorCode)
-	require.Equal(t, expectedFrameType, transportErr.FrameType)
-	require.Equal(t, "unknown frame type", transportErr.ErrorMessage)
+func TestFrameAllowedAtEncLevel(t *testing.T) {
+	type testCase struct {
+		name             string
+		frameType        FrameType
+		frame            Frame
+		allowedInitial   bool
+		allowedHandshake bool
+		allowedZeroRTT   bool
+		allowedOneRTT    bool
+	}
+
+	for _, tc := range []testCase{
+		{
+			name:             "CRYPTO_FRAME",
+			frameType:        FrameTypeCrypto,
+			frame:            &CryptoFrame{Offset: 0, Data: []byte("foo")},
+			allowedInitial:   true,
+			allowedHandshake: true,
+			allowedZeroRTT:   false,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "ACK_FRAME",
+			frameType:        FrameTypeAck,
+			frame:            &AckFrame{AckRanges: []AckRange{{Smallest: 1, Largest: 1}}},
+			allowedInitial:   true,
+			allowedHandshake: true,
+			allowedZeroRTT:   false,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "CONNECTION_CLOSE_FRAME",
+			frameType:        FrameTypeConnectionClose,
+			frame:            &ConnectionCloseFrame{IsApplicationError: false, ReasonPhrase: "err"},
+			allowedInitial:   true,
+			allowedHandshake: true,
+			allowedZeroRTT:   false,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "PING_FRAME",
+			frameType:        FrameTypePing,
+			frame:            &PingFrame{},
+			allowedInitial:   true,
+			allowedHandshake: true,
+			allowedZeroRTT:   true,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "NEW_TOKEN_FRAME",
+			frameType:        FrameTypeNewToken,
+			frame:            &NewTokenFrame{Token: []byte("tok")},
+			allowedInitial:   false,
+			allowedHandshake: false,
+			allowedZeroRTT:   false,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "PATH_RESPONSE_FRAME",
+			frameType:        FrameTypePathResponse,
+			frame:            &PathResponseFrame{Data: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}},
+			allowedInitial:   false,
+			allowedHandshake: false,
+			allowedZeroRTT:   false,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "RETIRE_CONNECTION_ID_FRAME",
+			frameType:        FrameTypeRetireConnectionID,
+			frame:            &RetireConnectionIDFrame{SequenceNumber: 1},
+			allowedInitial:   false,
+			allowedHandshake: false,
+			allowedZeroRTT:   false,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "MAX_DATA_FRAME",
+			frameType:        FrameTypeMaxData,
+			frame:            &MaxDataFrame{MaximumData: 1},
+			allowedInitial:   false,
+			allowedHandshake: false,
+			allowedZeroRTT:   true,
+			allowedOneRTT:    true,
+		},
+		{
+			name:             "STREAM_FRAME",
+			frameType:        FrameType(0x8),
+			frame:            &StreamFrame{StreamID: 1, Data: []byte("foobar")},
+			allowedInitial:   false,
+			allowedHandshake: false,
+			allowedZeroRTT:   true,
+			allowedOneRTT:    true,
+		},
+	} {
+		for _, encLevel := range []protocol.EncryptionLevel{
+			protocol.EncryptionInitial,
+			protocol.EncryptionHandshake,
+			protocol.Encryption0RTT,
+			protocol.Encryption1RTT,
+		} {
+			t.Run(fmt.Sprintf("%s/%v", tc.name, encLevel), func(t *testing.T) {
+				var allowed bool
+				switch encLevel {
+				case protocol.EncryptionInitial:
+					allowed = tc.allowedInitial
+				case protocol.EncryptionHandshake:
+					allowed = tc.allowedHandshake
+				case protocol.Encryption0RTT:
+					allowed = tc.allowedZeroRTT
+				case protocol.Encryption1RTT:
+					allowed = tc.allowedOneRTT
+				}
+
+				parser := NewFrameParser(true, true)
+				b, err := tc.frame.Append(nil, protocol.Version1)
+				require.NoError(t, err)
+				frameType, _, err := parser.ParseType(b, encLevel)
+				if allowed {
+					require.NoError(t, err)
+					require.Equal(t, tc.frameType, frameType)
+				} else {
+					require.Error(t, err)
+					var transportErr *qerr.TransportError
+					require.ErrorAs(t, err, &transportErr)
+					require.Equal(t, qerr.FrameEncodingError, transportErr.ErrorCode)
+				}
+			})
+		}
+	}
+}
+
+func TestFrameParserDatagramFrame(t *testing.T) {
+	parser := NewFrameParser(true, true)
+	f := &DatagramFrame{
+		Data: []byte("foobar"),
+	}
+	b, err := f.Append(nil, protocol.Version1)
+	require.NoError(t, err)
+	frameType, l, err := parser.ParseType(b, protocol.Encryption1RTT)
+	require.NoError(t, err)
+	require.Equal(t, FrameTypeDatagramNoLength, frameType)
+	require.Equal(t, 1, l)
+
+	// ParseLessCommonFrame should not be used to handle DATAGRAM frames
+	_, _, err = parser.ParseLessCommonFrame(frameType, b[l:], protocol.Version1)
+	require.Error(t, err)
+
+	// parseDatagramFrame should be used for this type
+	datagramFrame, l, err := parser.ParseDatagramFrame(frameType, b[l:], protocol.Version1)
+	require.NoError(t, err)
+	require.IsType(t, &DatagramFrame{}, datagramFrame)
+	require.Equal(t, 6, l)
+	require.Equal(t, f.Data, datagramFrame.Data)
 }
 
 func TestFrameParserDatagramUnsupported(t *testing.T) {
@@ -226,7 +500,8 @@ func TestFrameParserDatagramUnsupported(t *testing.T) {
 	f := &DatagramFrame{Data: []byte("foobar")}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	_, _, err = parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+
+	_, _, err = parser.ParseType(b, protocol.Encryption1RTT)
 	checkFrameUnsupported(t, err, 0x30)
 }
 
@@ -235,14 +510,22 @@ func TestFrameParserResetStreamAtUnsupported(t *testing.T) {
 	f := &ResetStreamFrame{StreamID: 0x1337, ReliableSize: 0x42, FinalSize: 0xdeadbeef}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	_, _, err = parser.ParseNext(b, protocol.Encryption1RTT, protocol.Version1)
+
+	_, _, err = parser.ParseType(b, protocol.Encryption1RTT)
 	checkFrameUnsupported(t, err, 0x24)
 }
 
 func TestFrameParserInvalidFrameType(t *testing.T) {
 	parser := NewFrameParser(true, true)
-	_, _, err := parser.ParseNext(encodeVarInt(0x42), protocol.Encryption1RTT, protocol.Version1)
-	checkFrameUnsupported(t, err, 0x42)
+
+	_, l, err := parser.ParseType(encodeVarInt(0x42), protocol.Encryption1RTT)
+
+	require.Equal(t, 2, l)
+
+	require.Error(t, err)
+	var transportErr *qerr.TransportError
+	require.ErrorAs(t, err, &transportErr)
+	require.Equal(t, qerr.FrameEncodingError, transportErr.ErrorCode)
 }
 
 func TestFrameParsingErrorsOnInvalidFrames(t *testing.T) {
@@ -253,7 +536,13 @@ func TestFrameParsingErrorsOnInvalidFrames(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	_, _, err = parser.ParseNext(b[:len(b)-2], protocol.Encryption1RTT, protocol.Version1)
+
+	frameType, l, err := parser.ParseType(b[:len(b)-2], protocol.Encryption1RTT)
+	require.NoError(t, err)
+	require.Equal(t, FrameTypeMaxStreamData, frameType)
+	require.Equal(t, 1, l)
+
+	_, _, err = parser.ParseLessCommonFrame(frameType, b[1:len(b)-2], protocol.Version1)
 	require.Error(t, err)
 	var transportErr *qerr.TransportError
 	require.ErrorAs(t, err, &transportErr)
@@ -274,102 +563,160 @@ func writeFrames(tb testing.TB, frames ...Frame) []byte {
 // We can therefore not use the require framework, as it allocates.
 func parseFrames(tb testing.TB, parser *FrameParser, data []byte, frames ...Frame) {
 	for _, expectedFrame := range frames {
-		l, frame, err := parser.ParseNext(data, protocol.Encryption1RTT, protocol.Version1)
+		frameType, l, err := parser.ParseType(data, protocol.Encryption1RTT)
 		if err != nil {
 			tb.Fatal(err)
 		}
 		data = data[l:]
-		if frame == nil {
-			break
-		}
 
-		// Use type switch approach (like master branch)
-		switch f := frame.(type) {
-		case *StreamFrame:
+		if frameType.IsStreamFrameType() {
 			sf := expectedFrame.(*StreamFrame)
-			if sf.StreamID != f.StreamID || sf.Offset != f.Offset || !bytes.Equal(sf.Data, f.Data) {
-				tb.Fatalf("STREAM frame does not match: %v vs %v", sf, f)
+			frame, l, err := ParseStreamFrame(data, frameType, protocol.Version1)
+			if err != nil {
+				tb.Fatal(err)
+			}
+			if sf.StreamID != frame.StreamID || sf.Offset != frame.Offset {
+				tb.Fatalf("STREAM frame does not match: %v vs %v", sf, frame)
 			}
-			f.PutBack()
-		case *AckFrame:
+			frame.PutBack()
+			data = data[l:]
+			continue
+		}
+
+		if frameType.IsAckFrameType() {
 			af, ok := expectedFrame.(*AckFrame)
 			if !ok {
 				tb.Fatalf("expected ACK, but got %v", expectedFrame)
 			}
+
+			f, l, err := parser.ParseAckFrame(frameType, data, protocol.Encryption1RTT, protocol.Version1)
 			if f.DelayTime != af.DelayTime || f.ECNCE != af.ECNCE || f.ECT0 != af.ECT0 || f.ECT1 != af.ECT1 {
+				tb.Fatal(err)
+			}
+			if f.DelayTime != af.DelayTime {
 				tb.Fatalf("ACK frame does not match: %v vs %v", af, f)
 			}
 			if !slices.Equal(f.AckRanges, af.AckRanges) {
 				tb.Fatalf("ACK frame ACK ranges don't match: %v vs %v", af, f)
 			}
-		case *DatagramFrame:
+			data = data[l:]
+			continue
+		}
+
+		if frameType.IsDatagramFrameType() {
 			df, ok := expectedFrame.(*DatagramFrame)
 			if !ok {
 				tb.Fatalf("expected DATAGRAM, but got %v", expectedFrame)
 			}
+
+			f, l, err := parser.ParseDatagramFrame(frameType, data, protocol.Version1)
+			if err != nil {
+				tb.Fatal(err)
+			}
 			if df.DataLenPresent != f.DataLenPresent || !bytes.Equal(df.Data, f.Data) {
 				tb.Fatalf("DATAGRAM frame does not match: %v vs %v", df, f)
 			}
-		case *MaxDataFrame:
+			data = data[l:]
+			continue
+		}
+
+		f, l, err := parser.ParseLessCommonFrame(frameType, data, protocol.Version1)
+		if err != nil {
+			tb.Fatal(err)
+		}
+		data = data[l:]
+
+		switch frameType {
+		case FrameTypeMaxData:
 			mdf, ok := expectedFrame.(*MaxDataFrame)
 			if !ok {
 				tb.Fatalf("expected MAX_DATA, but got %v", expectedFrame)
 			}
-			if *f != *mdf {
+			if *f.(*MaxDataFrame) != *mdf {
 				tb.Fatalf("MAX_DATA frame does not match: %v vs %v", f, mdf)
 			}
-		case *MaxStreamsFrame:
+		case FrameTypeUniMaxStreams:
 			msf, ok := expectedFrame.(*MaxStreamsFrame)
 			if !ok {
 				tb.Fatalf("expected MAX_STREAMS, but got %v", expectedFrame)
 			}
-			if *f != *msf {
+			if *f.(*MaxStreamsFrame) != *msf {
 				tb.Fatalf("MAX_STREAMS frame does not match: %v vs %v", f, msf)
 			}
-		case *MaxStreamDataFrame:
+		case FrameTypeMaxStreamData:
 			mdf, ok := expectedFrame.(*MaxStreamDataFrame)
 			if !ok {
 				tb.Fatalf("expected MAX_STREAM_DATA, but got %v", expectedFrame)
 			}
-			if *f != *mdf {
+			if *f.(*MaxStreamDataFrame) != *mdf {
 				tb.Fatalf("MAX_STREAM_DATA frame does not match: %v vs %v", f, mdf)
 			}
-		case *CryptoFrame:
+		case FrameTypeCrypto:
 			cf, ok := expectedFrame.(*CryptoFrame)
 			if !ok {
 				tb.Fatalf("expected CRYPTO, but got %v", expectedFrame)
 			}
-			if f.Offset != cf.Offset || !bytes.Equal(f.Data, cf.Data) {
+			frame := f.(*CryptoFrame)
+			if frame.Offset != cf.Offset || !bytes.Equal(frame.Data, cf.Data) {
 				tb.Fatalf("CRYPTO frame does not match: %v vs %v", f, cf)
 			}
-		case *PingFrame:
-			_ = f
-		case *ResetStreamFrame:
+		case FrameTypePing:
+			_ = f.(*PingFrame)
+		case FrameTypeResetStream:
 			rsf, ok := expectedFrame.(*ResetStreamFrame)
 			if !ok {
 				tb.Fatalf("expected RESET_STREAM, but got %v", expectedFrame)
 			}
-			if *f != *rsf {
+			if *f.(*ResetStreamFrame) != *rsf {
 				tb.Fatalf("RESET_STREAM frame does not match: %v vs %v", f, rsf)
 			}
+			continue
 		default:
-			tb.Fatalf("Frame type not supported in benchmark: %T", f)
+			tb.Fatalf("Frame type not supported in benchmark or should not occur: %v", frameType)
 		}
 	}
 }
 
-func benchmarkFrames(b *testing.B, frames ...Frame) {
-	buf := writeFrames(b, frames...)
+func TestFrameParserAllocs(t *testing.T) {
+	t.Run("STREAM", func(t *testing.T) {
+		var frames []Frame
+		for i := range 10 {
+			frames = append(frames, &StreamFrame{
+				StreamID:       protocol.StreamID(1337 + i),
+				Offset:         protocol.ByteCount(1e7 + i),
+				Data:           make([]byte, 200+i),
+				DataLenPresent: true,
+			})
+		}
+		require.Zero(t, testFrameParserAllocs(t, frames))
+	})
 
+	t.Run("ACK", func(t *testing.T) {
+		var frames []Frame
+		for i := range 10 {
+			frames = append(frames, &AckFrame{
+				AckRanges: []AckRange{
+					{Smallest: protocol.PacketNumber(5000 + i), Largest: protocol.PacketNumber(5200 + i)},
+					{Smallest: protocol.PacketNumber(1 + i), Largest: protocol.PacketNumber(4200 + i)},
+				},
+				DelayTime: time.Duration(int64(time.Millisecond) * int64(i)),
+				ECT0:      uint64(5000 + i),
+				ECT1:      uint64(i),
+				ECNCE:     uint64(10 + i),
+			})
+		}
+		require.Zero(t, testFrameParserAllocs(t, frames))
+	})
+}
+
+func testFrameParserAllocs(t *testing.T, frames []Frame) float64 {
+	buf := writeFrames(t, frames...)
 	parser := NewFrameParser(true, true)
 	parser.SetAckDelayExponent(3)
 
-	b.ResetTimer()
-	b.ReportAllocs()
-
-	for range b.N {
-		parseFrames(b, parser, buf, frames...)
-	}
+	return testing.AllocsPerRun(100, func() {
+		parseFrames(t, parser, buf, frames...)
+	})
 }
 
 func BenchmarkParseOtherFrames(b *testing.B) {
@@ -428,3 +775,17 @@ func BenchmarkParseDatagramFrame(b *testing.B) {
 	}
 	benchmarkFrames(b, frames...)
 }
+
+func benchmarkFrames(b *testing.B, frames ...Frame) {
+	buf := writeFrames(b, frames...)
+
+	parser := NewFrameParser(true, true)
+	parser.SetAckDelayExponent(3)
+
+	b.ResetTimer()
+	b.ReportAllocs()
+
+	for range b.N {
+		parseFrames(b, parser, buf, frames...)
+	}
+}
diff --git a/internal/wire/frame_test.go b/internal/wire/frame_test.go
index 3012b558..5dbfba89 100644
--- a/internal/wire/frame_test.go
+++ b/internal/wire/frame_test.go
@@ -27,3 +27,16 @@ func TestProbingFrames(t *testing.T) {
 		require.Equal(t, expected, IsProbingFrame(f))
 	}
 }
+
+func TestIsProbingFrameType(t *testing.T) {
+	tests := map[FrameType]bool{
+		FrameTypePathChallenge:   true,
+		FrameTypePathResponse:    true,
+		FrameTypeNewConnectionID: true,
+		FrameType(0x01):          false,
+		FrameType(0xFF):          false,
+	}
+	for ft, expected := range tests {
+		require.Equal(t, expected, IsProbingFrameType(ft))
+	}
+}
diff --git a/internal/wire/frame_type_test.go b/internal/wire/frame_type_test.go
new file mode 100644
index 00000000..2702af37
--- /dev/null
+++ b/internal/wire/frame_type_test.go
@@ -0,0 +1,29 @@
+package wire
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestIsStreamFrameType(t *testing.T) {
+	for i := 0x08; i <= 0x0f; i++ {
+		require.Truef(t, FrameType(i).IsStreamFrameType(), "FrameType(0x%x).IsStreamFrameType() = false, want true", i)
+	}
+
+	require.False(t, FrameType(0x1).IsStreamFrameType())
+}
+
+func TestIsAckFrameType(t *testing.T) {
+	require.True(t, FrameTypeAck.IsAckFrameType(), "AckFrameType should be recognized as ACK")
+	require.True(t, FrameTypeAckECN.IsAckFrameType(), "AckECNFrameType should be recognized as ACK")
+	require.False(t, FrameTypePing.IsAckFrameType(), "PingFrameType should not be recognized as ACK")
+	require.False(t, FrameType(0x10).IsAckFrameType(), "MaxDataFrameType should not be recognized as ACK")
+}
+
+func TestIsDatagramFrameType(t *testing.T) {
+	require.True(t, FrameTypeDatagramNoLength.IsDatagramFrameType(), "DatagramNoLengthFrameType should be recognized as DATAGRAM")
+	require.True(t, FrameTypeDatagramWithLength.IsDatagramFrameType(), "DatagramWithLengthFrameType should be recognized as DATAGRAM")
+	require.False(t, FrameTypePing.IsDatagramFrameType(), "PingFrameType should not be recognized as DATAGRAM")
+	require.False(t, FrameType(0x1e).IsDatagramFrameType(), "HandshakeDoneFrameType should not be recognized as DATAGRAM")
+}
diff --git a/internal/wire/handshake_done_frame_test.go b/internal/wire/handshake_done_frame_test.go
index 51381df4..bec44ec9 100644
--- a/internal/wire/handshake_done_frame_test.go
+++ b/internal/wire/handshake_done_frame_test.go
@@ -11,6 +11,6 @@ func TestWriteHandshakeDoneSampleFrame(t *testing.T) {
 	frame := HandshakeDoneFrame{}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	require.Equal(t, []byte{handshakeDoneFrameType}, b)
+	require.Equal(t, []byte{byte(FrameTypeHandshakeDone)}, b)
 	require.Equal(t, protocol.ByteCount(1), frame.Length(protocol.Version1))
 }
diff --git a/internal/wire/max_data_frame_test.go b/internal/wire/max_data_frame_test.go
index 2c806089..a6cb3d25 100644
--- a/internal/wire/max_data_frame_test.go
+++ b/internal/wire/max_data_frame_test.go
@@ -32,7 +32,7 @@ func TestWriteMaxDataFrame(t *testing.T) {
 	f := &MaxDataFrame{MaximumData: 0xdeadbeefcafe}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{maxDataFrameType}
+	expected := []byte{byte(FrameTypeMaxData)}
 	expected = append(expected, encodeVarInt(0xdeadbeefcafe)...)
 	require.Equal(t, expected, b)
 	require.Len(t, b, int(f.Length(protocol.Version1)))
diff --git a/internal/wire/max_stream_data_frame_test.go b/internal/wire/max_stream_data_frame_test.go
index 05559ebf..f4757da5 100644
--- a/internal/wire/max_stream_data_frame_test.go
+++ b/internal/wire/max_stream_data_frame_test.go
@@ -36,7 +36,7 @@ func TestWriteMaxStreamDataFrame(t *testing.T) {
 		StreamID:          0xdecafbad,
 		MaximumStreamData: 0xdeadbeefcafe42,
 	}
-	expected := []byte{maxStreamDataFrameType}
+	expected := []byte{byte(FrameTypeMaxStreamData)}
 	expected = append(expected, encodeVarInt(0xdecafbad)...)
 	expected = append(expected, encodeVarInt(0xdeadbeefcafe42)...)
 	b, err := f.Append(nil, protocol.Version1)
diff --git a/internal/wire/max_streams_frame_test.go b/internal/wire/max_streams_frame_test.go
index f5be03e1..7afbce2f 100644
--- a/internal/wire/max_streams_frame_test.go
+++ b/internal/wire/max_streams_frame_test.go
@@ -13,7 +13,7 @@ import (
 
 func TestParseMaxStreamsFrameBidirectional(t *testing.T) {
 	data := encodeVarInt(0xdecaf)
-	f, l, err := parseMaxStreamsFrame(data, bidiMaxStreamsFrameType, protocol.Version1)
+	f, l, err := parseMaxStreamsFrame(data, FrameTypeBidiMaxStreams, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamTypeBidi, f.Type)
 	require.EqualValues(t, 0xdecaf, f.MaxStreamNum)
@@ -22,7 +22,7 @@ func TestParseMaxStreamsFrameBidirectional(t *testing.T) {
 
 func TestParseMaxStreamsFrameUnidirectional(t *testing.T) {
 	data := encodeVarInt(0xdecaf)
-	f, l, err := parseMaxStreamsFrame(data, uniMaxStreamsFrameType, protocol.Version1)
+	f, l, err := parseMaxStreamsFrame(data, FrameTypeUniMaxStreams, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamTypeUni, f.Type)
 	require.EqualValues(t, 0xdecaf, f.MaxStreamNum)
@@ -59,7 +59,7 @@ func TestParseMaxStreamsMaxValue(t *testing.T) {
 			typ, l, err := quicvarint.Parse(b)
 			require.NoError(t, err)
 			b = b[l:]
-			frame, _, err := parseMaxStreamsFrame(b, typ, protocol.Version1)
+			frame, _, err := parseMaxStreamsFrame(b, FrameType(typ), protocol.Version1)
 			require.NoError(t, err)
 			require.Equal(t, f, frame)
 		})
@@ -84,7 +84,7 @@ func TestParseMaxStreamsErrorsOnTooLargeStreamCount(t *testing.T) {
 			typ, l, err := quicvarint.Parse(b)
 			require.NoError(t, err)
 			b = b[l:]
-			_, _, err = parseMaxStreamsFrame(b, typ, protocol.Version1)
+			_, _, err = parseMaxStreamsFrame(b, FrameType(typ), protocol.Version1)
 			require.EqualError(t, err, fmt.Sprintf("%d exceeds the maximum stream count", protocol.MaxStreamCount+1))
 		})
 	}
@@ -97,7 +97,7 @@ func TestWriteMaxStreamsBidirectional(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{bidiMaxStreamsFrameType}
+	expected := []byte{byte(FrameTypeBidiMaxStreams)}
 	expected = append(expected, encodeVarInt(0xdeadbeef)...)
 	require.Equal(t, expected, b)
 	require.Len(t, b, int(f.Length(protocol.Version1)))
@@ -110,7 +110,7 @@ func TestWriteMaxStreamsUnidirectional(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{uniMaxStreamsFrameType}
+	expected := []byte{byte(FrameTypeUniMaxStreams)}
 	expected = append(expected, encodeVarInt(0xdecafbad)...)
 	require.Equal(t, expected, b)
 	require.Len(t, b, int(f.Length(protocol.Version1)))
diff --git a/internal/wire/new_connection_id_frame_test.go b/internal/wire/new_connection_id_frame_test.go
index 739cf2cf..3292e0f3 100644
--- a/internal/wire/new_connection_id_frame_test.go
+++ b/internal/wire/new_connection_id_frame_test.go
@@ -77,7 +77,7 @@ func TestWriteNewConnectionIDFrame(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{newConnectionIDFrameType}
+	expected := []byte{byte(FrameTypeNewConnectionID)}
 	expected = append(expected, encodeVarInt(0x1337)...)
 	expected = append(expected, encodeVarInt(0x42)...)
 	expected = append(expected, 6)
diff --git a/internal/wire/new_token_frame_test.go b/internal/wire/new_token_frame_test.go
index 77da62e8..cd2ae3da 100644
--- a/internal/wire/new_token_frame_test.go
+++ b/internal/wire/new_token_frame_test.go
@@ -43,7 +43,7 @@ func TestWriteNewTokenFrame(t *testing.T) {
 	f := &NewTokenFrame{Token: []byte(token)}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{newTokenFrameType}
+	expected := []byte{byte(FrameTypeNewToken)}
 	expected = append(expected, encodeVarInt(uint64(len(token)))...)
 	expected = append(expected, token...)
 	require.Equal(t, expected, b)
diff --git a/internal/wire/path_challenge_frame_test.go b/internal/wire/path_challenge_frame_test.go
index 3a755e89..f5e521aa 100644
--- a/internal/wire/path_challenge_frame_test.go
+++ b/internal/wire/path_challenge_frame_test.go
@@ -32,6 +32,6 @@ func TestWritePathChallenge(t *testing.T) {
 	frame := PathChallengeFrame{Data: [8]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x13, 0x37}}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	require.Equal(t, []byte{pathChallengeFrameType, 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x13, 0x37}, b)
+	require.Equal(t, []byte{byte(FrameTypePathChallenge), 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x13, 0x37}, b)
 	require.Len(t, b, int(frame.Length(protocol.Version1)))
 }
diff --git a/internal/wire/path_response_frame_test.go b/internal/wire/path_response_frame_test.go
index d939b0a1..884c407d 100644
--- a/internal/wire/path_response_frame_test.go
+++ b/internal/wire/path_response_frame_test.go
@@ -32,6 +32,6 @@ func TestWritePathResponse(t *testing.T) {
 	frame := PathResponseFrame{Data: [8]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x13, 0x37}}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	require.Equal(t, []byte{pathResponseFrameType, 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x13, 0x37}, b)
+	require.Equal(t, []byte{byte(FrameTypePathResponse), 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x13, 0x37}, b)
 	require.Len(t, b, int(frame.Length(protocol.Version1)))
 }
diff --git a/internal/wire/reset_stream_frame_test.go b/internal/wire/reset_stream_frame_test.go
index 3f85a985..d85d616b 100644
--- a/internal/wire/reset_stream_frame_test.go
+++ b/internal/wire/reset_stream_frame_test.go
@@ -77,7 +77,7 @@ func TestWriteResetStream(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{resetStreamFrameType}
+	expected := []byte{byte(FrameTypeResetStream)}
 	expected = append(expected, encodeVarInt(0x1337)...)
 	expected = append(expected, encodeVarInt(0xcafe)...)
 	expected = append(expected, encodeVarInt(0x11223344decafbad)...)
@@ -94,7 +94,7 @@ func TestWriteResetStreamAt(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{resetStreamAtFrameType}
+	expected := []byte{byte(FrameTypeResetStreamAt)}
 	expected = append(expected, encodeVarInt(1337)...)
 	expected = append(expected, encodeVarInt(0xcafe)...)
 	expected = append(expected, encodeVarInt(42)...)
diff --git a/internal/wire/retire_connection_id_frame_test.go b/internal/wire/retire_connection_id_frame_test.go
index 9c76151c..e1b64ccf 100644
--- a/internal/wire/retire_connection_id_frame_test.go
+++ b/internal/wire/retire_connection_id_frame_test.go
@@ -32,7 +32,7 @@ func TestWriteRetireConnectionID(t *testing.T) {
 	frame := &RetireConnectionIDFrame{SequenceNumber: 0x1337}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{retireConnectionIDFrameType}
+	expected := []byte{byte(FrameTypeRetireConnectionID)}
 	expected = append(expected, encodeVarInt(0x1337)...)
 	require.Equal(t, expected, b)
 	require.Len(t, b, int(frame.Length(protocol.Version1)))
diff --git a/internal/wire/stop_sending_frame_test.go b/internal/wire/stop_sending_frame_test.go
index b670c047..90bc8a32 100644
--- a/internal/wire/stop_sending_frame_test.go
+++ b/internal/wire/stop_sending_frame_test.go
@@ -39,7 +39,7 @@ func TestWriteStopSendingFrame(t *testing.T) {
 	}
 	b, err := frame.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{stopSendingFrameType}
+	expected := []byte{byte(FrameTypeStopSending)}
 	expected = append(expected, encodeVarInt(0xdeadbeefcafe)...)
 	expected = append(expected, encodeVarInt(0xdecafbad)...)
 	require.Equal(t, expected, b)
diff --git a/internal/wire/stream_data_blocked_frame_test.go b/internal/wire/stream_data_blocked_frame_test.go
index b73e4581..325cb58f 100644
--- a/internal/wire/stream_data_blocked_frame_test.go
+++ b/internal/wire/stream_data_blocked_frame_test.go
@@ -38,7 +38,7 @@ func TestWriteStreamDataBlocked(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{streamDataBlockedFrameType}
+	expected := []byte{byte(FrameTypeStreamDataBlocked)}
 	expected = append(expected, encodeVarInt(uint64(f.StreamID))...)
 	expected = append(expected, encodeVarInt(uint64(f.MaximumStreamData))...)
 	require.Equal(t, expected, b)
diff --git a/internal/wire/stream_frame_test.go b/internal/wire/stream_frame_test.go
index b6177571..3c658100 100644
--- a/internal/wire/stream_frame_test.go
+++ b/internal/wire/stream_frame_test.go
@@ -14,7 +14,7 @@ func TestParseStreamFrameWithOffBit(t *testing.T) {
 	data := encodeVarInt(0x12345)                    // stream ID
 	data = append(data, encodeVarInt(0xdecafbad)...) // offset
 	data = append(data, []byte("foobar")...)
-	frame, l, err := parseStreamFrame(data, 0x8^0x4, protocol.Version1)
+	frame, l, err := ParseStreamFrame(data, 0x8^0x4, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamID(0x12345), frame.StreamID)
 	require.Equal(t, []byte("foobar"), frame.Data)
@@ -27,7 +27,7 @@ func TestParseStreamFrameRespectsLEN(t *testing.T) {
 	data := encodeVarInt(0x12345)           // stream ID
 	data = append(data, encodeVarInt(4)...) // data length
 	data = append(data, []byte("foobar")...)
-	frame, l, err := parseStreamFrame(data, 0x8^0x2, protocol.Version1)
+	frame, l, err := ParseStreamFrame(data, 0x8^0x2, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamID(0x12345), frame.StreamID)
 	require.Equal(t, []byte("foob"), frame.Data)
@@ -39,7 +39,7 @@ func TestParseStreamFrameRespectsLEN(t *testing.T) {
 func TestParseStreamFrameWithFINBit(t *testing.T) {
 	data := encodeVarInt(9) // stream ID
 	data = append(data, []byte("foobar")...)
-	frame, l, err := parseStreamFrame(data, 0x8^0x1, protocol.Version1)
+	frame, l, err := ParseStreamFrame(data, 0x8^0x1, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamID(9), frame.StreamID)
 	require.Equal(t, []byte("foobar"), frame.Data)
@@ -51,7 +51,7 @@ func TestParseStreamFrameWithFINBit(t *testing.T) {
 func TestParseStreamFrameAllowsEmpty(t *testing.T) {
 	data := encodeVarInt(0x1337)                  // stream ID
 	data = append(data, encodeVarInt(0x12345)...) // offset
-	f, l, err := parseStreamFrame(data, 0x8^0x4, protocol.Version1)
+	f, l, err := ParseStreamFrame(data, 0x8^0x4, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamID(0x1337), f.StreamID)
 	require.Equal(t, protocol.ByteCount(0x12345), f.Offset)
@@ -64,7 +64,7 @@ func TestParseStreamFrameRejectsOverflow(t *testing.T) {
 	data := encodeVarInt(0x12345)                                         // stream ID
 	data = append(data, encodeVarInt(uint64(protocol.MaxByteCount-5))...) // offset
 	data = append(data, []byte("foobar")...)
-	_, _, err := parseStreamFrame(data, 0x8^0x4, protocol.Version1)
+	_, _, err := ParseStreamFrame(data, 0x8^0x4, protocol.Version1)
 	require.EqualError(t, err, "stream data overflows maximum offset")
 }
 
@@ -72,7 +72,7 @@ func TestParseStreamFrameRejectsLongFrames(t *testing.T) {
 	data := encodeVarInt(0x12345)                                                // stream ID
 	data = append(data, encodeVarInt(uint64(protocol.MaxPacketBufferSize)+1)...) // data length
 	data = append(data, make([]byte, protocol.MaxPacketBufferSize+1)...)
-	_, _, err := parseStreamFrame(data, 0x8^0x2, protocol.Version1)
+	_, _, err := ParseStreamFrame(data, 0x8^0x2, protocol.Version1)
 	require.Equal(t, io.EOF, err)
 }
 
@@ -80,7 +80,7 @@ func TestParseStreamFrameRejectsFramesExceedingRemainingSize(t *testing.T) {
 	data := encodeVarInt(0x12345)           // stream ID
 	data = append(data, encodeVarInt(7)...) // data length
 	data = append(data, []byte("foobar")...)
-	_, _, err := parseStreamFrame(data, 0x8^0x2, protocol.Version1)
+	_, _, err := ParseStreamFrame(data, 0x8^0x2, protocol.Version1)
 	require.Equal(t, io.EOF, err)
 }
 
@@ -90,10 +90,10 @@ func TestParseStreamFrameErrorsOnEOFs(t *testing.T) {
 	data = append(data, encodeVarInt(0xdecafbad)...) // offset
 	data = append(data, encodeVarInt(6)...)          // data length
 	data = append(data, []byte("foobar")...)
-	_, _, err := parseStreamFrame(data, typ, protocol.Version1)
+	_, _, err := ParseStreamFrame(data, FrameType(typ), protocol.Version1)
 	require.NoError(t, err)
 	for i := range data {
-		_, _, err = parseStreamFrame(data[:i], typ, protocol.Version1)
+		_, _, err = ParseStreamFrame(data[:i], FrameType(typ), protocol.Version1)
 		require.Error(t, err)
 	}
 }
@@ -101,7 +101,7 @@ func TestParseStreamFrameErrorsOnEOFs(t *testing.T) {
 func TestParseStreamUsesBufferForLongFrames(t *testing.T) {
 	data := encodeVarInt(0x12345) // stream ID
 	data = append(data, bytes.Repeat([]byte{'f'}, protocol.MinStreamFrameBufferSize)...)
-	frame, l, err := parseStreamFrame(data, 0x8, protocol.Version1)
+	frame, l, err := ParseStreamFrame(data, 0x8, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamID(0x12345), frame.StreamID)
 	require.Equal(t, bytes.Repeat([]byte{'f'}, protocol.MinStreamFrameBufferSize), frame.Data)
@@ -115,7 +115,7 @@ func TestParseStreamUsesBufferForLongFrames(t *testing.T) {
 func TestParseStreamDoesNotUseBufferForShortFrames(t *testing.T) {
 	data := encodeVarInt(0x12345) // stream ID
 	data = append(data, bytes.Repeat([]byte{'f'}, protocol.MinStreamFrameBufferSize-1)...)
-	frame, l, err := parseStreamFrame(data, 0x8, protocol.Version1)
+	frame, l, err := ParseStreamFrame(data, 0x8, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamID(0x12345), frame.StreamID)
 	require.Equal(t, bytes.Repeat([]byte{'f'}, protocol.MinStreamFrameBufferSize-1), frame.Data)
diff --git a/internal/wire/streams_blocked_frame_test.go b/internal/wire/streams_blocked_frame_test.go
index ae8913ac..e49a124d 100644
--- a/internal/wire/streams_blocked_frame_test.go
+++ b/internal/wire/streams_blocked_frame_test.go
@@ -13,7 +13,7 @@ import (
 
 func TestParseStreamsBlockedFrameBidirectional(t *testing.T) {
 	data := encodeVarInt(0x1337)
-	f, l, err := parseStreamsBlockedFrame(data, bidiStreamBlockedFrameType, protocol.Version1)
+	f, l, err := parseStreamsBlockedFrame(data, FrameTypeBidiStreamBlocked, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamTypeBidi, f.Type)
 	require.EqualValues(t, 0x1337, f.StreamLimit)
@@ -22,7 +22,7 @@ func TestParseStreamsBlockedFrameBidirectional(t *testing.T) {
 
 func TestParseStreamsBlockedFrameUnidirectional(t *testing.T) {
 	data := encodeVarInt(0x7331)
-	f, l, err := parseStreamsBlockedFrame(data, uniStreamBlockedFrameType, protocol.Version1)
+	f, l, err := parseStreamsBlockedFrame(data, FrameTypeUniStreamBlocked, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, protocol.StreamTypeUni, f.Type)
 	require.EqualValues(t, 0x7331, f.StreamLimit)
@@ -31,11 +31,11 @@ func TestParseStreamsBlockedFrameUnidirectional(t *testing.T) {
 
 func TestParseStreamsBlockedFrameErrorsOnEOFs(t *testing.T) {
 	data := encodeVarInt(0x12345678)
-	_, l, err := parseStreamsBlockedFrame(data, bidiStreamBlockedFrameType, protocol.Version1)
+	_, l, err := parseStreamsBlockedFrame(data, FrameTypeBidiStreamBlocked, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, len(data), l)
 	for i := range data {
-		_, _, err := parseStreamsBlockedFrame(data[:i], bidiStreamBlockedFrameType, protocol.Version1)
+		_, _, err := parseStreamsBlockedFrame(data[:i], FrameTypeBidiStreamBlocked, protocol.Version1)
 		require.Equal(t, io.EOF, err)
 	}
 }
@@ -58,7 +58,7 @@ func TestParseStreamsBlockedFrameMaxStreamCount(t *testing.T) {
 			typ, l, err := quicvarint.Parse(b)
 			require.NoError(t, err)
 			b = b[l:]
-			frame, l, err := parseStreamsBlockedFrame(b, typ, protocol.Version1)
+			frame, l, err := parseStreamsBlockedFrame(b, FrameType(typ), protocol.Version1)
 			require.NoError(t, err)
 			require.Equal(t, f, frame)
 			require.Equal(t, len(b), l)
@@ -84,7 +84,7 @@ func TestParseStreamsBlockedFrameErrorOnTooLargeStreamCount(t *testing.T) {
 			typ, l, err := quicvarint.Parse(b)
 			require.NoError(t, err)
 			b = b[l:]
-			_, _, err = parseStreamsBlockedFrame(b, typ, protocol.Version1)
+			_, _, err = parseStreamsBlockedFrame(b, FrameType(typ), protocol.Version1)
 			require.EqualError(t, err, fmt.Sprintf("%d exceeds the maximum stream count", protocol.MaxStreamCount+1))
 		})
 	}
@@ -97,7 +97,7 @@ func TestWriteStreamsBlockedFrameBidirectional(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{bidiStreamBlockedFrameType}
+	expected := []byte{byte(FrameTypeBidiStreamBlocked)}
 	expected = append(expected, encodeVarInt(0xdeadbeefcafe)...)
 	require.Equal(t, expected, b)
 	require.Equal(t, int(f.Length(protocol.Version1)), len(b))
@@ -110,7 +110,7 @@ func TestWriteStreamsBlockedFrameUnidirectional(t *testing.T) {
 	}
 	b, err := f.Append(nil, protocol.Version1)
 	require.NoError(t, err)
-	expected := []byte{uniStreamBlockedFrameType}
+	expected := []byte{byte(FrameTypeUniStreamBlocked)}
 	expected = append(expected, encodeVarInt(0xdeadbeefcafe)...)
 	require.Equal(t, expected, b)
 	require.Equal(t, int(f.Length(protocol.Version1)), len(b))
diff --git a/packet_packer_test.go b/packet_packer_test.go
index 5f2ec0c1..748c061e 100644
--- a/packet_packer_test.go
+++ b/packet_packer_test.go
@@ -736,10 +736,15 @@ func TestPackLongHeaderPadToAtLeast4Bytes(t *testing.T) {
 	require.Equal(t, []byte{0, 0}, data[:2])
 	// ...followed by the PING frame
 	frameParser := wire.NewFrameParser(false, false)
-	l, frame, err := frameParser.ParseNext(data[2:], protocol.EncryptionHandshake, protocol.Version1)
+
+	frameType, lt, err := frameParser.ParseType(data[2:], protocol.EncryptionHandshake)
+	require.NoError(t, err)
+	require.Equal(t, 1, lt)
+	frame, l, err := frameParser.ParseLessCommonFrame(frameType, data[2+lt:], protocol.Version1)
 	require.NoError(t, err)
 	require.IsType(t, &wire.PingFrame{}, frame)
-	require.Equal(t, sealer.Overhead(), len(data)-2-l)
+	require.Zero(t, l)
+	require.Equal(t, sealer.Overhead(), len(data)-2-lt)
 }
 
 func TestPackShortHeaderPadToAtLeast4Bytes(t *testing.T) {
@@ -774,10 +779,15 @@ func TestPackShortHeaderPadToAtLeast4Bytes(t *testing.T) {
 
 	// ... followed by the STREAM frame
 	frameParser := wire.NewFrameParser(false, false)
-	frameLen, frame, err := frameParser.ParseNext(payload[1:], protocol.Encryption1RTT, protocol.Version1)
+	frameType, l, err := frameParser.ParseType(payload[1:], protocol.Encryption1RTT)
+	require.NoError(t, err)
+	require.Equal(t, 1, l)
+	require.True(t, frameType.IsStreamFrameType())
+
+	frame, frameLen, err := wire.ParseStreamFrame(payload[1+l:], frameType, protocol.Version1)
 	require.NoError(t, err)
 	require.Equal(t, f, frame)
-	require.Equal(t, len(payload)-1, frameLen)
+	require.Equal(t, len(payload)-2, frameLen)
 }
 
 func TestPackInitialProbePacket(t *testing.T) {