Why My Go API Was Slower Than Expected: A Performance Investigation
2025-06-12
TL;DR
I picked Go expecting fast performance out of the box. But my CreateUser
endpoint was taking 1.07 seconds, and I hadn’t even added email or OTP verification yet. Turns out, I was making two unnecessary DB round trips to check for duplicate email and phone entries. By letting Postgres handle those constraints and simplifying the logic, I reduced response time by 70%. Here’s the story.
Setting the Scene
Go is often praised for its performance, and I won’t dispute that. But what happens when a dev (like me) comes from a JavaScript background expecting magic out of the box?
I picked Go because it’s supposed to be fast, efficient, and developer-friendly for solo devs. But as I quickly learned, you still need to think about design choices. I was a little “delulu” thinking Go would handle everything for me.
My First API Design
The CreateUser
endpoint was supposed to be simple:
- Accept a payload with user details
- Check for duplicates (email, phone)
- Hash the password
- Save the user
- Generate a JWT token
- Respond to the client
Here’s the visual from my early design stage:
And here’s what I expected: Response time: ~400ms (it’s Go, right?) What I got: 1.07s, and that’s without sending verification emails or OTPs yet.
The First Look, Here’s the Code
func (s *UserService) CreateUser(params repository.CreateUserParams, ctx context.Context) (string, error) {
_, err := s.queries.GetUserByEmail(ctx, params.Email)
if err == nil {
return "", fmt.Errorf("user with email already exists")
}
_, err = s.queries.GetUserByPhoneNumber(ctx, params.PhoneNumber)
if err == nil {
return "", fmt.Errorf("user with phone number already exists")
}
if err != nil {
pkg.Logger().Error(common.INTERNAL_SYS_ERROR, zap.String("API Error", err.Error()))
return pkg.ErrorResponse(c, http.StatusInternalServerError, "An error occurred, try again later")
}
payload := &common.JWTPayload{
UserID: result.ID,
UserType: string(result.UserType),
PhoneNumber: result.PhoneNumber,
}
token, err := pkg.GenerateToken(*payload)
if err != nil {
pkg.Logger().Error(common.INTERNAL_SYS_ERROR, zap.String("TOKEN_ERROR", err.Error()))
return "", err
}
return token, nil
}
The password hashing was done in the handler (not shown here), and even with that, the whole thing still clocked at 1.07s. That felt… wrong.
First Optimization Attempts
I started by commenting out the hashing. New time: 1.02s — not great.
Then I dropped the bcrypt cost from 14 to 9. Slight improvement.
Security concern? Maybe. But bcrypt cost 9 is still generally acceptable for most apps. Depends on your risk tolerance.
Still, that didn’t fix the problem.
Rethinking the Flow
I looked deeper at the function.
Each GetUserBy...
call is a separate DB round trip:
Assuming it takes 300ms each (I know, I know. thats slow for now I am using Neon for dev enviroment, so this is a latency issue):
- Query by email: ~300ms
- Query by phone: ~300ms Total: 600ms wasted before even creating the user.
Coming from Node.js, I would’ve used:
const [email_exists, phone_exists] = Promise.all([
getUserByEmail(email),
getUserByPhone(phone)
]);
Go has goroutines, but using them just for a CreateUser
call felt like alot or maybe I don’t understand them at this stage. Then I thought:
What if I don’t query at all and let Postgres enforce the constraints?
Let Postgres Do the Work
I added helper functions to detect constraint violations based on Postgres error strings:
func IsDuplicateKeyError(err error, field string) bool {
errStr := err.Error()
switch field {
case "email":
return contains(errStr, "users_email_key") ||
contains(errStr, "duplicate key value violates unique constraint") && contains(errStr, "email")
case "phone":
return contains(errStr, "users_phone_key") || contains(errStr, "users_phone_number_key") ||
contains(errStr, "duplicate key value violates unique constraint") && contains(errStr, "phone")
}
return false
}
This allowed me to remove both pre checks and rely on a single insert. Here’s the updated service logic:
- _, err := s.queries.GetUserByEmail(ctx, params.Email)
- if err == nil {
- return "", fmt.Errorf("user with email already exists")
- }
-
- _, err = s.queries.GetUserByPhoneNumber(ctx, params.PhoneNumber)
- if err == nil {
- return "", fmt.Errorf("user with phone number already exists")
- }
result, err := s.queries.CreateUser(ctx, params)
if err != nil {
+ if pkg.IsDuplicateKeyError(err, "email") {
+ return "", fmt.Errorf("user with email already exists")
+ }
+ if pkg.IsDuplicateKeyError(err, "phone") {
+ return "", fmt.Errorf("user with phone number already exists")
+ }
pkg.Logger().Error(common.DB_ERROR, zap.String("DB_ERROR", err.Error()))
return "", err
}
The Result
Response time dropped by 70%.
This was still an early stage version and didn’t include OTP or email, but it felt like a real win. I didn’t need to go full optimization mode, I let the database do the work.
What’s Next
I’ll continue to document future iterations, especially as the endpoint grows more complex. There’s still work to do.
Conclusion
If you’re coming from JS or any high-level background and trying out Go for speed, don’t assume everything will be fast by default. Go gives you tools, but you still have to think carefully about performance, especially around DB access patterns.
Sometimes, the best optimization is not more code, it’s less.