diff --git a/java/src/com/google/template/soy/jbcsrc/TemplateCompiler.java b/java/src/com/google/template/soy/jbcsrc/TemplateCompiler.java
index 45b9d07cb1..1013cb2005 100644
--- a/java/src/com/google/template/soy/jbcsrc/TemplateCompiler.java
+++ b/java/src/com/google/template/soy/jbcsrc/TemplateCompiler.java
@@ -41,6 +41,7 @@
 import com.google.template.soy.jbcsrc.restricted.CodeBuilder;
 import com.google.template.soy.jbcsrc.restricted.Expression;
 import com.google.template.soy.jbcsrc.restricted.FieldRef;
+import com.google.template.soy.jbcsrc.restricted.LambdaFactory;
 import com.google.template.soy.jbcsrc.restricted.LocalVariable;
 import com.google.template.soy.jbcsrc.restricted.MethodRef;
 import com.google.template.soy.jbcsrc.restricted.MethodRefs;
@@ -66,7 +67,6 @@
 import com.google.template.soy.types.NullType;
 import com.google.template.soy.types.TemplateType;
 import com.google.template.soy.types.TemplateType.Parameter;
-import java.lang.invoke.LambdaMetafactory;
 import java.lang.invoke.MethodHandle;
 import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
@@ -153,18 +153,6 @@ void compile() {
     generateModifiableSelectMethod();
   }
 
-  private static final Handle METAFACTORY_HANDLE =
-      MethodRef.createPure(
-              LambdaMetafactory.class,
-              "metafactory",
-              MethodHandles.Lookup.class,
-              String.class,
-              MethodType.class,
-              MethodType.class,
-              MethodHandle.class,
-              MethodType.class)
-          .asHandle();
-
   private static final Handle DELEGATE_FACTORY_HANDLE =
       MethodRef.createPure(
               RecordToPositionalCallFactory.class,
@@ -176,9 +164,6 @@ void compile() {
               String[].class)
           .asHandle();
 
-  private static final String COMPILED_TEMPLATE_INIT_DESCRIPTOR =
-      Type.getMethodDescriptor(BytecodeUtils.COMPILED_TEMPLATE_TYPE);
-
   private static final Type COMPILED_TEMPLATE_RENDER_DESCRIPTOR =
       Type.getMethodType(
           BytecodeUtils.RENDER_RESULT_TYPE,
@@ -203,18 +188,7 @@ private void generateTemplateMethod(MethodRef templateMethod, MethodRef renderMe
     // assuming foo is the name of the template class.
     Statement methodBody =
         Statement.returnExpression(
-            new Expression(BytecodeUtils.COMPILED_TEMPLATE_TYPE) {
-              @Override
-              protected void doGen(CodeBuilder cb) {
-                cb.visitInvokeDynamicInsn(
-                    "render",
-                    COMPILED_TEMPLATE_INIT_DESCRIPTOR,
-                    METAFACTORY_HANDLE,
-                    COMPILED_TEMPLATE_RENDER_DESCRIPTOR,
-                    renderMethod.asHandle(),
-                    COMPILED_TEMPLATE_RENDER_DESCRIPTOR);
-              }
-            });
+            LambdaFactory.create(MethodRefs.COMPILED_TEMPLATE_RENDER, renderMethod).invoke());
     CodeBuilder methodWriter =
         new CodeBuilder(methodAccess(), templateMethod.method(), /* exceptions= */ null, writer);
     generateTemplateMetadata(methodWriter);
diff --git a/java/src/com/google/template/soy/jbcsrc/restricted/LambdaFactory.java b/java/src/com/google/template/soy/jbcsrc/restricted/LambdaFactory.java
new file mode 100644
index 0000000000..d1b06610ff
--- /dev/null
+++ b/java/src/com/google/template/soy/jbcsrc/restricted/LambdaFactory.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2023 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.template.soy.jbcsrc.restricted;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Arrays.asList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.template.soy.jbcsrc.restricted.Expression.Feature;
+import com.google.template.soy.jbcsrc.restricted.Expression.Features;
+import com.google.template.soy.jbcsrc.shared.ExtraConstantBootstraps;
+import java.lang.invoke.LambdaMetafactory;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import org.objectweb.asm.ConstantDynamic;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.Type;
+
+/**
+ * A helper for generating lambda callsites. Allows for some extra compile time checking and
+ * encapsulation.
+ */
+public final class LambdaFactory {
+
+  private static final Handle METAFACTORY_HANDLE =
+      MethodRef.createPure(
+              LambdaMetafactory.class,
+              "metafactory",
+              MethodHandles.Lookup.class,
+              String.class,
+              MethodType.class,
+              MethodType.class,
+              MethodHandle.class,
+              MethodType.class)
+          .asHandle();
+  private static final Handle CONDY_METAFACTORY =
+      MethodRef.createPure(
+              ExtraConstantBootstraps.class,
+              "constantMetafactory",
+              MethodHandles.Lookup.class,
+              String.class,
+              Class.class,
+              MethodType.class,
+              MethodHandle.class,
+              MethodType.class)
+          .asHandle();
+
+  /**
+   * Create a {@code LambdaFactory} that can create instances of an interface that implements {@code
+   * interfaceMethod} by delegating to {@code implMethod}.
+   */
+  public static LambdaFactory create(MethodRef interfaceMethod, MethodRef implMethod) {
+    checkState(interfaceMethod.isInterfaceMethod(), "Only interfaces are supported");
+    return new LambdaFactory(interfaceMethod.owner(), interfaceMethod, implMethod);
+  }
+
+  private final TypeInfo interfaceType;
+  private final String interfaceMethodName;
+  private final Type interfaceMethodType;
+  private final MethodRef implMethod;
+  private final String callSiteDescriptor;
+  private final ImmutableList<Type> boundParams;
+
+  private LambdaFactory(TypeInfo interfaceType, MethodRef interfaceMethod, MethodRef implMethod) {
+    this.interfaceType = interfaceType;
+    this.interfaceMethodName = interfaceMethod.method().getName();
+    this.implMethod = implMethod;
+    // Skip the first param as it is the receiver type.  MethodRef models 'receivers' as an argument
+    // but the jdk models them separately so from the perspective of the lambda-metafactory it isn't
+    // part of the interface method signature.
+    var interfaceMethodParams =
+        interfaceMethod.argTypes().subList(1, interfaceMethod.argTypes().size());
+    this.interfaceMethodType =
+        Type.getMethodType(
+            interfaceMethod.returnType(), interfaceMethodParams.toArray(new Type[0]));
+    // Lambdas are essentially an inner classes
+    // class Impl extends Interface {
+    // Impl(...boundParams) {...}
+    // method(..freeParams) { return target(..boundParams, ...freeParams);}
+    // }
+    // So all 'free' params must be in trailing argument position of the target method and exactly
+    // match the interface method parameter types.
+    // The JDK does allow for there to be some mismatch, as long as the mismatches are resolvable
+    // with simple type coercion rules (aka if the java cast operator could handle it, so could the
+    // lambda factory).  However, for now we require an exact match.
+    var trailingImplParams =
+        implMethod
+            .argTypes()
+            .subList(
+                implMethod.argTypes().size() - interfaceMethodParams.size(),
+                implMethod.argTypes().size());
+    checkArgument(
+        interfaceMethodParams.equals(trailingImplParams),
+        "trailing parameters of %s must match the free parameters of %s",
+        implMethod,
+        interfaceMethod);
+    this.boundParams =
+        implMethod
+            .argTypes()
+            .subList(0, implMethod.argTypes().size() - interfaceMethodParams.size());
+    this.callSiteDescriptor =
+        Type.getMethodDescriptor(interfaceType.type(), boundParams.toArray(new Type[0]));
+  }
+
+  public Expression invoke(Expression... args) {
+    return invoke(asList(args));
+  }
+
+  public Expression invoke(Iterable<Expression> args) {
+    Expression.checkTypes(boundParams, args);
+    var features = Features.of(Feature.NON_JAVA_NULLABLE, Feature.NON_SOY_NULLISH);
+    if (Expression.areAllCheap(args)) {
+      features = features.plus(Feature.CHEAP);
+    }
+    return new Expression(interfaceType.type(), features) {
+      @Override
+      protected void doGen(CodeBuilder cb) {
+        // When there are no bound parameters we can link this with condy instead of indy.
+        // According to Brian Goetz this should be faster to link even if the impls are identical
+        // See https://mail.openjdk.org/pipermail/amber-dev/2023-October/008327.html
+        if (boundParams.isEmpty()) {
+          cb.visitLdcInsn(
+              new ConstantDynamic(
+                  interfaceMethodName,
+                  interfaceType.type().getDescriptor(),
+                  CONDY_METAFACTORY,
+                  interfaceMethodType,
+                  implMethod.asHandle(),
+                  interfaceMethodType));
+
+        } else {
+          for (Expression arg : args) {
+            arg.gen(cb);
+          }
+          cb.visitInvokeDynamicInsn(
+              interfaceMethodName,
+              callSiteDescriptor,
+              METAFACTORY_HANDLE,
+              interfaceMethodType,
+              implMethod.asHandle(),
+              interfaceMethodType);
+        }
+      }
+    };
+  }
+}
diff --git a/java/src/com/google/template/soy/jbcsrc/restricted/MethodRef.java b/java/src/com/google/template/soy/jbcsrc/restricted/MethodRef.java
index 0d42ad72ee..d2c5d63f09 100644
--- a/java/src/com/google/template/soy/jbcsrc/restricted/MethodRef.java
+++ b/java/src/com/google/template/soy/jbcsrc/restricted/MethodRef.java
@@ -248,6 +248,10 @@ public Handle asHandle() {
         owner().isInterface());
   }
 
+  boolean isInterfaceMethod() {
+    return owner().isInterface() && opcode() == Opcodes.INVOKEINTERFACE;
+  }
+
   // TODO(lukes): consider different names.  'invocation'? invoke() makes it sounds like we are
   // actually calling the method rather than generating an expression that will output code that
   // will invoke the method.
diff --git a/java/src/com/google/template/soy/jbcsrc/shared/ExtraConstantBootstraps.java b/java/src/com/google/template/soy/jbcsrc/shared/ExtraConstantBootstraps.java
index 18ec9dd87e..f7a4570141 100644
--- a/java/src/com/google/template/soy/jbcsrc/shared/ExtraConstantBootstraps.java
+++ b/java/src/com/google/template/soy/jbcsrc/shared/ExtraConstantBootstraps.java
@@ -16,6 +16,7 @@
 package com.google.template.soy.jbcsrc.shared;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.lang.invoke.MethodType.methodType;
 import static java.util.Arrays.stream;
 
 import com.google.common.collect.ImmutableList;
@@ -27,7 +28,10 @@
 import com.google.template.soy.data.internal.ParamStore;
 import com.google.template.soy.data.internal.SoyMapImpl;
 import com.google.template.soy.data.internal.SoyRecordImpl;
+import java.lang.invoke.LambdaMetafactory;
+import java.lang.invoke.MethodHandle;
 import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
 
 /** Extra constant bootstrap methods. */
 public final class ExtraConstantBootstraps {
@@ -100,5 +104,27 @@ public static RecordProperty symbol(MethodHandles.Lookup lookup, String name, Cl
     return RecordProperty.get(name);
   }
 
+  // JDK has half implemented support for invoking lambdas via condy.
+  // There is a special case for this exact signature
+  // See BootstrapMethodInvoker+ isLambdaMetafactoryCondyBSM
+  // and https://bugs.openjdk.org/browse/JDK-8198418
+  // According to Brian Goetz this got de-staffed for priority and scope creep reasons but is still
+  // a good idea because condy linkage is cheaper than invokedynamic
+  // https://mail.openjdk.org/pipermail/amber-dev/2023-October/008327.html
+  @Keep
+  public static Object constantMetafactory(
+      MethodHandles.Lookup lookup,
+      String name,
+      Class<?> type,
+      MethodType samMethodType,
+      MethodHandle implMethod,
+      MethodType instantiatedMethodType)
+      throws Throwable {
+    return LambdaMetafactory.metafactory(
+            lookup, name, methodType(type), samMethodType, implMethod, instantiatedMethodType)
+        .getTarget()
+        .invoke();
+  }
+
   private ExtraConstantBootstraps() {}
 }
diff --git a/java/tests/com/google/template/soy/jbcsrc/restricted/LambdaFactoryTest.java b/java/tests/com/google/template/soy/jbcsrc/restricted/LambdaFactoryTest.java
new file mode 100644
index 0000000000..1f5c4990ce
--- /dev/null
+++ b/java/tests/com/google/template/soy/jbcsrc/restricted/LambdaFactoryTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2023 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.template.soy.jbcsrc.restricted;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.template.soy.jbcsrc.restricted.BytecodeUtils.constant;
+
+import com.google.template.soy.jbcsrc.restricted.testing.ExpressionEvaluator;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class LambdaFactoryTest {
+  // Interface methods to implement with a lambda
+  private static final MethodRef SUPPLIER_GET = MethodRef.createPure(Supplier.class, "get");
+  private static final MethodRef FUNCTION_APPLY =
+      MethodRef.createPure(Function.class, "apply", Object.class);
+  private static final MethodRef BIFUNCTION_APPLY =
+      MethodRef.createPure(BiFunction.class, "apply", Object.class, Object.class);
+
+  // Implementation referecences
+  private static final MethodRef IDENTITY_FUNCTION =
+      MethodRef.createPure(LambdaFactoryTest.class, "identity", Object.class);
+  private static final MethodRef RETURNS_HELLO_FUNCTION =
+      MethodRef.createPure(LambdaFactoryTest.class, "returnsHello");
+  private static final MethodRef CONCAT_FUNCTION =
+      MethodRef.createPure(LambdaFactoryTest.class, "concat", Object.class, Object.class);
+
+  // Actual implementations
+  public static Object identity(Object o) {
+    return o;
+  }
+
+  public static Object concat(Object o, Object o2) {
+    return o + ", " + o2;
+  }
+
+  public static Object returnsHello() {
+    return "hello";
+  }
+
+  @Test
+  public void testNoBoundParameters() throws ReflectiveOperationException {
+    @SuppressWarnings("unchecked")
+    Supplier<Object> supplier =
+        (Supplier)
+            ExpressionEvaluator.evaluate(
+                LambdaFactory.create(SUPPLIER_GET, RETURNS_HELLO_FUNCTION).invoke());
+
+    assertThat(supplier.get()).isEqualTo("hello");
+  }
+
+  @Test
+  public void testOneBundParameter() throws ReflectiveOperationException {
+    @SuppressWarnings("unchecked")
+    Supplier<Object> supplier =
+        (Supplier)
+            ExpressionEvaluator.evaluate(
+                LambdaFactory.create(SUPPLIER_GET, IDENTITY_FUNCTION).invoke(constant("bound")));
+    assertThat(supplier.get()).isEqualTo("bound");
+  }
+
+  @Test
+  public void testOneFreeParameter() throws ReflectiveOperationException {
+    @SuppressWarnings("unchecked")
+    Function<Object, Object> fn =
+        (Function)
+            ExpressionEvaluator.evaluate(
+                LambdaFactory.create(FUNCTION_APPLY, IDENTITY_FUNCTION).invoke());
+    assertThat(fn.apply("free")).isEqualTo("free");
+  }
+
+  @Test
+  public void testOneFreeAndOneBoundParameter() throws ReflectiveOperationException {
+    @SuppressWarnings("unchecked")
+    Function<Object, Object> fn =
+        (Function)
+            ExpressionEvaluator.evaluate(
+                LambdaFactory.create(FUNCTION_APPLY, CONCAT_FUNCTION).invoke(constant("first")));
+    assertThat(fn.apply("second")).isEqualTo("first, second");
+  }
+
+  @Test
+  public void testTwoFreeParameters() throws ReflectiveOperationException {
+    @SuppressWarnings("unchecked")
+    BiFunction<Object, Object, Object> fn =
+        (BiFunction)
+            ExpressionEvaluator.evaluate(
+                LambdaFactory.create(BIFUNCTION_APPLY, CONCAT_FUNCTION).invoke());
+    assertThat(fn.apply("first", "second")).isEqualTo("first, second");
+  }
+}