Skip to content

crypto/tls, crypto/x509: binary size increases sharply starting with Go 1.24 #81132

Open

Description

@atdiar

Go version

Reproduced with:

  • go1.23.2
  • go1.24.13
  • go1.26.7
  • go1.27.0

Host: linux/amd64
Targets tested:

  • GOOS=linux GOARCH=amd64 CGO_ENABLED=0
  • GOOS=js GOARCH=wasm

For Go 1.24.13, 1.26.7, and 1.27.0, go env GOFIPS140 reports off.

No third-party dependencies are required.

What did you do?

I noticed a large binary-size increase when upgrading a Go application from Go 1.23 to Go 1.24. The standard-library part of the increase can be reproduced with a blank import of crypto/tls:

package main

import _ "crypto/tls"

func main() {}

For comparison, I tested four programs: empty, blank-import crypto/tls, blank-import crypto/x509, and blank-import net/http.

Reproduction script:

ROOT="$(mktemp -d)"
cd "$ROOT"

cat > go.mod <<'EOM'
module go-crypto-size-repro

go 1.23
EOM

mkdir empty tls x509 http

cat > empty/main.go <<'EOM'
package main
func main() {}
EOM

cat > tls/main.go <<'EOM'
package main
import _ "crypto/tls"
func main() {}
EOM

cat > x509/main.go <<'EOM'
package main
import _ "crypto/x509"
func main() {}
EOM

cat > http/main.go <<'EOM'
package main
import _ "net/http"
func main() {}
EOM

for TARGET in linux/amd64 js/wasm; do
    GOOS="${TARGET%/*}"
    GOARCH="${TARGET#*/}"

    echo
    echo "=== $GOOS/$GOARCH ==="
    printf '%-12s %12s %12s %12s %12s\n' VERSION empty tls x509 http

    for TC in go1.23.2 go1.24.13 go1.26.7 go1.27.0; do
        sizes=()
        for P in empty tls x509 http; do
            GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 \
            GOTOOLCHAIN="$TC" \
                go build -trimpath -ldflags='-s -w' \
                -o "$P-$TC-$GOOS-$GOARCH" "./$P"
            sizes+=("$(wc -c < "$P-$TC-$GOOS-$GOARCH")")
        done
        printf '%-12s %12d %12d %12d %12d\n' \
            "$TC" "${sizes[0]}" "${sizes[1]}" "${sizes[2]}" "${sizes[3]}"
    done
done

What did you see happen?

linux/amd64

Stripped binaries, CGO_ENABLED=0:

Version empty crypto/tls crypto/x509 net/http
go1.23.2 979,096 1,671,320 1,626,264 2,723,992
go1.24.13 1,007,800 2,384,056 2,293,944 3,231,928
go1.26.7 1,200,290 2,654,370 2,416,802 3,494,050
go1.27.0 1,216,672 2,699,424 2,474,144 3,539,104

Go 1.23.2 -> Go 1.24.13:

Program Change
empty +28,704 bytes (+2.93%)
crypto/tls +712,736 bytes (+42.65%)
crypto/x509 +667,680 bytes (+41.06%)
net/http +507,936 bytes (+18.65%)

Subtracting the empty-program size, the incremental package cost is:

Package Go 1.23.2 Go 1.24.13 Change
crypto/tls 692,224 1,376,256 +98.82%
crypto/x509 647,168 1,286,144 +98.73%
net/http 1,744,896 2,224,128 +27.46%

js/wasm

Version empty crypto/tls crypto/x509 net/http
go1.23.2 1,562,137 2,504,144 2,455,891 3,262,592
go1.24.13 1,567,379 3,758,954 3,623,080 4,168,698
go1.26.7 1,800,136 4,383,725 3,880,366 4,692,491
go1.27.0 1,855,363 4,484,580 3,990,654 4,797,695

Go 1.23.2 -> Go 1.24.13:

Program Change
empty +5,242 bytes (+0.34%)
crypto/tls +1,254,810 bytes (+50.11%)
crypto/x509 +1,167,189 bytes (+47.53%)
net/http +906,106 bytes (+27.77%)

Subtracting the empty-program size:

Package Go 1.23.2 Go 1.24.13 Change
crypto/tls 942,007 2,191,575 +132.65%
crypto/x509 893,754 2,055,701 +130.01%
net/http 1,700,455 2,601,319 +52.98%

So the discontinuity is not WASM-only, although it is particularly costly on WASM. It begins at the Go 1.23 -> Go 1.24 boundary and persists through Go 1.27.

What did you expect to see?

Some binary-size change between releases is expected, especially given the cryptographic changes in Go 1.24.

However, I did not expect the incremental cost of merely importing crypto/tls or crypto/x509 to approximately double on linux/amd64 and more than double on js/wasm while GOFIPS140=off.

Go 1.24 introduced the Go Cryptographic Module / FIPS 140-3 machinery. In the FIPS design discussion (#70123), the expected binary-size effect was described as small. Is all of the additional crypto implementation/type/init graph required when FIPS mode is off, or is some of it being retained unnecessarily?

Additional investigation

This was originally found in a larger js/wasm application. With otherwise identical source, a minimal document/application path grew approximately:

Version Size
go1.23.12 6.89 MB
go1.24.6 13.64 MB
go1.26.3 14.10 MB
go1.27.0 15.10 MB

Linker -dumpdep investigation showed:

  • no <ReflectMethod> retention in either Go 1.23 or Go 1.24;
  • a large increase in reachable symbols and UsedInIface types;
  • much of the additional Go 1.24 graph in net/http, crypto/tls, crypto/x509, and crypto/internal/fips140;
  • compiled application package archive sizes did not show a corresponding increase, suggesting that much of the increase appears at final-link reachability.

The small blank-import examples above are intended to isolate the standard-library component from that larger application.

Related issues

This appears distinct from #81067. That issue is a Go 1.27 WASM size regression associated with encoding/json / jsonv2; GOEXPERIMENT=nojsonv2 reportedly restores its previous size.

It also appears distinct from #78321, which concerned a 32 MiB FIPS DRBG BSS allocation increasing WASM initial linear memory in Go 1.26 and was fixed by statically excluding that code.

Also related, #62202 especially @aclements comment.

This issue concerns binary size, begins at the Go 1.23 -> Go 1.24 boundary, affects both linux/amd64 and js/wasm, and reproduces directly with crypto/tls or crypto/x509.

cc @FiloSottile

Activity

  1. dexhunter commented on Aug 26, 2026

    @dexhunter

    Would a focused investigation into avoidable final-link reachability be useful here? I can start from the four minimal programs in the report and compare Go 1.23.2, 1.24.13, and current master on linux/amd64 and js/wasm. I would use stripped binary bytes as the primary metric and keep FIPS-off and FIPS-enabled behavior as correctness controls.

    Before I build anything, should the scope be reducing the FIPS-related reachable graph when GOFIPS140=off, or is the Go 1.24 size increase an expected tradeoff that should be documented instead?

  2. atdiar commented on Aug 28, 2026

    @atdiar
    Author

    Hi Aiden, Hello @dexhunter,

    Regarding you first question, I think it would help. (if you can reproduce).
    As for your second question, not quite sure, I am trying to understand what changed exactly. If it is FIPS related, it should probably be documented, at least, before figuring out if there is a way to mitigate.

    If it ends up not being FIPS-related somehow, we can then figure out whether there is anything that could be done.

    I did try to inspect if there wasn't something getting in the way of DCE given the changes in 1.24 but could not find anything salient. Maybe you will have better luck.

  3. FiloSottile commented on Aug 28, 2026

    @FiloSottile
    Contributor

    I had a bot do a deeper bisect, and then looked at the top contributors.

    The main insight is that FIPS 140-3 init() self-tests made empty imports bring in a good chunk of the program, but that did not change the size of a program that actually uses the package.

    Here's the size of a program that does tls.Client(...).Handshake(). You can see the growth is not nearly as dramatic.

    Linux/amd64, CGO_ENABLED=0, -trimpath -ldflags='-s -w':

    Toolchain Size
    go1.23.2 3,571,864
    go1.24.13 3,977,400
    go1.26.7 4,239,522
    go1.27.0 4,268,192
    tip 4,186,272

    Here are the top contributors to a program that just imports crypto/x509, and how much they add to a program that does a TLS handshake.

    Commit Blank x509 TLS handshake
    c759ea7 FIPS check 73,728 B 4,096 B
    cf53137 DRBG CAST 32,768 B 0 B
    48d260c AES CAST 12,288 B 8,192 B
    566cf1c ECDH move 32,768 B 12,288 B
    03f075b ECDSA move 49,152 B 32,768 B
    45c6cc3 ECDSA CAST 65,536 B 8,192 B
    93fcd8f RSA move 20,480 B 20,480 B
    e8d9561 policy validation 212,992 B 45,056 B
    Total 499,712 B 131,072 B

    The X.509 policy validation is actual new code. The RSA move does add 12 KB of self-test key, which is annoying and might be worth trading some FIPS 104-3 mode-only init CPU time against.

    This investigation turned up a couple easy wins, that should reduce binary size by some 40 KB. I'll mail them soon.

    We can also drastically reduce the empty import cost of e8d9561 by removing the var anyPolicyOID = mustNewOIDFromInts() call from init time.

    In general though, I don't think importing but not using a package is particularly common or something we need to care about?

  4. gopherbot commented on Aug 28, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/824126 mentions this issue: crypto/rsa: reduce size of FIPS 140-3 CAST

  5. gopherbot commented on Aug 28, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/824125 mentions this issue: crypto/ecdh,crypto/ecdsa: reduce binary size by avoiding body duplication

  6. gopherbot commented on Aug 28, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/824124 mentions this issue: crypto/x509: avoid linking fmt on empty package imports

  7. atdiar commented on Aug 29, 2026

    @atdiar
    Author

    Thanks @FiloSottile . Yes the blank import is just for the reproducer.
    The issue was that the original wasm program was transitively importing those by importing net/http, x/net etc...
    The quasi doubling of binary size is unexpected.
    I will investigate further, I didn't think it was FIPS related and you are showing that this is not the main reason for what I observe.
    But seems the change was adjacent to the introduction of FIPS.

  8. prattmic commented on Sep 4, 2026

    @prattmic
    Member

    cc @golang/security

  9. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Sep 4, 2026
  10. FiloSottile commented on Sep 4, 2026

    @FiloSottile
    Contributor

    To be clear, the FIPS 140-3 mode support does make some code reachable from a blank import that would normally be linked only by a program that actually uses the package. However, I am not convinced that near-empty imports are a common case.

    To look at more real-world cases, we'd need the actual program, or a more realistic reproducer.

  11. atdiar commented on Sep 4, 2026

    @atdiar
    Author

    I understand but I think there might have been init changes that might interact with the linker phase and subsequently interact unfavorably with DCE. That can happen at any point within the import graph, not just a blank import.
    For us, it happens when importing net/http for instance.

    Can't really post the actual code yet but here is a more realistic reproducer:

    package main
    
    import "net/http"
    
    // A lazily-loading collection: cheap accessors that fetch on first use.
    // Nothing ever calls Size or performs a request; main only places a *Store
    // in an interface, as any map[string]any or registry would.
    type Store struct {
    	items  []string
    	loaded bool
    }
    
    func (s *Store) load() {
    	if s.loaded {
    		return
    	}
    	if resp, err := http.Get("https://example.invalid/items"); err == nil {
    		resp.Body.Close()
    	}
    	s.loaded = true
    }
    
    func (s *Store) Size() int {
    	s.load()
    	return len(s.items)
    }
    
    var sink any
    
    func main() {
    	sink = &Store{}
    }

    -trimpath -ldflags='-s -w', CGO_ENABLED=0, stripped bytes on my machine:

    go1.23.12 go1.24.13
    blank import _ "net/http", linux/amd64 2,736,280 3,236,024
    program above, linux/amd64 2,736,280 5,583,032
    blank import _ "net/http", js/wasm 3,268,126 4,169,107
    program above, js/wasm 3,268,678 9,645,497

    The real program is isomorphic: same code runs on the client (browser via wasm) and the server (server-side rendering).

    On 1.23 the program costs zero bytes over the blank import: Size, load, and
    the HTTP client are all dead code. On 1.24 the same lines cost +2,347,008
    bytes on amd64 and +5,476,390 on js/wasm, more than doubling the binary. Two
    controls on 1.24: with var sink *Store (no interface conversion) and with
    the method renamed to ItemCount() int, the program is within ~600 bytes of
    the blank import on both targets.

    Where the retention might come from, from -dumpdep on these builds (with the help of a little llm):

    • The init()-registered CASTs and the check package make hash-interface
      method calls (Size() int, BlockSize() int, ...) reachable from init;
      that's what lights up same-signature methods of unrelated types, as above.

    • var x = sync.OnceFunc/OnceValue(...) package vars (the lazy self-tests
      and the ecdsa curve constructors) build their closures at init, so the
      closures' whole callee graphs are retained on import while the pre-1.24 shape
      (a sync.Once inside the function) was invisible to a blank import ?

    • nistec's init decodes the 88 KB P-256 table; since inittasks reference
      imported packages' inittasks, any live init that transitively imports
      nistec keeps it.

    My real-world case: my wasm app's framework went from 6.9 to 13.6 MB.
    Explanation:
    The net/http import is entirely internal to the framework: main.go never touches it. By importing the framework, a client transitively links net/http, and with it crypto/tls and the whole 1.24 init graph, even if they don't use it (it is used by the framework for fetch requests, but arguably a client may remain local and not issue any).
    That is how it ends up inflating every app via the combination of FIPS init changes and of coarse DCE wrt method signatures but we can't change the latter yet).

    One more data point: fips140.Supported() rejects GOARCH=wasm, so on
    js/wasm the self-test/check graph can never run: it's retained purely by
    init reachability.

    I'll leave what to do about all this to you(don't want to touch crypto code, code owners should probably be better suited to take on this), happy to run measurements on any candidate fix however.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.binary-size

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions